This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/agent_team/confluence/mermaid.py
Adam Moussa c7f9c1bac2 feat(agent-team): Confluence-writer node (draft -> approve gate -> write)
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
2026-06-25 10:56:48 -04:00

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,
)