Add a Confluence documentation lane to the Plane-2 pipeline, flag-gated behind AGENT_TEAM_CONFLUENCE_ENABLED (default off; daemon behavior unchanged when off). - confluence/client.py: OAuth 2LO + Basic REST client, dry-run-default writes - confluence/mermaid.py: vendored ADF-only Mermaid editor (macro-count + revert-diff guards, dry-run default) - nodes/confluence_writer.py(+_llm): conf_draft -> conf_gate -> conf_write, both direct (task_kind=confluence) and post-build documentation flows - task_model/graph/coordinator: new phases, state channels, route_after_intake, CONFLUENCE_APPROVAL_KIND gate delivery, task_kind forwarding - db schema v5: widen pending_questions kind CHECK (atomic rebuild) - tests for client, mermaid, writer node, ledger v5, coordinator gate, e2e
454 lines
18 KiB
Python
454 lines
18 KiB
Python
"""Vendored, ADF-only Mermaid architecture-map editor (design D7).
|
|
|
|
This module edits the Mermaid diagrams on the Confluence "AWS Architecture Map"
|
|
page (id 1540098) by surgically mutating the page's **ADF (Atlassian Document
|
|
Format) JSON** in memory — and ONLY the ADF. It NEVER reconstructs the page body
|
|
from a Markdown / storage-format round-trip.
|
|
|
|
Why ADF-only is load-bearing (carry this rationale forward — do not "simplify"):
|
|
A previous tooling approach edited the architecture map by round-tripping the
|
|
page through a Markdown/storage-format body. The Mermaid diagrams are stored
|
|
as Confluence *extension* macros (weweave / mermaid-cloud style nodes) whose
|
|
payload does not survive a lossy Markdown conversion, so that round-trip
|
|
**SILENTLY DELETED every diagram on page 1540098**. To make that class of
|
|
failure impossible, this editor:
|
|
|
|
* operates only on the parsed ADF document (a dict/JSON tree), never on a
|
|
flattened body string;
|
|
* counts the Mermaid macros before and after every edit
|
|
(:func:`count_mermaid_macros`) so a drop in macro count is detectable;
|
|
* produces a **revert-diff** alongside every edit so a change is provably
|
|
reversible, and a NO-OP edit set yields an EMPTY revert-diff (the safety
|
|
invariant the unit tests assert);
|
|
* defaults to **dry-run** (``apply=False``) so a planned change is producible
|
|
and inspectable without ever mutating the input document.
|
|
|
|
The macros this editor recognises are ADF ``extension`` / ``bodiedExtension`` /
|
|
``inlineExtension`` nodes whose ``extensionKey`` / ``extensionType`` identify a
|
|
Mermaid renderer (weweave "mermaid-cloud" and the common variants). Each such
|
|
macro carries the diagram source in its ``parameters`` (a ``macroParams`` map or
|
|
a raw ``body``); this module reads/replaces that source and nothing else.
|
|
|
|
The module is pure: no network, no external dependencies, no environment reads.
|
|
Every function is deterministic and unit-testable against in-memory ADF dicts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
__all__ = [
|
|
"MERMAID_EXTENSION_KEYS",
|
|
"MERMAID_EXTENSION_TYPES",
|
|
"ADFDocument",
|
|
"MermaidEdit",
|
|
"MermaidEditResult",
|
|
"RevertOp",
|
|
"count_mermaid_macros",
|
|
"iter_mermaid_macros",
|
|
"macro_diagram_source",
|
|
"macro_key",
|
|
"plan_mermaid_edits",
|
|
]
|
|
|
|
# An ADF document (or any ADF node) is a plain JSON object: a dict tree.
|
|
ADFDocument = dict[str, Any]
|
|
|
|
# Extension keys that identify a Mermaid macro. weweave's cloud renderer uses
|
|
# "mermaid-cloud"; other deployments use a bare "mermaid". Matching is
|
|
# case-insensitive (see :func:`_is_mermaid_macro`).
|
|
MERMAID_EXTENSION_KEYS: frozenset[str] = frozenset(
|
|
{"mermaid-cloud", "mermaid", "mermaid-diagram", "mermaidcloud"}
|
|
)
|
|
|
|
# extensionType namespaces a macro belongs to. weweave macros are namespaced
|
|
# under "com.weweave.*"; Confluence-native macros under "com.atlassian.*". An
|
|
# extension whose *type* names weweave/mermaid is treated as Mermaid even if a
|
|
# deployment renamed the key.
|
|
MERMAID_EXTENSION_TYPES: frozenset[str] = frozenset(
|
|
{
|
|
"com.weweave.mermaid",
|
|
"com.weweave.confluence.mermaid",
|
|
"com.atlassian.confluence.macro.core",
|
|
}
|
|
)
|
|
|
|
# ADF node types that can carry an extension macro.
|
|
_EXTENSION_NODE_TYPES: frozenset[str] = frozenset(
|
|
{"extension", "bodiedExtension", "inlineExtension"}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MermaidEdit:
|
|
"""A single requested diagram edit, addressed by a stable macro key.
|
|
|
|
``macro_key`` is the value :func:`macro_key` returns for the target macro
|
|
(its ``localId`` when present, else a deterministic positional key). It is
|
|
the *address* of the macro to edit; an edit whose key matches no macro is a
|
|
no-op for that key (recorded in :attr:`MermaidEditResult.unmatched_keys`).
|
|
|
|
``new_source`` is the replacement Mermaid diagram text. If it equals the
|
|
macro's current source the edit is a no-op and contributes nothing to the
|
|
revert-diff (the core safety invariant).
|
|
"""
|
|
|
|
macro_key: str
|
|
new_source: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RevertOp:
|
|
"""One reversible change: restore ``macro_key``'s source to ``old_source``.
|
|
|
|
A :class:`RevertOp` is emitted only for a macro whose source actually
|
|
changed. Applying every :class:`RevertOp` to the produced ``new_adf``
|
|
reconstructs the original document's diagram sources exactly, so the edit is
|
|
provably reversible.
|
|
"""
|
|
|
|
macro_key: str
|
|
old_source: str
|
|
new_source: str
|
|
|
|
|
|
@dataclass
|
|
class MermaidEditResult:
|
|
"""Outcome of :func:`plan_mermaid_edits`.
|
|
|
|
Attributes:
|
|
macro_count: Number of Mermaid macros found in the input document.
|
|
new_adf: The edited ADF document. In dry-run (``apply=False``) this is a
|
|
deep copy carrying the planned edits, and the caller's input is left
|
|
untouched. With ``apply=True`` it is still a deep copy (this module
|
|
never mutates its argument), but it is the document the caller is
|
|
expected to persist.
|
|
revert_diff: The reversible change-list. **EMPTY for a no-op edit set**
|
|
— the invariant the safety test asserts.
|
|
unmatched_keys: Edit keys that addressed no macro (surfaced, not raised,
|
|
so a partially-stale edit set is observable rather than silent).
|
|
skip_mermaid: ``True`` when the document has zero Mermaid macros, telling
|
|
the calling node to fall back to a storage-format body update instead
|
|
of an ADF Mermaid edit (design D7).
|
|
"""
|
|
|
|
macro_count: int
|
|
new_adf: ADFDocument
|
|
revert_diff: list[RevertOp] = field(default_factory=list)
|
|
unmatched_keys: list[str] = field(default_factory=list)
|
|
skip_mermaid: bool = False
|
|
|
|
@property
|
|
def changed(self) -> bool:
|
|
"""``True`` iff at least one macro's source was actually changed."""
|
|
return bool(self.revert_diff)
|
|
|
|
|
|
def _is_mermaid_macro(node: Any) -> bool:
|
|
"""Return ``True`` if ``node`` is an ADF extension node rendering Mermaid.
|
|
|
|
Recognition is intentionally permissive (key OR type matches a known Mermaid
|
|
identifier) and case-insensitive, so a deployment that renamed the key but
|
|
kept the weweave type — or vice versa — is still detected. A non-extension
|
|
node, or an extension that matches no Mermaid identifier, returns ``False``.
|
|
"""
|
|
if not isinstance(node, dict):
|
|
return False
|
|
if node.get("type") not in _EXTENSION_NODE_TYPES:
|
|
return False
|
|
attrs = node.get("attrs")
|
|
if not isinstance(attrs, dict):
|
|
return False
|
|
key = str(attrs.get("extensionKey", "")).strip().lower()
|
|
ext_type = str(attrs.get("extensionType", "")).strip().lower()
|
|
if key in {k.lower() for k in MERMAID_EXTENSION_KEYS}:
|
|
return True
|
|
if ext_type in {t.lower() for t in MERMAID_EXTENSION_TYPES}:
|
|
# A generic macro-core extension only counts as Mermaid if its
|
|
# parameters also name mermaid (avoids matching unrelated core macros).
|
|
if ext_type == "com.atlassian.confluence.macro.core":
|
|
return "mermaid" in key or _params_name_mermaid(attrs)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _params_name_mermaid(attrs: dict[str, Any]) -> bool:
|
|
"""Heuristic: does a generic macro's parameters identify it as Mermaid."""
|
|
params = attrs.get("parameters")
|
|
if not isinstance(params, dict):
|
|
return False
|
|
macro = params.get("macroMetadata") or params.get("macroParams") or {}
|
|
blob = str(params.get("macroName", "")) + str(macro)
|
|
return "mermaid" in blob.lower()
|
|
|
|
|
|
def iter_mermaid_macros(adf: ADFDocument) -> list[ADFDocument]:
|
|
"""Return every Mermaid macro node in ``adf`` in document order.
|
|
|
|
Walks the ADF ``content`` tree depth-first (the same order Confluence
|
|
renders), so positional keys are stable across calls on the same document.
|
|
Returns the live node objects from the passed tree (callers that need
|
|
isolation pass a copy — :func:`plan_mermaid_edits` does).
|
|
"""
|
|
found: list[ADFDocument] = []
|
|
_walk_collect(adf, found)
|
|
return found
|
|
|
|
|
|
def _walk_collect(node: Any, found: list[ADFDocument]) -> None:
|
|
"""Depth-first accumulate Mermaid macro nodes into ``found``."""
|
|
if isinstance(node, dict):
|
|
if _is_mermaid_macro(node):
|
|
found.append(node)
|
|
content = node.get("content")
|
|
if isinstance(content, list):
|
|
for child in content:
|
|
_walk_collect(child, found)
|
|
elif isinstance(node, list):
|
|
for child in node:
|
|
_walk_collect(child, found)
|
|
|
|
|
|
def count_mermaid_macros(adf: ADFDocument) -> int:
|
|
"""Count the Mermaid macros (extension nodes) in an ADF document.
|
|
|
|
The pre/post macro-count verification: the calling node compares this before
|
|
and after an edit (and against the known expected count — 16 on page
|
|
1540098) so a round-trip that drops diagrams is caught rather than silently
|
|
accepted.
|
|
"""
|
|
return len(iter_mermaid_macros(adf))
|
|
|
|
|
|
def macro_key(node: ADFDocument, *, index: int) -> str:
|
|
"""Return a stable address for ``node`` (its ``localId`` else positional).
|
|
|
|
ADF extension nodes usually carry a ``localId`` (stable across edits) — that
|
|
is the preferred key. When absent, a deterministic positional key
|
|
``"#<index>"`` (document order from :func:`iter_mermaid_macros`) is used so
|
|
every macro is still addressable. The ``index`` is the macro's position
|
|
among Mermaid macros, not among all ADF nodes.
|
|
"""
|
|
local_id = node.get("attrs", {}).get("localId") if isinstance(node, dict) else None
|
|
if isinstance(local_id, str) and local_id.strip():
|
|
return local_id.strip()
|
|
return f"#{index}"
|
|
|
|
|
|
def macro_diagram_source(node: ADFDocument) -> str:
|
|
"""Read the Mermaid diagram source out of a macro node.
|
|
|
|
weweave stores the diagram text in different slots depending on macro
|
|
flavour; this checks them in priority order:
|
|
|
|
* ``attrs.parameters.macroParams.code.value`` (weweave cloud),
|
|
* ``attrs.parameters.macroParams.<first param>.value`` fallback,
|
|
* ``attrs.text`` (some inline extensions),
|
|
* the ``bodiedExtension`` text content.
|
|
|
|
Returns ``""`` when no source slot is present (a malformed/empty macro),
|
|
never raising — a missing source is a no-op target, not a crash.
|
|
"""
|
|
if not isinstance(node, dict):
|
|
return ""
|
|
attrs = node.get("attrs")
|
|
if isinstance(attrs, dict):
|
|
params = attrs.get("parameters")
|
|
if isinstance(params, dict):
|
|
macro_params = params.get("macroParams")
|
|
if isinstance(macro_params, dict):
|
|
code = macro_params.get("code")
|
|
if isinstance(code, dict) and "value" in code:
|
|
return str(code["value"])
|
|
for value in macro_params.values():
|
|
if isinstance(value, dict) and "value" in value:
|
|
return str(value["value"])
|
|
text = attrs.get("text")
|
|
if isinstance(text, str):
|
|
return text
|
|
# bodiedExtension carries source in its content text nodes.
|
|
return _bodied_text(node)
|
|
|
|
|
|
def _bodied_text(node: dict[str, Any]) -> str:
|
|
"""Concatenate text nodes inside a bodiedExtension's content (if any)."""
|
|
content = node.get("content")
|
|
if not isinstance(content, list):
|
|
return ""
|
|
parts: list[str] = []
|
|
for child in content:
|
|
_collect_text(child, parts)
|
|
return "".join(parts)
|
|
|
|
|
|
def _collect_text(node: Any, parts: list[str]) -> None:
|
|
"""Depth-first gather ``text`` node values."""
|
|
if isinstance(node, dict):
|
|
if node.get("type") == "text" and isinstance(node.get("text"), str):
|
|
parts.append(node["text"])
|
|
child_content = node.get("content")
|
|
if isinstance(child_content, list):
|
|
for child in child_content:
|
|
_collect_text(child, parts)
|
|
|
|
|
|
def _set_diagram_source(node: dict[str, Any], new_source: str) -> bool:
|
|
"""Write ``new_source`` into the macro's source slot in place.
|
|
|
|
Mirrors the slot priority of :func:`macro_diagram_source`. Returns ``True``
|
|
if a slot was written, ``False`` if no writable slot exists (the macro is
|
|
then recorded as unmatched, never silently dropped). Operates on ``node`` in
|
|
place — callers pass a node belonging to a deep-copied tree.
|
|
"""
|
|
attrs = node.get("attrs")
|
|
if isinstance(attrs, dict):
|
|
params = attrs.get("parameters")
|
|
if isinstance(params, dict):
|
|
macro_params = params.get("macroParams")
|
|
if isinstance(macro_params, dict):
|
|
code = macro_params.get("code")
|
|
if isinstance(code, dict):
|
|
code["value"] = new_source
|
|
return True
|
|
for value in macro_params.values():
|
|
if isinstance(value, dict) and "value" in value:
|
|
value["value"] = new_source
|
|
return True
|
|
if isinstance(attrs.get("text"), str):
|
|
attrs["text"] = new_source
|
|
return True
|
|
return _set_bodied_text(node, new_source)
|
|
|
|
|
|
def _set_bodied_text(node: dict[str, Any], new_source: str) -> bool:
|
|
"""Replace the first text node inside a bodiedExtension's content."""
|
|
content = node.get("content")
|
|
if not isinstance(content, list):
|
|
return False
|
|
for child in content:
|
|
if _replace_first_text(child, new_source):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _replace_first_text(node: Any, new_source: str) -> bool:
|
|
"""Depth-first: set the first ``text`` node found to ``new_source``."""
|
|
if isinstance(node, dict):
|
|
if node.get("type") == "text" and isinstance(node.get("text"), str):
|
|
node["text"] = new_source
|
|
return True
|
|
child_content = node.get("content")
|
|
if isinstance(child_content, list):
|
|
for child in child_content:
|
|
if _replace_first_text(child, new_source):
|
|
return True
|
|
return False
|
|
|
|
|
|
def plan_mermaid_edits(
|
|
adf: ADFDocument,
|
|
edits: list[MermaidEdit] | None = None,
|
|
*,
|
|
apply: bool = False,
|
|
) -> MermaidEditResult:
|
|
"""Plan (and optionally apply) a set of Mermaid diagram edits to ``adf``.
|
|
|
|
This is the editor's single entry point. It NEVER mutates the passed ``adf``
|
|
(it works on a deep copy) and defaults to **dry-run** (``apply=False``): the
|
|
returned :class:`MermaidEditResult` carries the planned ``new_adf`` and a
|
|
``revert_diff`` without the caller persisting anything.
|
|
|
|
Safety invariants (asserted by the unit tests):
|
|
|
|
* A **NO-OP edit set** (no edits, or edits whose ``new_source`` matches the
|
|
current source) yields an **EMPTY** ``revert_diff`` and ``changed=False``.
|
|
* The Mermaid ``macro_count`` is computed from the input and exposed so the
|
|
caller can compare it to the post-edit count (they must be equal — an ADF
|
|
edit can never drop a macro) and to the expected count for the page.
|
|
* If the document has **zero Mermaid macros**, ``skip_mermaid=True`` so the
|
|
node falls back to a storage-format body update (design D7) instead of an
|
|
empty ADF edit.
|
|
|
|
Args:
|
|
adf: The page's ADF document (a dict tree). Not mutated.
|
|
edits: Diagram edits addressed by :func:`macro_key`. ``None``/empty means
|
|
"verify only" — count macros, produce an empty revert-diff.
|
|
apply: Dry-run default. ``True`` signals the caller intends to persist
|
|
``new_adf``; this function still only returns the edited copy (it
|
|
performs no I/O), but the flag is recorded so the boundary is
|
|
explicit and the dry-run path is the default everywhere.
|
|
|
|
Returns:
|
|
A :class:`MermaidEditResult`.
|
|
"""
|
|
macros_in = iter_mermaid_macros(adf)
|
|
macro_count = len(macros_in)
|
|
|
|
# Zero macros -> tell the node to fall back to a storage-format body update,
|
|
# rather than attempting (and "succeeding" at) an empty ADF edit (D7).
|
|
if macro_count == 0:
|
|
return MermaidEditResult(
|
|
macro_count=0,
|
|
new_adf=copy.deepcopy(adf),
|
|
revert_diff=[],
|
|
unmatched_keys=[edit.macro_key for edit in (edits or [])],
|
|
skip_mermaid=True,
|
|
)
|
|
|
|
# Work on a deep copy so the caller's document is never mutated, even with
|
|
# apply=True (this module performs no persistence; the caller does).
|
|
new_adf = copy.deepcopy(adf)
|
|
|
|
edit_list = list(edits or [])
|
|
if not edit_list:
|
|
# Verify-only: no edits requested. Empty revert-diff by construction.
|
|
return MermaidEditResult(
|
|
macro_count=macro_count,
|
|
new_adf=new_adf,
|
|
revert_diff=[],
|
|
unmatched_keys=[],
|
|
skip_mermaid=False,
|
|
)
|
|
|
|
# Index the copy's macros by stable key (document order matches the input,
|
|
# so positional keys line up with the keys the caller derived from `adf`).
|
|
macros_out = iter_mermaid_macros(new_adf)
|
|
by_key: dict[str, ADFDocument] = {}
|
|
for index, node in enumerate(macros_out):
|
|
by_key.setdefault(macro_key(node, index=index), node)
|
|
|
|
revert_diff: list[RevertOp] = []
|
|
unmatched_keys: list[str] = []
|
|
|
|
for edit in edit_list:
|
|
target = by_key.get(edit.macro_key)
|
|
if target is None:
|
|
unmatched_keys.append(edit.macro_key)
|
|
continue
|
|
old_source = macro_diagram_source(target)
|
|
if edit.new_source == old_source:
|
|
# No-op edit: identical source contributes nothing to the
|
|
# revert-diff (the safety invariant).
|
|
continue
|
|
if not _set_diagram_source(target, edit.new_source):
|
|
# Macro has no writable source slot; record as unmatched rather than
|
|
# silently dropping the change.
|
|
unmatched_keys.append(edit.macro_key)
|
|
continue
|
|
revert_diff.append(
|
|
RevertOp(
|
|
macro_key=edit.macro_key,
|
|
old_source=old_source,
|
|
new_source=edit.new_source,
|
|
)
|
|
)
|
|
|
|
return MermaidEditResult(
|
|
macro_count=macro_count,
|
|
new_adf=new_adf,
|
|
revert_diff=revert_diff,
|
|
unmatched_keys=unmatched_keys,
|
|
skip_mermaid=False,
|
|
)
|