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/nodes/confluence_writer.py
Adam Moussa f56ee1c58d harden(agent-team): identity-aware macro-preservation guard
Address the verifier's residual on the macro-loss fix: the guard was raw-count
based, so a body that DROPPED the real Mermaid macro while ADDING an unrelated
macro (equal count) could slip through. Replace count_storage_macros in the
guard with storage_macro_signature (per-ac:name multiset) and refuse if ANY
macro identity loses occurrences. +2 tests.
2026-06-25 11:22:07 -04:00

772 lines
33 KiB
Python

"""Confluence-writer pipeline stage — draft -> human gate -> (dry-run) write.
Three LangGraph nodes plus a router that document a task's work on Confluence,
gated by a human exactly like the plan gate (design §3.3 human-in-the-loop):
... -> CONF_DRAFT -> CONF_GATE -> {CONF_WRITE | back to CONF_DRAFT | terminal}
* :func:`conf_draft_node` — an **AGENTIC** Claude call that inspects the repo
(read-only) and emits a Confluence page update as DATA (a ``confluence_draft``
dict). It writes nothing to the repo. Source: Flow A uses ``state['task']``;
Flow B folds in ``state['plan']`` + ``state['candidate_diff']`` + the repo
name. On a ``request_changes`` loop-back the prior ``confluence_feedback`` is
folded into the prompt.
* :func:`conf_gate_node` — the resumable HUMAN GATE. Mirrors
:func:`agent_team.graph.plan_gate_node` VERBATIM in shape: a ceiling guard
FIRST (cap on ``confluence_gate_visits`` -> terminal PARKED), then an
``interrupt()`` carrying a stable ``question_id`` derived from a ``turn`` in a
HIGH namespace disjoint from BOTH the clarifier and the plan gate, a deadline,
and a ``kind`` discriminator. On resume it parses the decision verb and routes
approve / request_changes / abandon.
* :func:`conf_write_node` — performs the write through the injected Confluence
client. **DRY-RUN by default**: a live write is issued ONLY when an explicit
apply flag is set (``config['confluence_apply']`` or env
``AGENT_TEAM_CONFLUENCE_APPLY`` truthy) AND the gate approved; otherwise it
composes the planned change + a revert diff and writes NOTHING.
* :func:`route_after_conf_gate` — the conditional-edge function mirroring
:func:`agent_team.graph.route_after_plan_gate`.
The HTTP/Confluence I/O is an INJECTED seam: :func:`conf_write_node` takes an
optional ``client`` (defaulting to a real :class:`ConfluenceClient`) so tests
pass an in-memory fake and no live network is touched on the paths tests hit.
The Confluence package import is DEFERRED to call time (and only on the live
node) so this module imports cleanly before that package exists. Secrets/creds
are read from ``os.environ`` at call time, never at module load.
Graph-wiring constants this module references by NAME (the graph-agent owns
``graph.py`` and must define / wire these):
* ``CONFLUENCE_APPROVAL_KIND`` — the interrupt-payload ``kind`` discriminator
(defined here with the literal value ``"confluence_approval"``; the graph may
re-export it). The graph must keep its value in sync.
* The CONF_DRAFT / CONF_GATE / CONF_WRITE phase values — this module emits the
:class:`~agent_team.task_model.Phase` ``.value`` strings the graph routes on.
See :data:`CONF_DRAFT_PHASE` / :data:`CONF_GATE_PHASE` / :data:`CONF_WRITE_PHASE`
/ :data:`CONF_DONE_PHASE` and the note in the returned summary.
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from langgraph.types import interrupt
from agent_team.billing import ClaudeResult, claude_invoke
from agent_team.transport import QuestionSet
from agent_team.nodes.confluence_writer_llm import (
build_confluence_prompt,
parse_confluence_reply,
)
from agent_team.task_model import Phase, PipelineState, TaskStatus
if TYPE_CHECKING: # pragma: no cover - typing only; package may not exist yet
from agent_team.confluence.client import ConfluenceClient
__all__ = [
"CONFLUENCE_APPROVAL_KIND",
"MAX_CONFLUENCE_GATE_VISITS",
"ConfluenceWriteError",
"conf_draft_node",
"conf_gate_node",
"conf_write_node",
"route_after_conf_gate",
]
# -- Graph-shared constants (see module docstring). --------------------------
# Interrupt-payload discriminator telling the responder/ledger this is a
# Confluence-approval gate (vs the clarifier question-set / plan-decision gate).
# The graph-agent defines the authoritative constant; this local fallback keeps
# the node self-contained and importable before graph.py wires it. KEEP THE
# VALUE IN SYNC with graph.PLAN_DECISION_KIND's sibling.
try: # pragma: no cover - exercised only once the graph defines it
from agent_team.graph import CONFLUENCE_APPROVAL_KIND # type: ignore
except Exception: # noqa: BLE001 - graph may not define it yet on this branch
CONFLUENCE_APPROVAL_KIND = "confluence_approval"
# Combined ceiling on Confluence-gate visits (bounded termination, mirrors
# graph.MAX_PLAN_GATE_VISITS). A human ``request_changes`` re-enters the draft
# stage, which gates AGAIN; once the visit count reaches this cap the gate stops
# offering request_changes and the task goes terminal PARKED so the human loop
# always terminates.
MAX_CONFLUENCE_GATE_VISITS = 3
# Per-call turn headroom for the draft call. This is a reasoning->JSON node (it
# turns the task/plan/diff already in state into a documentation draft), so it
# follows the SAME shape as the planner/clarifier: a few turns, NO tools. The
# subscription invoker's single-shot default (max_turns=1) crashes the reasoning
# nodes ("Reached maximum number of turns (1)"), so the planner uses max_turns=4;
# we match it.
#
# Deliberately NOT agentic + NOT tool-using: the merged #60 fix (PR #61) proved
# live that giving a generative node read-only tools + a higher turn cap just
# moves the failure — the tool session exhausts the cap reading files before it
# emits output, or returns narration instead of the artifact (observed at
# max_turns=8). The lesson (memory feedback_claude_sdk_single_shot): reasoning
# nodes run tools-off; any repo context the draft needs is gathered
# deterministically INTO the prompt (build_confluence_prompt folds in plan +
# candidate_diff), never via Claude tool calls.
_DRAFT_MAX_TURNS = 4
# Phase .value strings this stage routes on. Now that task_model defines dedicated
# CONF_* phases, the stage records them directly (durable current_phase reflects
# the real lane in the ledger / transitions / dashboard instead of borrowing
# VERIFY/REVIEW/BUILD).
CONF_DRAFT_PHASE = Phase.CONF_DRAFT.value
CONF_GATE_PHASE = Phase.CONF_GATE.value
CONF_WRITE_PHASE = Phase.CONF_WRITE.value
CONF_DONE_PHASE = Phase.DONE.value
# Disjoint namespace base for the Confluence gate's stable ``turn`` derivation.
# The clarifier numbers turns 0..N (qa_history length); the plan gate uses
# 1_000_000 + visits (graph._PLAN_GATE_TURN_BASE). This base is HIGHER and
# distinct from BOTH so a Confluence-gate turn can never collide with either for
# the same thread (the ResumeWorker turn guard matches a resume to its open
# interrupt by turn).
_CONF_GATE_TURN_BASE = 2_000_000
# Fixed namespace for deriving a STABLE question_id from (thread_id, turn) —
# uuid5 so the id is uuid-shaped yet deterministic across the resume replay of
# the node (mirrors graph._QUESTION_ID_NAMESPACE; a distinct namespace so a
# Confluence-gate id never collides with a clarifier/plan-gate id).
_CONF_QUESTION_ID_NAMESPACE = uuid.UUID("c0f1e2d3-4a5b-6c7d-8e9f-0a1b2c3d4e5f")
# Default open window for a Confluence-gate question (mirrors
# graph.DEFAULT_CLARIFY_DEADLINE).
_DEFAULT_CONF_DEADLINE = timedelta(hours=24)
class ConfluenceWriteError(Exception):
"""Raised when the live Confluence write cannot be performed.
Distinct from a draft-parse failure (:class:`ConfluenceDraftError`) and from
a gate rejection: this is an I/O-time failure on the apply path, so the
coordinator can fail/park the task rather than mark it documented.
"""
# -- small helpers (mirror graph.py). ----------------------------------------
def _utc_now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string (ledger-compatible)."""
return datetime.now(timezone.utc).isoformat()
def _question_id_for(thread_id: str, turn: int) -> str:
"""Return the stable Confluence-gate question_id for ``(thread_id, turn)``."""
return uuid.uuid5(_CONF_QUESTION_ID_NAMESPACE, f"{thread_id}:{turn}").hex
def _conf_gate_turn(visits: int) -> int:
"""Return the stable gate ``turn`` for the ``visits``-th Confluence-gate visit.
Offset into a high, disjoint namespace (:data:`_CONF_GATE_TURN_BASE`) so a
Confluence-gate turn can never collide with a clarifier turn (qa_history
length) or a plan-gate turn (1_000_000 + visits) for the same thread.
Monotonic in ``visits`` so each successive suspend has its own stable
``(thread_id, turn)`` identity.
"""
return _CONF_GATE_TURN_BASE + visits
def _truthy(value: Any) -> bool:
"""Interpret a config/env flag as a boolean (env strings are case-folded)."""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _draft_preview(draft: dict[str, Any]) -> str:
"""Render a short human-readable preview of the draft for the gate payload."""
title = str(draft.get("title", "(untitled)"))
page_id = draft.get("page_id")
target = f"update page {page_id}" if page_id else "create new page"
body = str(draft.get("body_storage", ""))
snippet = body[:400] + ("..." if len(body) > 400 else "")
mermaid = draft.get("mermaid_edits") or []
lines = [f"Title: {title}", f"Action: {target}"]
if mermaid:
lines.append(f"Mermaid edits: {len(mermaid)}")
lines.append("")
lines.append(snippet)
return "\n".join(lines)
# Decision options the human picks from at the Confluence approval gate. Mirrors
# the plan gate's approve / request_changes / abandon surface so a downstream
# coordinator handler can render the three decision buttons.
_CONF_DECISION_OPTIONS: tuple[str, ...] = ("approve", "request_changes", "abandon")
def _conf_approval_question_set(
*, thread_id: str, question_id: str, turn: int, preview: str
) -> QuestionSet:
"""Build the QuestionSet carried in the CONF_GATE interrupt payload.
The coordinator's notify path requires a ``question_set`` so the inbound
answer maps back to ``question_id`` (mirrors
:meth:`coordinator._plan_decision_question_set`). The single question's
prompt is the human-readable draft ``preview`` followed by the
approve / request_changes / abandon options; ``context`` carries the
``kind`` discriminator (:data:`CONFLUENCE_APPROVAL_KIND`) so the transport
renders decision buttons instead of generic question blocks.
"""
options = " / ".join(_CONF_DECISION_OPTIONS)
prompt = (
f"{preview}\n\n"
f"Approve, request changes, or abandon this Confluence update? ({options})"
)
return QuestionSet(
thread_id=thread_id,
question_id=question_id,
turn=turn,
questions=[prompt],
context={
"kind": CONFLUENCE_APPROVAL_KIND,
"options": list(_CONF_DECISION_OPTIONS),
"presentation": preview,
},
)
# -- Node 1: CONF_DRAFT (agentic). -------------------------------------------
def conf_draft_node(
state: PipelineState, config: dict[str, Any] | None = None
) -> PipelineState:
"""CONF_DRAFT stage: reasoning over in-state context -> Confluence draft (as DATA).
Builds the draft prompt (Flow A from ``state['task']``; Flow B folding in
``plan`` + ``candidate_diff`` + repo name; ``confluence_feedback`` folded in
on a ``request_changes`` loop-back), then makes ONE single-shot, **tool-less**
Claude call (``max_turns=4``) that turns that context into a documentation
draft. This is a reasoning->JSON node like the planner: it does NOT read the
repository agentically — the merged #60 fix (PR #61) proved live that handing
a generative node read-only tools just exhausts the turn cap or returns
narration. Any repo context the draft needs is folded into the prompt by
:func:`build_confluence_prompt`, never fetched via tools.
Returns a **partial** :class:`PipelineState`: the parsed ``confluence_draft``
plus the phase advanced to the Confluence gate and ``status`` ACTIVE.
``config`` is forwarded to the billing seam so the caller can pin the billing
mode; it is threaded through to :func:`claude_invoke` as ``config``.
"""
prompt = build_confluence_prompt(state)
result: ClaudeResult = claude_invoke(
prompt,
config=config,
max_turns=_DRAFT_MAX_TURNS,
)
draft = parse_confluence_reply(result.text)
return PipelineState(
confluence_draft=draft, # type: ignore[typeddict-unknown-key]
current_phase=CONF_GATE_PHASE,
status=TaskStatus.ACTIVE.value,
updated_at=_utc_now_iso(),
)
# -- Node 2: CONF_GATE (resumable human gate; mirrors plan_gate_node). --------
def conf_gate_node(
state: PipelineState, config: dict[str, Any] | None = None
) -> PipelineState:
"""CONF_GATE stage: the resumable human approval of the Confluence draft.
Mirrors :func:`agent_team.graph.plan_gate_node` in shape:
* **Ceiling guard FIRST.** If ``confluence_gate_visits`` already reached
:data:`MAX_CONFLUENCE_GATE_VISITS` the gate does NOT interrupt: it returns
terminal PARKED with a ``failure_reason``, so every suspend strictly
consumes one of a finite number of visits and the human loop terminates.
* **Suspend.** Otherwise it bumps the visit count and ``interrupt()``s with a
payload carrying ``thread_id``, a stable ``question_id`` (from a ``turn``
in a HIGH namespace disjoint from the clarifier AND the plan gate), the
``turn``, ``kind`` = :data:`CONFLUENCE_APPROVAL_KIND`, ``transport``,
``deadline``, ``slack_thread_ts``, the ``confluence_draft``, and a
human-readable ``preview``.
On resume, ``interrupt()`` returns the decision (the value passed to
``Command(resume=...)``). The verb is parsed via the shared safe normalizer
(unrecognized -> ``request_changes``, like the plan gate's
``_parse_decision``):
* ``approve`` -> phase CONF_WRITE, status ACTIVE;
* ``request_changes`` -> phase CONF_DRAFT, status ACTIVE, ``confluence_feedback``
set to the notes, ``confluence_gate_visits`` bumped (loop back to redraft);
* ``abandon`` -> terminal FAILED with a ``failure_reason``.
"""
prior_visits = int(state.get("confluence_gate_visits", 0) or 0) # type: ignore[call-overload]
if prior_visits >= MAX_CONFLUENCE_GATE_VISITS:
return PipelineState(
status=TaskStatus.PARKED.value,
current_phase=Phase.PARKED.value,
failure_reason=(
"confluence-gate revision ceiling reached "
f"({prior_visits}/{MAX_CONFLUENCE_GATE_VISITS} gate visits)"
),
updated_at=_utc_now_iso(),
)
visits = prior_visits + 1
thread_id = state.get("thread_id", "")
transport = state.get("transport", "")
slack_thread_ts = state.get("slack_thread_ts", "")
draft = dict(state.get("confluence_draft") or {}) # type: ignore[call-overload]
turn = _conf_gate_turn(visits)
question_id = _question_id_for(thread_id, turn)
deadline = (datetime.now(timezone.utc) + _DEFAULT_CONF_DEADLINE).isoformat()
preview = _draft_preview(draft)
question_set = _conf_approval_question_set(
thread_id=thread_id,
question_id=question_id,
turn=turn,
preview=preview,
)
decision = interrupt(
{
"thread_id": thread_id,
"question_id": question_id,
"turn": turn,
"kind": CONFLUENCE_APPROVAL_KIND,
"question_set": question_set,
"transport": transport,
"deadline": deadline,
"slack_thread_ts": slack_thread_ts,
"confluence_draft": draft,
"preview": preview,
}
)
return _apply_conf_decision(state, decision, visits=visits)
def _parse_decision(decision: Any) -> tuple[str, str]:
"""Normalize a resume decision into ``(verb, notes)`` (mirrors graph).
Delegates to the transport-neutral
:func:`agent_team.decisions.normalize_decision` (``allow_abandon=True``) so
every writer fails safe at this single chokepoint: an explicit approve /
request_changes / abandon is honoured, and anything UNRECOGNIZED maps to
``request_changes`` carrying the full reply as notes (never a silent
terminal abandon).
"""
from agent_team.decisions import normalize_decision
result = normalize_decision(decision, allow_abandon=True)
return result["decision"], result["notes"]
def _apply_conf_decision(
state: PipelineState, decision: Any, *, visits: int
) -> PipelineState:
"""Consume the owner's resume decision and return the routing state.
Mirrors :func:`agent_team.graph._apply_plan_decision`. The default for an
unrecognized/empty decision is the SAFE direction (``request_changes``),
never an accidental approve and never a silent terminal abandon.
"""
verb, notes = _parse_decision(decision)
now = _utc_now_iso()
if verb == "approve":
return PipelineState(
status=TaskStatus.ACTIVE.value,
current_phase=CONF_WRITE_PHASE,
confluence_gate_visits=visits, # type: ignore[typeddict-unknown-key]
updated_at=now,
)
if verb == "request_changes":
return PipelineState(
status=TaskStatus.ACTIVE.value,
current_phase=CONF_DRAFT_PHASE,
confluence_feedback=notes, # type: ignore[typeddict-unknown-key]
confluence_gate_visits=visits, # type: ignore[typeddict-unknown-key]
updated_at=now,
)
# Explicit abandon only (unrecognized was already mapped to request_changes).
return PipelineState(
status=TaskStatus.FAILED.value,
current_phase=Phase.PARKED.value,
confluence_gate_visits=visits, # type: ignore[typeddict-unknown-key]
failure_reason=f"confluence draft abandoned at human gate: {notes}".rstrip(
": "
),
updated_at=now,
)
def route_after_conf_gate(state: PipelineState) -> str:
"""Conditional-edge after the Confluence gate (mirrors route_after_plan_gate).
Reads the routing state :func:`conf_gate_node` wrote on resume (or on the
ceiling-reached terminal park) and maps it to a route id:
* status ACTIVE + phase CONF_DRAFT -> ``'revise'`` (loop back to redraft);
* status ACTIVE + phase CONF_WRITE -> ``'approve'`` (advance to the write);
* anything else (FAILED, or PARKED ceiling) -> ``'terminal'``.
"""
status = state.get("status")
phase = state.get("current_phase")
if status == TaskStatus.ACTIVE.value and phase == CONF_DRAFT_PHASE:
return "revise"
if status == TaskStatus.ACTIVE.value and phase == CONF_WRITE_PHASE:
return "approve"
return "terminal"
# -- Node 3: CONF_WRITE (dry-run by default; injected client). ----------------
def _default_confluence_client() -> ConfluenceClient:
"""Construct the real Confluence client at call time (deferred import).
The import is deferred so this module loads cleanly before the
:mod:`agent_team.confluence` package exists. Credentials are read from the
environment inside the client at call time, never captured here.
"""
from agent_team.confluence.client import ConfluenceClient
return ConfluenceClient()
def _assert_page_allowed(page_id: Any) -> None:
"""Refuse a live write to a page outside the configured allowlist (opt-in).
When ``AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS`` (comma-separated page ids) is
set, a live update may target only those pages. Unset/empty leaves the write
unconstrained (preserving current behaviour) — set it in the box env to pin
the agent to e.g. the IT architecture-map page. The env is read at call time.
"""
raw = os.environ.get("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS")
if not raw or not raw.strip():
return
allowed = {part.strip() for part in raw.split(",") if part.strip()}
if str(page_id) not in allowed:
raise ConfluenceWriteError(
f"refusing Confluence write to page {page_id!r}: not in the "
"AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS allowlist "
f"({sorted(allowed)})."
)
def _apply_enabled(state: PipelineState, config: dict[str, Any] | None) -> bool:
"""Decide whether a LIVE write may be issued (dry-run is the default).
A live write requires an explicit apply flag — ``config['confluence_apply']``
truthy OR the env ``AGENT_TEAM_CONFLUENCE_APPLY`` truthy — AND the gate must
have approved (the node only runs on the approve route, but this is checked
defensively). The env is read at call time, never at import.
"""
flag = False
if config is not None:
flag = _truthy(config.get("confluence_apply"))
if not flag:
flag = _truthy(os.environ.get("AGENT_TEAM_CONFLUENCE_APPLY"))
return flag
def conf_write_node(
state: PipelineState,
config: dict[str, Any] | None = None,
*,
client: ConfluenceClient | None = None,
) -> PipelineState:
"""CONF_WRITE stage: perform (or dry-run) the Confluence page update.
**DRY-RUN by default.** A LIVE write is issued ONLY when
:func:`_apply_enabled` is true (an explicit ``confluence_apply`` config flag
or the ``AGENT_TEAM_CONFLUENCE_APPLY`` env truthy). Otherwise the node
composes the *planned* change plus a revert diff and writes NOTHING — so the
default path tests hit touches no live network.
Write routing:
* If the draft carries ``mermaid_edits`` AND the target page has diagram
macros, the update is routed through
:func:`agent_team.confluence.mermaid.plan_mermaid_edits`;
* otherwise it is a storage-format body update.
The Confluence client is INJECTED (``client``) so tests pass an in-memory
fake; the default is a real client constructed at call time (deferred import,
creds read from the environment). The Confluence package import is deferred
so this module loads before that package exists.
Returns a **partial** :class:`PipelineState`: terminal DONE (phase
CONF_DONE) with a ``confluence_result`` dict describing what was (or would
have been) written.
"""
draft = dict(state.get("confluence_draft") or {}) # type: ignore[call-overload]
if not draft.get("title") or not draft.get("body_storage"):
raise ConfluenceWriteError(
"conf_write_node requires a confluence_draft with title + body_storage"
)
apply = _apply_enabled(state, config)
page_id = draft.get("page_id")
mermaid_edits = draft.get("mermaid_edits") or []
if not apply:
# Dry run: compose the planned change + revert diff, write NOTHING.
result: dict[str, Any] = {
"applied": False,
"dry_run": True,
"action": "update" if page_id else "create",
"page_id": page_id,
"title": draft.get("title"),
"mermaid_edits": len(mermaid_edits),
"planned_change": {
"title": draft.get("title"),
"body_storage": draft.get("body_storage"),
},
"revert_diff": _compose_revert(state, draft),
}
return PipelineState(
status=TaskStatus.DONE.value,
current_phase=CONF_DONE_PHASE,
confluence_result=result, # type: ignore[typeddict-unknown-key]
updated_at=_utc_now_iso(),
)
# Live write path (only with an explicit apply flag AND prior gate approval).
conf = client if client is not None else _default_confluence_client()
# Defense-in-depth: the write target page_id is model-derived. When an
# allowlist is configured, refuse a live update to any page not on it so a
# hallucinated/injected id cannot redirect an approved write to an arbitrary
# page the service-account token can reach.
if page_id:
_assert_page_allowed(page_id)
try:
if mermaid_edits and _page_has_macros(conf, page_id):
outcome, applied = _apply_mermaid_edits(conf, page_id, mermaid_edits)
else:
outcome, applied = _apply_storage_update(conf, page_id, draft)
except ConfluenceWriteError:
raise
except Exception as exc: # noqa: BLE001 - normalized to a typed write error
raise ConfluenceWriteError(f"confluence write failed: {exc}") from exc
result = {
"applied": applied,
"dry_run": False,
"action": "update" if page_id else "create",
"page_id": getattr(outcome, "page_id", None)
if not isinstance(outcome, dict)
else outcome.get("page_id", page_id),
"title": draft.get("title"),
"mermaid_edits": len(mermaid_edits),
"outcome": outcome if isinstance(outcome, dict) else _outcome_to_dict(outcome),
}
return PipelineState(
status=TaskStatus.DONE.value,
current_phase=CONF_DONE_PHASE,
confluence_result=result, # type: ignore[typeddict-unknown-key]
updated_at=_utc_now_iso(),
)
def _apply_storage_update(
client: ConfluenceClient, page_id: Any, draft: dict[str, Any]
) -> tuple[Any, bool]:
"""Issue the live storage-format page update; return ``(outcome, applied)``.
Fetches the page's CURRENT version + body in one read (``update_page`` expects
the current number and bumps it internally per Confluence's optimistic-
concurrency contract), enforces the macro-preservation guard so a wholesale
body replace can never DROP diagram macros (the page-1540098 data-loss class),
then calls ``update_page(..., apply=True)``. ``applied`` is read off the
returned :class:`PlannedPageUpdate` (``outcome.applied``); a missing attribute
defaults to ``False`` (fail-honest — never claim a success without evidence).
"""
version_number = 0
if page_id:
version_number, current_body = _read_version_and_body(client, page_id)
_assert_macros_preserved(page_id, current_body, draft.get("body_storage"))
outcome = client.update_page(
page_id=page_id,
title=draft.get("title"),
body_storage=draft.get("body_storage"),
version_number=version_number,
apply=True,
)
applied = bool(getattr(outcome, "applied", False))
return outcome, applied
def _read_version_and_body(client: ConfluenceClient, page_id: Any) -> tuple[int, str]:
"""Read the target page's CURRENT version number and storage body in one GET.
A page object missing a usable ``version.number`` is treated as version 0 so
the update still issues against a sane baseline rather than crashing; a
missing body is ``""``.
"""
page = client.get_page(str(page_id))
version = 0
current_body = ""
if isinstance(page, dict):
version_obj = page.get("version")
if isinstance(version_obj, dict) and isinstance(version_obj.get("number"), int):
version = version_obj["number"]
body = page.get("body")
if isinstance(body, dict):
storage = body.get("storage")
if isinstance(storage, dict) and isinstance(storage.get("value"), str):
current_body = storage["value"]
return version, current_body
def _assert_macros_preserved(page_id: Any, current_body: str, new_body: Any) -> None:
"""Refuse a storage write that would DROP Confluence macros (fail closed).
A wholesale storage-format body replacement silently destroys ``<ac:...>``
macro extensions (Mermaid diagrams) the model-authored body does not
reproduce — the exact failure that erased every diagram on page 1540098.
The check is IDENTITY-aware, not a raw count: it compares the per-macro-name
multiset (``storage_macro_signature``) of the current page against the
proposed body and refuses if ANY macro identity loses occurrences. So a body
that drops the real Mermaid macro but adds an unrelated macro (keeping the
raw count equal) is still refused.
"""
from agent_team.confluence.client import storage_macro_signature
current = storage_macro_signature(current_body or "")
proposed = storage_macro_signature(str(new_body or ""))
dropped = {
key: count - proposed.get(key, 0)
for key, count in current.items()
if count > proposed.get(key, 0)
}
if dropped:
raise ConfluenceWriteError(
f"refusing storage write to page {page_id}: a wholesale storage update "
f"would DROP diagram/extension macros {dropped} (e.g. Mermaid). Edit via "
"the ADF path (get_page_adf/update_page_adf) or preserve the existing "
"macros in body_storage."
)
def _apply_mermaid_edits(
client: ConfluenceClient, page_id: Any, mermaid_edits: list[dict[str, Any]]
) -> tuple[Any, bool]:
"""Plan the Mermaid ADF edits for a macro page; return ``(outcome, applied)``.
:func:`agent_team.confluence.mermaid.plan_mermaid_edits` is PURE ADF: it takes
the parsed ADF document and a list of :class:`~agent_team.confluence.mermaid.MermaidEdit`
(``macro_key`` / ``new_source``) and never touches the client. The draft's
edit dicts (``{"mermaid": ..., "macro_id"/"anchor": ...}``) are converted to
``MermaidEdit`` objects here.
The Confluence client currently exposes NO ADF fetch/persist methods (only
storage-format ``get_page`` / ``update_page``). When the injected client adds
an ADF capability (``get_page_adf`` + ``update_page_adf``) this routes through
it and reports ``applied`` off that persistence; until then there is no way to
persist an ADF edit, so this raises :class:`ConfluenceWriteError` rather than
falsely recording a successful Mermaid write.
"""
from agent_team.confluence import mermaid as mermaid_mod
edits = [
mermaid_mod.MermaidEdit(
macro_key=str(item.get("macro_id") or item.get("anchor") or ""),
new_source=str(item.get("mermaid", "")),
)
for item in mermaid_edits
]
get_adf = getattr(client, "get_page_adf", None)
put_adf = getattr(client, "update_page_adf", None)
if not callable(get_adf) or not callable(put_adf):
raise ConfluenceWriteError(
"Mermaid live apply needs ADF persistence: the Confluence client "
"lacks get_page_adf/update_page_adf (ADF-only edits cannot round-trip "
"through storage format without dropping diagram macros)."
)
adf = get_adf(str(page_id))
plan = mermaid_mod.plan_mermaid_edits(adf, edits, apply=True)
if plan.skip_mermaid:
# The page turned out to carry zero Mermaid macros after all — defer to
# the caller's storage-format fallback path semantics by signalling skip.
raise ConfluenceWriteError(
"Mermaid live apply found no Mermaid macros on the page (skip_mermaid)."
)
outcome = put_adf(str(page_id), plan.new_adf)
applied = bool(getattr(outcome, "applied", False))
return outcome, applied
def _page_has_macros(client: ConfluenceClient, page_id: Any) -> bool:
"""Check whether the target page carries diagram macros — FAIL CLOSED.
Delegates to the injected client's ``page_has_macros`` when available, else
reads the page's storage body and counts ``<ac:...>`` macro elements. When
macro presence cannot be determined (probe raises / page unreadable), returns
``True`` (assume macros) so a macro page is NEVER mistaken for a plain page and
routed into a destructive wholesale storage overwrite. The storage path's own
:func:`_assert_macros_preserved` guard is the final backstop.
"""
if not page_id:
return False
checker = getattr(client, "page_has_macros", None)
if callable(checker):
try:
return bool(checker(page_id))
except Exception: # noqa: BLE001 - undetermined => fail closed
return True
# No explicit capability: detect from the storage body (fail closed on error).
try:
from agent_team.confluence.client import count_storage_macros
_version, current_body = _read_version_and_body(client, page_id)
return count_storage_macros(current_body) > 0
except Exception: # noqa: BLE001 - undetermined => fail closed
return True
def _compose_revert(state: PipelineState, draft: dict[str, Any]) -> dict[str, Any]:
"""Compose a revert descriptor for the dry-run record.
Captures enough of the prior state to describe how the planned change would
be reverted — the prior page id (if updating) and a marker that the original
body is unchanged on disk (the dry run wrote nothing). Pure data; no I/O.
"""
page_id = draft.get("page_id")
return {
"page_id": page_id,
"note": (
"dry run — no write performed; revert is a no-op. On apply, revert "
"restores the page version prior to this update."
if page_id
else "dry run — would create a new page; revert deletes it."
),
}
def _outcome_to_dict(outcome: Any) -> dict[str, Any]:
"""Coerce a client write-outcome object into a JSON-safe dict (best effort)."""
for attr in ("to_dict", "_asdict"):
fn = getattr(outcome, attr, None)
if callable(fn):
try:
return dict(fn())
except Exception: # noqa: BLE001
pass
if isinstance(outcome, dict):
return dict(outcome)
return {"repr": repr(outcome)}