feat(agent-team): Confluence-writer node (draft → approve gate → dry-run write) [flag-gated] #66

Closed
amoussa1229 wants to merge 10 commits from feat/agent-team-confluence-node into main
43 changed files with 7122 additions and 88 deletions

View file

@ -5,10 +5,11 @@ name: CI Web
# (→ status context `ci-web / ci`) so it does NOT collide with the Python
# `ci / ci` context emitted by ci.yaml.
#
# Our package.json currently exposes build + test (+ typecheck); `build` runs
# `tsc -b && vite build`, so the type-check happens inside build. Prettier
# (format:check), ESLint (lint), and Playwright (test:e2e) are not wired yet, so
# those reusable steps are toggled off until those tools are added.
# package.json exposes the full reusable-workflow script set: format:check
# (Prettier), lint (ESLint flat config), build (`tsc -b && vite build`), test
# (vitest), and test:e2e (Playwright chromium browser smoke against a mocked
# /api). All reusable steps are enabled (#65). The reusable workflow installs the
# Playwright browser itself (`npx playwright install --with-deps chromium`).
on:
pull_request:
@ -27,7 +28,8 @@ jobs:
node-version: "24"
working-directory: agent-team/web
cache-dependency-path: agent-team/web/package-lock.json
required-scripts: "build,test"
run-format-check: false
run-lint: false
run-e2e: false
required-scripts: "format:check,lint,build,test,test:e2e"
run-format-check: true
run-lint: true
run-e2e: true
e2e-browser: "chromium"

View file

@ -0,0 +1,29 @@
"""Confluence client seam for the durable doc-update pipeline.
Ports the OAuth/Basic auth seam from the security-review bash checker
(``checkers/confluence-doc.sh``) into a typed, dependency-injectable
:class:`~agent_team.confluence.client.ConfluenceClient`. The HTTP transport is
a protocol so unit tests inject an in-memory fake (no live network), and
``update_page`` defaults to dry-run so a planned change is producible without
mutating Confluence.
"""
from agent_team.confluence.client import (
ConfluenceAuth,
ConfluenceClient,
ConfluenceError,
HttpClient,
PlannedPageUpdate,
body_diff,
init_auth,
)
__all__ = [
"ConfluenceAuth",
"ConfluenceClient",
"ConfluenceError",
"HttpClient",
"PlannedPageUpdate",
"body_diff",
"init_auth",
]

View file

@ -0,0 +1,633 @@
"""Confluence REST v2 client with a dry-run-first page updater.
This module ports the auth seam from the security-review bash reference
(``checkers/confluence-doc.sh`` ``conf_api_init`` / ``conf_get``, lines
~210-304) into a typed Python client suited to the durable pipeline:
Auth (OAuth wins when present), mirroring the bash ``conf_api_init`` precedence:
A. **2LO client-credentials (OAuth).** When ``CONFLUENCE_OAUTH_CLIENT_ID`` and
``CONFLUENCE_OAUTH_CLIENT_SECRET`` are set, POST a
``grant_type=client_credentials`` request to ``CONFLUENCE_OAUTH_TOKEN_URL``
(default ``https://auth.atlassian.com/oauth/token``) for a Bearer token,
then resolve the cloudId from ``CONFLUENCE_CLOUD_ID`` or the OAuth-native
``accessible-resources`` endpoint. Base becomes
``https://api.atlassian.com/ex/confluence/<cloudId>``.
B. **Basic auth.** When ``CONFLUENCE_BASE_URL`` + ``CONFLUENCE_EMAIL`` +
``CONFLUENCE_API_TOKEN`` are set, use HTTP Basic against the configured
base.
As in the bash reference, a missing/incomplete cred set or a failed token /
cloudId resolution surfaces cleanly (:class:`ConfluenceError`) so the caller can
SKIP rather than raise a false alarm — mirroring ``conf_api_init`` returning
non-zero.
I/O seam: HTTP is the injected :class:`HttpClient` protocol so tests pass an
in-memory fake and no live network is touched in any code path the tests hit.
The default :class:`UrllibHttpClient` is stdlib-only (``urllib``), matching
``agent_team.transport.github_adapter`` — no third-party dependency.
Secrets (``CONFLUENCE_*``) are read from ``os.environ`` at CALL time via
:func:`init_auth`, never captured at import or stored long-lived on the module.
Dry-run-first updater: :meth:`ConfluenceClient.update_page` defaults to
``apply=False``. In dry-run it returns a :class:`PlannedPageUpdate` (target id,
``new_version = current + 1``, and a unified-diff body delta) and performs NO
network write. Only ``apply=True`` issues the PUT.
"""
from __future__ import annotations
import base64
import difflib
import json
import os
import re
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Protocol
from urllib import error as _urlerror
from urllib import parse as _urlparse
from urllib import request as _urlrequest
__all__ = [
"DEFAULT_OAUTH_TOKEN_URL",
"ATLASSIAN_API_BASE",
"ACCESSIBLE_RESOURCES_URL",
"ConfluenceAuth",
"ConfluenceClient",
"ConfluenceError",
"HttpClient",
"PlannedPageUpdate",
"UrllibHttpClient",
"body_diff",
"count_storage_macros",
"init_auth",
"storage_macro_signature",
]
# Confluence storage-format macros (Mermaid diagrams, info panels, etc.) are
# serialised as ``<ac:structured-macro>`` / ``<ac:adf-extension>`` elements whose
# payload does NOT survive a wholesale body replacement. Counting them lets a
# writer refuse a storage update that would silently drop diagram macros — the
# exact failure that erased every diagram on page 1540098. Case-insensitive.
_STORAGE_MACRO_RE = re.compile(
r"<ac:(?:structured-macro|adf-extension)\b", re.IGNORECASE
)
# Opening tag of a macro element + its attribute blob, so the macro's IDENTITY
# (its ``ac:name``) can be read — an identity-aware guard refuses a body that
# DROPS a specific diagram macro even when an unrelated macro keeps the raw count
# equal. ``[^>]*`` matches both self-closing and paired opening tags.
_STORAGE_MACRO_TAG_RE = re.compile(
r"<ac:(structured-macro|adf-extension)\b([^>]*)>", re.IGNORECASE
)
_AC_NAME_RE = re.compile(r'ac:name\s*=\s*"([^"]*)"', re.IGNORECASE)
# Default 2LO token endpoint (overridable via CONFLUENCE_OAUTH_TOKEN_URL).
DEFAULT_OAUTH_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
# OAuth-native gateway base for per-cloud Confluence access.
ATLASSIAN_API_BASE = "https://api.atlassian.com/ex/confluence"
# OAuth-native resource enumeration used to resolve the cloudId.
ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
class ConfluenceError(RuntimeError):
"""Raised when auth resolution or a REST call fails.
Carries an HTTP-ish ``status`` (``0`` for pre-flight/config failures such as
missing creds or an unresolvable cloudId) and a (truncated) ``body``. A
caller that wants the bash ``conf_api_init``-style "skip, no false alarm"
behaviour can catch this and skip rather than propagate.
"""
def __init__(self, status: int, body: str) -> None:
self.status = status
self.body = body
super().__init__(f"Confluence error {status}: {body[:200]}")
class HttpClient(Protocol):
"""Injected HTTP seam returning ``(status, body)`` for each verb.
``body`` is the parsed JSON (a ``dict``) when the response carries JSON, else
the raw ``bytes``. Keeping the surface narrow (``get``/``post``/``put``)
means the client has no hard HTTP dependency and tests inject a pure
in-memory fake — no live network on any tested path.
"""
def get(
self, url: str, *, headers: dict[str, str]
) -> tuple[int, dict[str, Any] | bytes]: ...
def post(
self,
url: str,
*,
headers: dict[str, str],
data: bytes,
) -> tuple[int, dict[str, Any] | bytes]: ...
def put(
self,
url: str,
*,
headers: dict[str, str],
data: bytes,
) -> tuple[int, dict[str, Any] | bytes]: ...
# ---------------------------------------------------------------------------
# Default stdlib-only HTTP impl (no third-party dependency at import time).
# ---------------------------------------------------------------------------
def _parse_body(raw: bytes, content_type: str) -> dict[str, Any] | bytes:
"""Parse a response body to a ``dict`` when it is JSON, else return bytes."""
if not raw:
return {}
if "application/json" in (content_type or "").lower():
try:
return json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return raw
return raw
class UrllibHttpClient:
"""Stdlib-only :class:`HttpClient` (``urllib``), built lazily.
Used only when no client is injected and an actual request is made; tests
never reach this path because they inject a fake. Matches the
``github_adapter`` pattern: urls are built from fixed ``https`` Atlassian
bases, so there is no SSRF/``file://`` surface.
"""
def _request(
self,
url: str,
*,
method: str,
headers: dict[str, str],
data: bytes | None,
) -> tuple[int, dict[str, Any] | bytes]:
request = _urlrequest.Request(url, data=data, method=method)
for key, value in headers.items():
request.add_header(key, value)
try:
with _urlrequest.urlopen(request) as response: # noqa: S310 (trusted atlassian host); nosemgrep
status = response.getcode()
raw = response.read()
content_type = response.headers.get("Content-Type", "")
except _urlerror.HTTPError as exc: # pragma: no cover - network path
raw = exc.read()
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
return exc.code, _parse_body(raw, content_type)
return status, _parse_body(raw, content_type)
def get(
self, url: str, *, headers: dict[str, str]
) -> tuple[int, dict[str, Any] | bytes]:
return self._request(url, method="GET", headers=headers, data=None)
def post(
self, url: str, *, headers: dict[str, str], data: bytes
) -> tuple[int, dict[str, Any] | bytes]:
return self._request(url, method="POST", headers=headers, data=data)
def put(
self, url: str, *, headers: dict[str, str], data: bytes
) -> tuple[int, dict[str, Any] | bytes]:
return self._request(url, method="PUT", headers=headers, data=data)
# ---------------------------------------------------------------------------
# Auth resolution (pure-ish: env in, resolved config out; one HTTP seam for OAuth).
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ConfluenceAuth:
"""Resolved auth context: the base URL plus per-request headers.
Exactly one of the two modes is materialised (``"oauth"`` or ``"basic"``),
mirroring ``conf_api_init`` resolving ONE mode. ``base`` already includes the
OAuth ``/ex/confluence/<cloudId>`` segment when relevant; both modes then
share the ``/wiki/api/v2/...`` path suffix exactly as ``conf_get`` does.
"""
mode: str
base: str
_headers: dict[str, str] = field(default_factory=dict)
def headers(self, *, extra: dict[str, str] | None = None) -> dict[str, str]:
"""Per-request headers (auth + accept), merged with ``extra``."""
merged = dict(self._headers)
if extra:
merged.update(extra)
return merged
def _resolve_cloud_id(
http: HttpClient,
*,
bearer: str,
configured_cloud_id: str | None,
site_url: str | None,
) -> str:
"""Resolve the cloudId, preferring config then accessible-resources.
Mirrors the bash fallback: use ``CONFLUENCE_CLOUD_ID`` when set, else query
the OAuth-native ``accessible-resources`` endpoint and prefer the resource
whose ``url`` matches the configured site, else the first one.
"""
if configured_cloud_id:
return configured_cloud_id
status, body = http.get(
ACCESSIBLE_RESOURCES_URL,
headers={"Authorization": f"Bearer {bearer}", "Accept": "application/json"},
)
if not (200 <= status < 300) or not isinstance(body, list):
# bash treats this as "could not resolve cloudId -> skip" (status 0).
raise ConfluenceError(
0, "OAuth: could not resolve cloudId (set CONFLUENCE_CLOUD_ID)"
)
chosen: str | None = None
if site_url:
for resource in body:
if isinstance(resource, dict) and resource.get("url") == site_url:
chosen = resource.get("id")
break
if not chosen and not site_url:
# No site_url to disambiguate: auto-resolve ONLY when there is exactly
# one accessible resource. Silently picking the first of several could
# target the WRONG Confluence site (a confused-deputy / wrong-blast-radius
# hazard) — require an explicit CONFLUENCE_CLOUD_ID instead.
candidates = [r["id"] for r in body if isinstance(r, dict) and r.get("id")]
if len(candidates) == 1:
chosen = candidates[0]
elif len(candidates) > 1:
raise ConfluenceError(
0,
"OAuth: multiple accessible Confluence sites; set "
"CONFLUENCE_CLOUD_ID (or CONFLUENCE_BASE_URL) to disambiguate",
)
if not chosen:
raise ConfluenceError(
0, "OAuth: could not resolve cloudId (set CONFLUENCE_CLOUD_ID)"
)
return chosen
def init_auth(http: HttpClient, *, env: dict[str, str] | None = None) -> ConfluenceAuth:
"""Resolve ONE auth mode from the environment at call time (OAuth wins).
Reads ``CONFLUENCE_*`` from ``env`` (default ``os.environ``) only when
invoked, never at import. Raises :class:`ConfluenceError` (``status=0``) on a
missing/incomplete cred set or a failed token / cloudId resolution — the
``conf_api_init`` "return non-zero -> caller skips" contract, so a missing
cred path is cleanly detectable.
Args:
http: HTTP seam (used only for the OAuth token + cloudId calls).
env: Environment mapping to read creds from; defaults to ``os.environ``.
Returns:
A :class:`ConfluenceAuth` with the resolved base + auth headers.
"""
environ = os.environ if env is None else env
client_id = environ.get("CONFLUENCE_OAUTH_CLIENT_ID")
client_secret = environ.get("CONFLUENCE_OAUTH_CLIENT_SECRET")
site_url = environ.get("CONFLUENCE_BASE_URL")
# Mode A: 2LO client-credentials (OAuth wins when its creds are present).
if client_id and client_secret:
token_url = environ.get("CONFLUENCE_OAUTH_TOKEN_URL") or DEFAULT_OAUTH_TOKEN_URL
form = _urlparse.urlencode(
{
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "client_credentials",
}
).encode("utf-8")
status, body = http.post(
token_url,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=form,
)
bearer = body.get("access_token") if isinstance(body, dict) else None
if not (200 <= status < 300) or not bearer:
# bash: "OAuth: token request failed — skipping API (no false alarm)".
raise ConfluenceError(0, "OAuth: token request failed")
cloud_id = _resolve_cloud_id(
http,
bearer=bearer,
configured_cloud_id=environ.get("CONFLUENCE_CLOUD_ID"),
site_url=site_url,
)
return ConfluenceAuth(
mode="oauth",
base=f"{ATLASSIAN_API_BASE}/{cloud_id}",
_headers={
"Authorization": f"Bearer {bearer}",
"Accept": "application/json",
},
)
# Mode B: Basic auth (email + API token against the configured base).
email = environ.get("CONFLUENCE_EMAIL")
api_token = environ.get("CONFLUENCE_API_TOKEN")
if site_url and email and api_token:
raw = f"{email}:{api_token}".encode("utf-8")
encoded = base64.b64encode(raw).decode("ascii")
return ConfluenceAuth(
mode="basic",
base=site_url.rstrip("/"),
_headers={
"Authorization": f"Basic {encoded}",
"Accept": "application/json",
},
)
# Neither mode fully configured -> caller skips (no false alarm).
raise ConfluenceError(0, "no Confluence credentials configured (OAuth or Basic)")
# ---------------------------------------------------------------------------
# Pure planning logic (no I/O) — kept separate so it is unit-trivial.
# ---------------------------------------------------------------------------
def body_diff(old_body: str, new_body: str, *, page_id: str) -> str:
"""Render a unified-diff of a page's storage body (old -> new).
Pure string logic with no I/O, so the dry-run plan is testable without any
HTTP. Empty string when the bodies are identical.
"""
old_lines = (old_body or "").splitlines(keepends=True)
new_lines = (new_body or "").splitlines(keepends=True)
diff = difflib.unified_diff(
old_lines,
new_lines,
fromfile=f"page/{page_id}@current",
tofile=f"page/{page_id}@planned",
)
return "".join(diff)
@dataclass(frozen=True)
class PlannedPageUpdate:
"""A dry-run page update: the change that WOULD be applied, no PUT issued.
Returned by :meth:`ConfluenceClient.update_page` when ``apply=False`` (the
default). ``applied`` is ``False`` here; the same dataclass is returned with
``applied=True`` after a real PUT so callers get a uniform shape.
"""
page_id: str
title: str
current_version: int
new_version: int
body_storage: str
body_delta: str
applied: bool = False
# ---------------------------------------------------------------------------
# The client (thin I/O wrapper over the seam + the pure planning logic).
# ---------------------------------------------------------------------------
class ConfluenceClient:
"""Read pages and plan/apply page updates over the injected HTTP seam.
Auth is resolved lazily on first use (or eagerly if a :class:`ConfluenceAuth`
is injected), reading ``CONFLUENCE_*`` from the environment at call time. The
HTTP seam is injected so tests stay hermetic.
"""
def __init__(
self,
*,
http: HttpClient | None = None,
auth: ConfluenceAuth | None = None,
env: dict[str, str] | None = None,
) -> None:
"""Construct the client.
Args:
http: Injected HTTP seam. Defaults to a stdlib-only
:class:`UrllibHttpClient` built lazily (tests inject a fake).
auth: Pre-resolved auth context. When omitted, auth is resolved on
first use from the environment via :func:`init_auth`.
env: Environment mapping for cred resolution; defaults to
``os.environ`` (read at call time, never at import).
"""
self._http: HttpClient = http or UrllibHttpClient()
self._auth = auth
self._env = env
def _ensure_auth(self) -> ConfluenceAuth:
"""Resolve auth on first use; raises :class:`ConfluenceError` if missing."""
if self._auth is None:
self._auth = init_auth(self._http, env=self._env)
return self._auth
@staticmethod
def _as_dict(body: dict[str, Any] | bytes) -> dict[str, Any]:
"""Coerce a response body to a dict or raise a clean parse error."""
if isinstance(body, dict):
return body
raise ConfluenceError(0, "expected JSON object response, got non-JSON body")
def get_page(self, page_id: str) -> dict[str, Any]:
"""GET ``/wiki/api/v2/pages/{id}?body-format=storage``.
Returns the parsed page object (including ``version.number`` and
``body.storage.value``). Raises :class:`ConfluenceError` on a non-2xx
response, carrying the status so the caller can branch (e.g. 404 = gone).
"""
auth = self._ensure_auth()
# Defense-in-depth: percent-encode the id segment so a malformed id can
# never rewrite the request path (request-path injection).
id_segment = _urlparse.quote(str(page_id), safe="")
url = f"{auth.base}/wiki/api/v2/pages/{id_segment}?body-format=storage"
status, body = self._http.get(url, headers=auth.headers())
if not (200 <= status < 300):
raise ConfluenceError(status, _stringify(body))
return self._as_dict(body)
def page_has_macros(self, page_id: str) -> bool:
"""Whether the page's CURRENT storage body carries Confluence macros.
Reads the page (storage format) and counts ``<ac:structured-macro>`` /
``<ac:adf-extension>`` elements (e.g. Mermaid diagram extensions). The
writer node uses this to refuse a wholesale storage overwrite that would
drop diagram macros. Raises :class:`ConfluenceError` if the page cannot
be read — the caller treats an unreadable page as "assume macros" (fail
closed) rather than overwriting blindly.
"""
page = self.get_page(str(page_id))
return count_storage_macros(_extract_storage_body(page)) > 0
def update_page(
self,
page_id: str,
title: str,
body_storage: str,
version_number: int,
*,
apply: bool = False,
) -> PlannedPageUpdate:
"""Plan (default) or apply a page update via ``/wiki/api/v2/pages/{id}``.
CRITICAL — dry-run by default. With ``apply=False`` (the default) NO
network write happens: this returns a :class:`PlannedPageUpdate`
describing the change (target id, ``new_version = version_number + 1``,
and a unified-diff ``body_delta`` against the page's current storage
body). Only ``apply=True`` issues the PUT.
Args:
page_id: Target page id.
title: New page title (Confluence requires title on update).
body_storage: New body in Confluence ``storage`` representation.
version_number: The page's CURRENT version number; the PUT/plan uses
``version_number + 1`` as the new version (Confluence's
optimistic-concurrency contract).
apply: When ``False`` (default) return the planned change without a
write. When ``True`` issue the PUT and return the applied result.
Returns:
A :class:`PlannedPageUpdate`. ``applied`` is ``False`` for a dry-run,
``True`` after a successful PUT.
Raises:
ConfluenceError: On auth failure, on a failed current-body read, or
on a non-2xx PUT response (apply path only).
"""
new_version = version_number + 1
# Compute the body delta against the page's current storage body. The
# GET is the only read; it never mutates, so it is safe in dry-run.
current_body = ""
try:
current_page = self.get_page(page_id)
current_body = _extract_storage_body(current_page)
except ConfluenceError:
# If we cannot read the current body we still produce a plan, but the
# diff is against an empty baseline (whole new body shown as added).
current_body = ""
body_delta = body_diff(current_body, body_storage, page_id=page_id)
if not apply:
return PlannedPageUpdate(
page_id=page_id,
title=title,
current_version=version_number,
new_version=new_version,
body_storage=body_storage,
body_delta=body_delta,
applied=False,
)
auth = self._ensure_auth()
# Defense-in-depth: percent-encode the id segment so a malformed id can
# never rewrite the request path (request-path injection).
id_segment = _urlparse.quote(str(page_id), safe="")
url = f"{auth.base}/wiki/api/v2/pages/{id_segment}"
payload = {
"id": str(page_id),
"status": "current",
"title": title,
"body": {"representation": "storage", "value": body_storage},
"version": {"number": new_version},
}
data = json.dumps(payload).encode("utf-8")
status, body = self._http.put(
url,
headers=auth.headers(extra={"Content-Type": "application/json"}),
data=data,
)
if not (200 <= status < 300):
raise ConfluenceError(status, _stringify(body))
return PlannedPageUpdate(
page_id=page_id,
title=title,
current_version=version_number,
new_version=new_version,
body_storage=body_storage,
body_delta=body_delta,
applied=True,
)
# ---------------------------------------------------------------------------
# Small helpers.
# ---------------------------------------------------------------------------
def count_storage_macros(body: str) -> int:
"""Count Confluence macro elements in a storage-format body.
Mermaid diagrams and other extensions live in storage XHTML as
``<ac:structured-macro>`` / ``<ac:adf-extension>`` elements whose payload is
lost if the body is wholesale-replaced. Counting them lets a writer refuse a
storage update that would drop macros (the page-1540098 diagram-loss class).
Returns ``0`` for an empty/None body.
"""
if not body:
return 0
return len(_STORAGE_MACRO_RE.findall(body))
def storage_macro_signature(body: str) -> Counter[str]:
"""Return a multiset of macro IDENTITIES in a storage-format body.
Each ``<ac:structured-macro>`` is keyed by its ``ac:name`` (e.g.
``"macro:mermaid-cloud"``), unnamed macros under ``"macro:_unnamed"``, and
``<ac:adf-extension>`` under ``"adf-extension"``. Comparing the current
page's signature to a proposed body's lets a writer refuse a storage update
that DROPS a specific diagram macro even when an unrelated macro keeps the
raw count equal (an identity-blind count guard would miss that). Empty/None
body yields an empty ``Counter``.
"""
sig: Counter[str] = Counter()
if not body:
return sig
for kind, attrs in _STORAGE_MACRO_TAG_RE.findall(body):
if kind.lower() == "adf-extension":
sig["adf-extension"] += 1
continue
name_match = _AC_NAME_RE.search(attrs)
key = (
f"macro:{name_match.group(1).strip().lower()}"
if name_match
else "macro:_unnamed"
)
sig[key] += 1
return sig
def _extract_storage_body(page: dict[str, Any]) -> str:
"""Pull ``body.storage.value`` from a v2 page object, defaulting to ``""``."""
body = page.get("body")
if isinstance(body, dict):
storage = body.get("storage")
if isinstance(storage, dict):
value = storage.get("value")
if isinstance(value, str):
return value
return ""
def _stringify(body: dict[str, Any] | bytes) -> str:
"""Render a response body for error messages (JSON or decoded bytes)."""
if isinstance(body, dict):
return json.dumps(body)
if isinstance(body, bytes):
return body.decode("utf-8", "replace")
return str(body)

View file

@ -0,0 +1,454 @@
"""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,
)

View file

@ -150,6 +150,13 @@ AlarmHook = Callable[[str], None]
ListenerFactory = Callable[[], Any]
def _truthy_env(value: str | None) -> bool:
"""Interpret an env-flag string as a boolean (case-folded; None -> False)."""
if not value:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def _open_question_id_for_thread(conn: Any, thread_id: str) -> str | None:
"""Return the ``question_id`` of an open ledger row for ``thread_id``, or None.
@ -699,6 +706,7 @@ class Coordinator:
ci_timeout: timedelta | None = None,
draft_pr_provider: "Callable[[], list[Any]] | None" = None,
plan_gate: bool = True,
confluence: bool | None = None,
) -> None:
self._db_path = Path(db_path)
self._transport = transport
@ -766,6 +774,15 @@ class Coordinator:
# serve/P2 path gate-enabled out of the box; tests build both shapes by
# toggling this with/without ``review_wiring``.
self._plan_gate = plan_gate
# Confluence-writer sub-pipeline (opt-in, default OFF). Gated behind the
# AGENT_TEAM_CONFLUENCE_ENABLED env flag UNLESS an explicit bool is passed
# (tests pass confluence=True/False directly). When OFF, ``setup`` builds
# the graph with confluence=False so NO conf_* nodes / route_after_intake
# exist and the daemon is byte-identical to today (every current test
# unchanged). The env is read at construction time, never at import.
if confluence is None:
confluence = _truthy_env(os.environ.get("AGENT_TEAM_CONFLUENCE_ENABLED"))
self._confluence = bool(confluence)
# Built by setup().
self._graph: Any = None
@ -889,6 +906,7 @@ class Coordinator:
build_verify=build_verify,
dispatch_node=dispatch_node_callable,
plan_gate=plan_gate,
confluence=self._confluence,
)
# The ResumeWorker is satisfied directly by the compiled LangGraph app
@ -920,7 +938,12 @@ class Coordinator:
# ------------------------------------------------------------------ #
def start_task(
self, *, task_text: str, transport_name: str, slack_thread_ts: str = ""
self,
*,
task_text: str,
transport_name: str,
slack_thread_ts: str = "",
task_kind: str = "",
) -> str:
"""Start one task: run to the first human gate, then notify (§3.3, §3.3.1).
@ -965,6 +988,7 @@ class Coordinator:
transport=transport_name,
task=task_text,
slack_thread_ts=slack_thread_ts,
task_kind=task_kind,
)
question = graph_mod.pending_question(self._graph, thread_id=thread_id)
@ -975,6 +999,20 @@ class Coordinator:
_LOG.info("start_task %s produced no pending question", thread_id)
return thread_id
# A direct Confluence task (Flow A) suspends at CONF_GATE on the FIRST
# invoke (kind='confluence_approval') instead of the clarifier — deliver
# the approval gate exactly like the lifecycle drain does (durable row +
# decision presentation) rather than the clarifier notify path.
if question.get("kind") == graph_mod.CONFLUENCE_APPROVAL_KIND:
label = self._task_label(thread_id, task_text)
self._post_confluence_gate(
question,
label=label,
root_ts=slack_thread_ts or None,
short=thread_id[:8],
)
return thread_id
question_set = question["question_set"]
deadline = question.get("deadline") or self._default_deadline()
@ -1211,12 +1249,37 @@ class Coordinator:
# the gate (B2a). Branch on the kind so the plan gate posts its
# plan + findings presentation while the clarifier keeps its
# existing follow-up path.
if question.get("kind") == graph_mod.PLAN_DECISION_KIND:
kind = question.get("kind")
if kind == graph_mod.PLAN_DECISION_KIND:
self._post_plan_decision_gate(
question, label=label, root_ts=root_ts, short=short
)
continue
if kind == graph_mod.CONFLUENCE_APPROVAL_KIND:
# The task is suspended at CONF_GATE (Confluence Flow A/B). Open
# + present the approve / request_changes / abandon gate exactly
# like the plan-decision gate so the human is notified and a
# durable pending_questions row is written (without this branch
# the gate is never delivered and the task suspends forever).
self._post_confluence_gate(
question, label=label, root_ts=root_ts, short=short
)
continue
# A clarifier QUESTION gate (kind == 'clarify' / legacy no-kind).
# An UNKNOWN kind reaching here would have no question_set, so
# .get() avoids a KeyError that would silently drop the gate.
question_set = question.get("question_set")
if question_set is None:
_LOG.warning(
"pending interrupt for %s has unknown kind %r and no "
"question_set; skipping delivery",
short,
kind,
)
continue
# Multi-turn clarify: a new clarifier question is waiting. Post it
# to the transport (the drain path otherwise leaves it unposted)
# and tell the human more input is needed. Thread it (and its
@ -1228,7 +1291,7 @@ class Coordinator:
responder_mod.notify_question(
conn,
self._transport,
question["question_set"],
question_set,
deadline=question.get("deadline")
or self._default_deadline(),
thread_ts=root_ts,
@ -1601,6 +1664,208 @@ class Coordinator:
},
)
# ------------------------------------------------------------------ #
# Confluence-approval gate (Flow A/B). Mirrors the plan-decision gate.
# ------------------------------------------------------------------ #
def _task_label(self, thread_id: str, task_text: str = "") -> str:
"""Render the ``"desc" (`short`)`` label for a task's lifecycle posts.
Prefers the live graph state's ``task`` description, falling back to the
supplied ``task_text`` (the intake string, available before the first
checkpoint read). Mirrors the inline label built in :meth:`tick`.
"""
short = thread_id[:8]
desc = ""
try:
snap = self._graph.get_state(graph_mod.thread_config(thread_id))
values = getattr(snap, "values", {}) or {}
desc = str(values.get("task") or "").strip()
except Exception: # noqa: BLE001 - fall back to the intake text
pass
if not desc:
desc = str(task_text or "").strip()
desc = desc or "(no description)"
if len(desc) > 90:
desc = desc[:90] + "…"
return f'"{desc}" (`{short}`)'
def _post_confluence_gate(
self,
question: "dict[str, Any]",
*,
label: str,
root_ts: str | None,
short: str,
) -> None:
"""Open + present the Confluence-approval gate for a suspended task.
The graph has suspended on the resumable CONF_GATE interrupt whose payload
carries ``kind == 'confluence_approval'`` plus the
``question_set``/``preview``. This mirrors :meth:`_post_plan_decision_gate`:
1. Writes the durable ``pending_questions`` row with
``kind='confluence_approval'`` (guarded by the single-open-gate
invariant), threaded under the task root.
2. POSTs the approve / request_changes / abandon presentation, threaded
under the task root, truncated to Slack's block limit.
The decision answer flows back through the UNCHANGED submit_answer →
resume-queue → ResumeWorker path (``conf_gate_node`` consumes the resume
value). Best-effort + fully guarded so a post failure never breaks tick.
"""
deadline = question.get("deadline") or self._default_deadline()
thread_id = str(question.get("thread_id") or "")
question_id = str(question.get("question_id") or "")
turn = int(question.get("turn") or 0)
transport_name = str(question.get("transport") or "")
body = self._confluence_presentation(question, label=label)
try:
conn = connect(self._db_path)
try:
opened = self._open_confluence_row_if_absent(
conn,
thread_id=thread_id,
question_id=question_id,
turn=turn,
transport_name=transport_name,
deadline=deadline,
root_ts=root_ts,
presentation=body,
)
finally:
conn.close()
except Exception: # noqa: BLE001 - a ledger error must not break the tick
_LOG.warning(
"failed to open confluence gate row for %s", short, exc_info=True
)
opened = False
if not opened:
return
self._emit(body, thread_ts=root_ts)
def _confluence_presentation(
self, question: "dict[str, Any]", *, label: str
) -> str:
"""Render the Confluence-gate presentation (draft preview + instructions).
Uses the human-readable ``preview`` the gate node built (the draft title +
action + body snippet), then appends the explicit decision instructions.
Truncated to Slack's section-block limit.
"""
preview = " ".join(str(question.get("preview") or "").split())
if not preview:
preview = "(no draft preview recorded)"
instructions = (
"• Decide: use the buttons below, OR reply *approve* / "
"*request changes <notes>* / *abandon* in this thread."
)
header = f"📄 {label} — Confluence update needs your approval."
body = "\n".join([header, preview, instructions])
return self._truncate_for_slack(body)
def _open_confluence_row_if_absent(
self,
conn: Any,
*,
thread_id: str,
question_id: str,
turn: int,
transport_name: str,
deadline: str,
root_ts: str | None,
presentation: str = "",
) -> bool:
"""Open a ``kind='confluence_approval'`` ledger row (one-open-gate, B-2).
Mirrors :meth:`_open_plan_decision_row_if_absent`: enforces the
single-open-gate invariant, writes the durable ``open`` row FIRST, then
posts the gate presentation threaded under ``root_ts`` and records the
``channel_ref``. Returns ``True`` iff a fresh row was opened.
"""
existing = _open_question_id_for_thread(conn, thread_id)
if existing is not None:
_LOG.warning(
"single-open-gate invariant: thread %s already has open question "
"%s; not opening a second (confluence_approval) gate row",
thread_id[:8],
existing,
)
return False
from agent_team import ledger as ledger_mod # noqa: PLC0415
ledger_mod.post_question(
conn,
question_id=question_id,
thread_id=thread_id,
turn=turn,
transport=transport_name or type(self._transport).__name__,
deadline_at=deadline,
kind=graph_mod.CONFLUENCE_APPROVAL_KIND,
)
channel_ref: str | None = None
try:
post_kwargs: dict[str, Any] = {}
if root_ts:
post_kwargs["thread_ts"] = root_ts
posted_ref = self._transport.post_question(
thread_id=thread_id,
question_id=question_id,
turn=turn,
question_set=self._confluence_question_set(
thread_id=thread_id,
question_id=question_id,
turn=turn,
presentation=presentation,
),
deadline=deadline,
**post_kwargs,
)
channel_ref = root_ts if root_ts else posted_ref
except Exception: # noqa: BLE001 - lost post is recoverable; keep the row
_LOG.warning(
"confluence gate post failed for thread %s; row stays open "
"for redelivery",
thread_id[:8],
exc_info=True,
)
return True
if channel_ref:
ledger_mod.set_channel_ref(
conn, question_id=question_id, channel_ref=channel_ref
)
return True
@staticmethod
def _confluence_question_set(
*, thread_id: str, question_id: str, turn: int, presentation: str = ""
) -> "QuestionSet":
"""Build the QuestionSet for the Confluence gate post (decision surface).
Mirrors :meth:`_plan_decision_question_set`: ``context`` carries
``kind == 'confluence_approval'`` (so the Slack transport renders the
three decision buttons) and the human-readable ``presentation`` body.
"""
from agent_team.transport.base import QuestionSet # noqa: PLC0415
return QuestionSet(
thread_id=thread_id,
question_id=question_id,
turn=turn,
questions=["Approve, request changes, or abandon this Confluence update?"],
context={
"kind": graph_mod.CONFLUENCE_APPROVAL_KIND,
"presentation": presentation,
},
)
@staticmethod
def _summarize_plan(values: "dict[str, Any]") -> str:
"""Condensed, Slack-friendly view of the approved plan (summary + phases).
@ -2213,7 +2478,19 @@ class Coordinator:
# destroyed and the human is never re-asked.
if not self._guarded_clear_unposted_open(row["question_id"]):
continue
question_set = question["question_set"]
# Every gate kind (clarify / plan_decision / confluence_approval)
# carries a question_set in its interrupt payload; .get() so an
# unknown kind without one is skipped rather than KeyError-ing and
# vanishing the redelivery.
question_set = question.get("question_set")
if question_set is None:
_LOG.warning(
"redeliver: pending interrupt for %s has no question_set "
"(kind=%r); skipping re-post",
thread_id[:8],
question.get("kind"),
)
continue
deadline = (
row["deadline_at"]
or question.get("deadline")

View file

@ -55,7 +55,11 @@ _WAITING_STATUSES = frozenset({"waiting_human"})
_PARKED_STATUSES = frozenset({"parked", "failed"})
# Map a topology node id back to the budget_ledger ``stage`` key for cost join.
# Most node ids equal their stage; the P3 build/verify vertices differ.
# Most node ids equal their stage; the P3 build/verify vertices differ. The
# Confluence vertices (conf_draft / conf_gate / conf_write) need NO entry — their
# node id already equals both their Phase value and their ledger stage key, so the
# identity fallback (``_NODE_TO_STAGE.get(node_id, node_id)``) resolves them and
# they render on the timeline like any other stage.
_NODE_TO_STAGE = {"build_node": "build", "verify_node": "verify"}

View file

@ -53,7 +53,7 @@ __all__ = [
]
# Bump when the DDL below changes; migrate() steps a connection forward.
SCHEMA_VERSION: int = 4
SCHEMA_VERSION: int = 5
# Default SQLite busy timeout (ms) so concurrent writers wait for the write
# lock rather than failing immediately.
@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS pending_questions (
answered_at TEXT,
answered_via TEXT,
kind TEXT NOT NULL DEFAULT 'clarify'
CHECK (kind IN ('clarify', 'plan_decision'))
CHECK (kind IN ('clarify', 'plan_decision', 'confluence_approval'))
)
""".strip()
@ -331,7 +331,88 @@ def migrate(conn: sqlite3.Connection) -> None:
_ensure_pending_questions_kind(conn)
current = 4
# Future steps go here: `if current < 5: ...; current = 5`.
if current < 5:
# v5: WIDEN the pending_questions ``kind`` CHECK from
# ('clarify','plan_decision') to add 'confluence_approval', so the new
# Confluence-approval human gate can persist its question rows. SQLite
# cannot ALTER a CHECK constraint in place, so this does the standard
# 12-step table rebuild: build a replacement table carrying the WIDENED
# CHECK, copy every existing row across, drop the old table, rename the
# new one into place, and recreate ALL of pending_questions' indexes
# (including the partial unique uq_pending_questions_open_channel_ref).
# Existing rows are preserved verbatim; re-running is safe because the
# version stamp gates this block (and the unconditional index tail below
# re-asserts the indexes idempotently). Placed in its own version block
# ABOVE the unconditional tail per the ORDERING CONSTRAINT below — the
# tail's CREATE ... IF NOT EXISTS would NOT rebuild an existing table.
#
# foreign_keys must be OFF during the table rebuild so the DROP/RENAME
# does not trip referential checks; connect() sets it ON. It is a no-op
# for this table (no FKs reference pending_questions) but follows the
# documented SQLite procedure. It is restored to ON afterwards.
#
# The whole DROP/RENAME swap is wrapped in an explicit BEGIN IMMEDIATE
# transaction so it is ATOMIC: connect() opens in autocommit mode
# (isolation_level=None), so without this each statement would commit on
# its own and a crash between DROP TABLE pending_questions and the RENAME
# would DESTROY the live ledger while schema_version stayed 4 — on restart
# the block re-runs and CREATE pending_questions_new fails ('already
# exists'), crash-looping with data loss. Wrapping makes a mid-rebuild
# failure roll back to the original table intact. PRAGMA foreign_keys
# cannot change inside a transaction, so it is toggled OUTSIDE the BEGIN.
# DROP ... _new IF EXISTS first clears any orphan left by a prior partial
# run (belt-and-suspenders; the transaction already prevents one).
conn.execute("PRAGMA foreign_keys=OFF")
try:
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute("DROP TABLE IF EXISTS pending_questions_new")
conn.execute(
"""
CREATE TABLE pending_questions_new (
question_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
turn INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('open', 'answered', 'expired', 'superseded')),
transport TEXT NOT NULL,
channel_ref TEXT,
posted_at TEXT,
deadline_at TEXT,
answer_json TEXT,
answered_at TEXT,
answered_via TEXT,
kind TEXT NOT NULL DEFAULT 'clarify'
CHECK (kind IN ('clarify', 'plan_decision', 'confluence_approval'))
)
""".strip()
)
# EXPLICIT column list (not SELECT *) so the copy survives a
# future column reorder/add on either table.
conn.execute(
"INSERT INTO pending_questions_new "
"(question_id, thread_id, turn, status, transport, "
"channel_ref, posted_at, deadline_at, answer_json, "
"answered_at, answered_via, kind) "
"SELECT question_id, thread_id, turn, status, transport, "
"channel_ref, posted_at, deadline_at, answer_json, "
"answered_at, answered_via, kind FROM pending_questions"
)
conn.execute("DROP TABLE pending_questions")
conn.execute(
"ALTER TABLE pending_questions_new RENAME TO pending_questions"
)
for stmt in _split_statements(PENDING_QUESTIONS_INDEXES_DDL):
conn.execute(stmt)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
finally:
conn.execute("PRAGMA foreign_keys=ON")
current = 5
# Future steps go here: `if current < 6: ...; current = 6`.
# Applied UNCONDITIONALLY (idempotent IF NOT EXISTS) so an already-stamped DB
# — which skips the version blocks above — still gains these tables without a

View file

@ -27,11 +27,12 @@ CREATE TABLE IF NOT EXISTS pending_questions (
answered_at TEXT,
answered_via TEXT,
-- kind: discriminates the human gate this question belongs to —
-- 'clarify' (the clarifier) or 'plan_decision' (the plan-review gate).
-- 'clarify' (the clarifier), 'plan_decision' (the plan-review gate), or
-- 'confluence_approval' (the Confluence-approval gate; widened in v5).
-- Defaults to 'clarify' so an in-place ALTER on a legacy ledger and any
-- existing rows take the clarifier value.
kind TEXT NOT NULL DEFAULT 'clarify'
CHECK (kind IN ('clarify', 'plan_decision'))
CHECK (kind IN ('clarify', 'plan_decision', 'confluence_approval'))
);
CREATE INDEX IF NOT EXISTS idx_pending_questions_thread

View file

@ -50,6 +50,12 @@ from typing import TYPE_CHECKING, Any
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from agent_team.nodes.confluence_writer import (
conf_draft_node,
conf_gate_node,
conf_write_node,
route_after_conf_gate,
)
from agent_team.task_model import (
Phase,
PipelineState,
@ -87,6 +93,13 @@ __all__ = [
"BUILD_NODE",
"BUILD_ROUTE",
"CLARIFY",
"CONFLUENCE_APPROVAL_KIND",
"CONF_APPROVE_ROUTE",
"CONF_DRAFT_NODE",
"CONF_GATE",
"CONF_REVISE_ROUTE",
"CONF_TERMINAL_ROUTE",
"CONF_WRITE_NODE",
"DEFAULT_CLARIFY_DEADLINE",
"DISPATCH_NODE",
"GATE_APPROVE_ROUTE",
@ -112,6 +125,8 @@ __all__ = [
"plan_node",
"plan_phase",
"resume_task",
"route_after_conf_gate",
"route_after_intake",
"route_after_plan_gate",
"start_task",
"thread_config",
@ -153,6 +168,43 @@ GATE_TERMINAL_ROUTE = "gate_terminal"
# can tell the two apart.
PLAN_DECISION_KIND = "plan_decision"
# --- Confluence-writer sub-pipeline (opt-in via build_graph(confluence=True)). -
# The Confluence stage documents a task's work on Confluence, gated by a human
# exactly like the plan gate:
#
# CONF_DRAFT -> CONF_GATE -> {CONF_WRITE | back to CONF_DRAFT | terminal}
#
# It is reached two ways (both behind the ``confluence`` flag — when the flag is
# off NONE of this is wired and the graph is byte-for-byte identical to today):
#
# * Flow A (direct doc task): INTAKE routes on ``state['task_kind']`` — when it
# is "confluence" the task goes straight to CONF_DRAFT instead of CLARIFY.
# * Flow B (post-build doc): when the build/verify subgraph is present, the
# approved-build terminus feeds into CONF_DRAFT so a shipped change documents
# itself. (If build_verify is absent, Flow B simply isn't wired.)
#
# The node functions + router live in agent_team.nodes.confluence_writer (imported
# above); these are the GRAPH VERTEX names + route ids, kept distinct from each
# other so a route key never collides with a vertex id (same discipline as the
# plan gate).
CONF_DRAFT_NODE = "conf_draft"
CONF_GATE = "conf_gate"
CONF_WRITE_NODE = "conf_write"
# The route ids route_after_conf_gate returns (mirror review_loop/plan-gate
# value-coupling: the node's router returns these literals, the conditional-edge
# map below keys on them).
CONF_APPROVE_ROUTE = "approve"
CONF_REVISE_ROUTE = "revise"
CONF_TERMINAL_ROUTE = "terminal"
# Interrupt-payload discriminator marking a Confluence-approval gate (vs the
# clarifier question-set / plan-decision gate). The confluence_writer node imports
# THIS constant from graph when present and falls back to the same literal, so
# this is the authoritative definition keeping the responder/ledger discriminator
# in sync (sibling to PLAN_DECISION_KIND).
CONFLUENCE_APPROVAL_KIND = "confluence_approval"
# Combined ceiling on plan-gate visits (Phase B2a, bounded termination). A human
# ``request_changes`` re-enters plan<->review, which can hit the review cap and
# gate AGAIN. Each gate visit increments ``plan_gate_visits``; once it reaches
@ -540,6 +592,27 @@ def route_after_plan_gate(state: PipelineState) -> str:
return GATE_TERMINAL_ROUTE
def route_after_intake(state: PipelineState) -> str:
"""LangGraph conditional-edge after INTAKE (Confluence Flow A dispatch).
Reads ``state['task_kind']`` and dispatches a direct Confluence-documentation
task straight to the Confluence draft stage; every other task (the default,
``task_kind`` empty) goes to the clarifier exactly as before. Returns a node
name (the conditional-edge map below is an identity map, keying each route on
the destination vertex):
* ``task_kind == "confluence"`` -> :data:`CONF_DRAFT_NODE`;
* anything else -> :data:`CLARIFY` (unchanged P1 behaviour).
This function only matters when ``build_graph(confluence=True)`` wired the
conditional edge; with the flag off INTAKE has its original unconditional
edge to CLARIFY and ``task_kind`` is never read.
"""
if state.get("task_kind") == "confluence":
return CONF_DRAFT_NODE
return CLARIFY
def _author_questions(state: PipelineState) -> list[str]:
"""Deterministic stand-in for the Claude clarifier's question authoring.
@ -643,6 +716,7 @@ def build_graph(
| None = None,
dispatch_node: Callable[[PipelineState], Any] | None = None,
plan_gate: bool = False,
confluence: bool = False,
) -> CompiledStateGraph:
"""Assemble + compile the P1 pipeline ``StateGraph`` (§3.3, §7.1).
@ -718,6 +792,27 @@ def build_graph(
falls back to ``BUILD -> VERIFY`` directly. ``dispatch_node`` requires
``build_verify`` (there is no BUILD/VERIFY pair to splice it between
otherwise).
``confluence`` (default ``False``) opts the Confluence-writer sub-pipeline
into the graph: ``CONF_DRAFT -> CONF_GATE -> {CONF_WRITE | CONF_DRAFT |
END}``, gated by a human exactly like the plan gate. When it is ON:
* **Flow A (direct doc task):** INTAKE's unconditional edge to CLARIFY is
replaced by a conditional edge on :func:`route_after_intake`, so a task with
``state['task_kind'] == "confluence"`` goes straight to CONF_DRAFT while
every other task still goes to CLARIFY (existing behaviour preserved).
* **Flow B (post-build doc):** when ``build_verify`` is also wired, the
verifier's approved (PR) terminus is repointed from END into CONF_DRAFT, so
a shipped change documents itself before the graph ends. If ``build_verify``
is absent, Flow B is simply not wired (the build path doesn't exist).
* **Tail:** ``CONF_DRAFT -> CONF_GATE``; the gate routes ``approve ->
CONF_WRITE``, ``revise -> CONF_DRAFT`` (bounded redraft loop), ``terminal ->
END``; and ``CONF_WRITE -> END``.
When ``confluence`` is ``False`` (the default) NONE of these nodes or edges
exist and the compiled graph is byte-for-byte behaviourally identical to the
P1/P2/P3 graph — INTAKE keeps its plain ``add_edge(INTAKE, CLARIFY)`` and
``task_kind`` is never consulted. This keeps every existing test untouched.
"""
clarify = live_clarify_node if live_clarify_node is not None else clarify_node
plan = live_plan_node if live_plan_node is not None else plan_node
@ -756,7 +851,18 @@ def build_graph(
builder.add_node(PLAN, _instrument(PLAN, plan, transition_recorder))
builder.add_edge(START, INTAKE)
builder.add_edge(INTAKE, CLARIFY)
# Confluence Flow A: route INTAKE on task_kind so a direct documentation task
# ("confluence") skips the clarifier and goes straight to CONF_DRAFT, while
# every other task still flows to CLARIFY. When the flag is off INTAKE keeps
# its original unconditional edge (so the graph is unchanged).
if confluence:
builder.add_conditional_edges(
INTAKE,
route_after_intake,
{CLARIFY: CLARIFY, CONF_DRAFT_NODE: CONF_DRAFT_NODE},
)
else:
builder.add_edge(INTAKE, CLARIFY)
builder.add_edge(CLARIFY, PLAN)
if review_node is None:
@ -841,13 +947,51 @@ def build_graph(
builder.add_edge(DISPATCH_NODE, VERIFY_NODE)
# VERIFY's verdict routes to {approved -> END (PR terminus),
# build -> BUILD (bounded build<->verify loop), parked -> END
# (escalation)} regardless of whether DISPATCH is wired.
# (escalation)} regardless of whether DISPATCH is wired. When the
# Confluence stage is opted in (Flow B), the approved (PR) terminus is
# repointed from END into CONF_DRAFT so a shipped change documents
# itself before the graph ends.
approved_target = CONF_DRAFT_NODE if confluence else END
builder.add_conditional_edges(
VERIFY_NODE,
route_after_verify,
{APPROVED_ROUTE: END, BUILD_ROUTE: BUILD_NODE, PARKED_ROUTE: END},
{
APPROVED_ROUTE: approved_target,
BUILD_ROUTE: BUILD_NODE,
PARKED_ROUTE: END,
},
)
if confluence:
# Confluence-writer tail: CONF_DRAFT -> CONF_GATE -> {CONF_WRITE | redraft
# | END}. The draft + write nodes take an optional config/client kwarg
# (LangGraph injects only the state + RunnableConfig); _instrument
# preserves each node's real signature so the right arity is passed. The
# gate suspends on interrupt() exactly like the plan gate. The entry edges
# (INTAKE Flow A, VERIFY Flow B) were wired above.
builder.add_node(
CONF_DRAFT_NODE,
_instrument(CONF_DRAFT_NODE, conf_draft_node, transition_recorder),
)
builder.add_node(
CONF_GATE, _instrument(CONF_GATE, conf_gate_node, transition_recorder)
)
builder.add_node(
CONF_WRITE_NODE,
_instrument(CONF_WRITE_NODE, conf_write_node, transition_recorder),
)
builder.add_edge(CONF_DRAFT_NODE, CONF_GATE)
builder.add_conditional_edges(
CONF_GATE,
route_after_conf_gate,
{
CONF_APPROVE_ROUTE: CONF_WRITE_NODE,
CONF_REVISE_ROUTE: CONF_DRAFT_NODE,
CONF_TERMINAL_ROUTE: END,
},
)
builder.add_edge(CONF_WRITE_NODE, END)
if checkpointer is None:
return builder.compile()
return builder.compile(checkpointer=checkpointer)
@ -970,6 +1114,7 @@ def start_task(
transport: str = "",
task: str = "",
slack_thread_ts: str = "",
task_kind: str = "",
) -> tuple[str, PipelineState]:
"""Start a new pipeline task and run it up to the first human gate (§3.3).
@ -996,6 +1141,15 @@ def start_task(
The graph MUST be compiled with a checkpointer for the suspend to persist;
an uncheckpointed graph would run straight through without honouring the
interrupt.
``task_kind`` selects the pipeline lane (Confluence Flow A). The default ``""``
is the ordinary clarify->plan task; ``"confluence"`` marks a direct
Confluence-documentation task that, in a graph compiled with
``build_graph(confluence=True)``, routes INTAKE straight to CONF_DRAFT (see
:func:`route_after_intake`). Like ``task``/``slack_thread_ts`` it is seeded
into the initial invoke and persists through INTAKE (``intake_node`` returns
only a partial state). With ``confluence=False`` the value is harmless and
never consulted (INTAKE's edge is unconditional).
"""
tid = thread_id or new_thread_id()
now = _utc_now_iso()
@ -1004,6 +1158,7 @@ def start_task(
status=TaskStatus.ACTIVE.value,
current_phase=_phase_value(Phase.INTAKE),
task=task,
task_kind=task_kind,
slack_thread_ts=slack_thread_ts,
qa_history=[],
transport=transport,

View file

@ -0,0 +1,772 @@
"""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)}

View file

@ -0,0 +1,384 @@
"""Prompt-build / reply-parse split for the Confluence-writer node.
This module is the pure, model-output-facing half of the Confluence-writer
stage — the same ``build_*_prompt`` / ``parse_*_reply`` split
:mod:`agent_team.nodes.planner` (``build_plan_prompt`` / ``parse_plan``) and
:mod:`agent_team.nodes.clarifier_llm` (``ClaudeClarifier._build_prompt`` /
``_parse``) use, so the node body (:mod:`agent_team.nodes.confluence_writer`)
stays a thin LangGraph state-transition wrapper.
The Confluence-writer node drafts a **Confluence page update** as DATA (it never
writes to a repo, and it never posts to Confluence from the draft node — the
write is a later, dry-run-by-default node). Two source flows feed the draft:
* **Flow A** — a direct documentation ask: the draft is built from the task
description alone (``state['task']``).
* **Flow B** — a post-build documentation update: the draft folds in the
approved ``plan``, the ``candidate_diff``, and the repo name so the page
reflects what the change actually did.
On a ``request_changes`` loop-back the prior gate's ``confluence_feedback`` is
folded into the prompt so the redraft answers the reviewer's notes (mirrors the
planner's ``_format_review_feedback`` loop-back).
The LLM binding is an INJECTED seam (``context_provider``), exactly like the
planner's, so the prompt assembly stays pure and unit-testable and no live model
is bound here.
Parsing is DEFENSIVE: the model output is UNTRUSTED. A garbled reply must not
silently produce an empty page write, so :func:`parse_confluence_reply` raises
:class:`ConfluenceDraftError` when it cannot recover a usable
``{title, body_storage}`` draft, and the node decides what to do with that.
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from agent_team.task_model import PipelineState
# Optional context-provider callable (mirrors planner.ContextProvider): () -> str.
# When injected, its result is prepended to the Confluence-writer prompt. Default
# None = unchanged behavior so existing callers are unaffected.
ContextProvider = Callable[[], str]
__all__ = [
"ConfluenceDraftError",
"build_confluence_prompt",
"parse_confluence_reply",
]
class ConfluenceDraftError(Exception):
"""Raised when the model reply cannot be parsed into a usable draft.
Distinct from a *gate rejection* (which loops back through the human gate):
this signals the draft model output itself was empty/garbled, so the node
can fail loudly rather than advance an empty page update toward a write.
"""
def _task_description(state: PipelineState) -> str:
"""Pull the task description out of the graph state (mirrors planner.py)."""
plan = state.get("plan") or {}
if isinstance(plan, dict):
desc = plan.get("task") or plan.get("description")
if isinstance(desc, str) and desc.strip():
return desc.strip()
desc = state.get("task") # type: ignore[call-overload]
if isinstance(desc, str) and desc.strip():
return desc.strip()
return ""
def _repo_name(state: PipelineState) -> str:
"""Pull the repo name out of the graph state (Flow B), defensively.
Looked for under a top-level ``repo`` key and, failing that, inside the plan
dict — the same conventional places the clarifier reads ``repo`` from. An
absent repo yields an empty string so the prompt section is simply omitted.
"""
repo = state.get("repo") # type: ignore[call-overload]
if isinstance(repo, str) and repo.strip():
return repo.strip()
plan = state.get("plan") or {}
if isinstance(plan, dict):
repo = plan.get("repo")
if isinstance(repo, str) and repo.strip():
return repo.strip()
return ""
def _candidate_diff(state: PipelineState) -> str:
"""Pull the candidate diff (Flow B) out of the graph state, defensively."""
diff = state.get("candidate_diff")
if isinstance(diff, str) and diff.strip():
return diff.strip()
return ""
def _format_plan(plan: Any) -> str:
"""Render the approved plan into prompt text (Flow B)."""
if not isinstance(plan, Mapping):
return ""
try:
return json.dumps(dict(plan), sort_keys=True, indent=2)
except (TypeError, ValueError):
return repr(plan)
def _truncate(text: str, limit: int) -> str:
"""Truncate ``text`` to ``limit`` chars with a marker (keep prompts bounded).
A candidate diff can be large; the draft only needs enough of it to describe
the change, so we cap it rather than blow the context budget.
"""
if len(text) <= limit:
return text
return text[:limit] + f"\n... [truncated {len(text) - limit} chars]"
def build_confluence_prompt(
state: PipelineState,
*,
context_provider: ContextProvider | None = None,
) -> str:
"""Build the Claude prompt that drafts a Confluence page update.
Pure string assembly over the graph state — no I/O — so the prompt shape is
directly unit-testable (mirrors :func:`planner.build_plan_prompt`).
Source selection:
* **Flow A** (no plan/diff) — the draft is built from ``state['task']``.
* **Flow B** (plan and/or candidate diff present) — the draft folds in the
approved plan, the candidate diff, and the repo name.
On a ``request_changes`` loop-back the prior ``confluence_feedback`` is
folded in so the redraft addresses every note (mirrors the planner's
review-feedback loop-back). ``context_provider`` is the optional injection
seam: when set it is called once and its result prepended (and a raising
provider degrades to an empty prefix, never a crash).
"""
description = _task_description(state)
repo = _repo_name(state)
diff = _candidate_diff(state)
plan = state.get("plan")
plan_text = _format_plan(plan)
feedback = state.get("confluence_feedback")
flow_b = bool(plan_text or diff)
sections: list[str] = []
if context_provider is not None:
try:
ctx = context_provider()
except Exception: # noqa: BLE001 - a context provider must never crash the draft
ctx = ""
if ctx:
sections += [ctx, ""]
sections += [
"You are the CONFLUENCE-WRITER stage of an agentic SDLC pipeline. "
"Inspect the repository (read-only) and draft a Confluence page update "
"that documents this work. Emit the draft as DATA only — do NOT edit, "
"create, or write any files, and do NOT post to Confluence; a later "
"human-gated node performs the actual write.",
"",
"## Task",
description or "(no task description provided)",
]
if repo:
sections += ["", "## Repository", repo]
if flow_b:
if plan_text:
sections += ["", "## Approved plan (what was decided)", plan_text]
if diff:
sections += [
"",
"## Candidate diff (what the change actually did)",
_truncate(diff, 8000),
]
sections += [
"",
"## Documentation intent",
"Update the Confluence page so it reflects the architecture AFTER "
"this change: new/removed/modified resources, data flow, and "
"configuration. If the page carries an architecture diagram macro, "
"describe the mermaid edits needed rather than rewriting unrelated "
"content.",
]
else:
sections += [
"",
"## Documentation intent",
"Draft the Confluence page update this documentation task asks for.",
]
if isinstance(feedback, str) and feedback.strip():
sections += [
"",
"## Reviewer feedback on the previous draft (address every point)",
feedback.strip(),
]
sections += [
"",
"## Output format",
"Return ONLY a JSON object with keys: "
'"title" (string, the page title), '
'"body_storage" (string, the page body in Confluence STORAGE format / '
"XHTML), "
'"page_id" (optional string, the id of an existing page to update; omit '
"or null to create), and "
'"mermaid_edits" (optional list of objects each with "macro_id" or '
'"anchor" and a "mermaid" string, for architecture-diagram macro '
"updates). Do not include prose outside the JSON. Do NOT use any "
"write/edit tools.",
]
return "\n".join(sections)
# A fenced ```...``` block, if the model wrapped its JSON in Markdown.
_FENCE_RE = re.compile(
r"```(?:json)?\s*\n?(?P<body>.*?)\n?\s*```",
flags=re.DOTALL | re.IGNORECASE,
)
def _strip_code_fence(text: str) -> str:
"""Strip a leading/trailing Markdown code fence if present (mirrors planner)."""
fenced = re.match(
r"^\s*```(?:json)?\s*\n(?P<body>.*?)\n?\s*```\s*$",
text,
flags=re.DOTALL | re.IGNORECASE,
)
if fenced:
return fenced.group("body")
return text
def _first_brace_span(text: str) -> str | None:
"""Return the first balanced ``{...}`` span in ``text`` (string-aware)."""
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escaped = False
for idx in range(start, len(text)):
ch = text[idx]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[start : idx + 1]
return None
def _extract_json_object(text: str) -> dict[str, Any] | None:
"""Extract a JSON object from UNTRUSTED model output, or ``None``.
Tolerates the common deviations from "JSON only" (leading apology, trailing
prose, ```json fences) by trying, in order: the whole string, the fenced
block body, then the first balanced ``{...}`` span. Returns ``None`` (never
raises) when nothing parses to a JSON object.
"""
if not isinstance(text, str) or not text.strip():
return None
candidates: list[str] = [_strip_code_fence(text).strip(), text.strip()]
fence = _FENCE_RE.search(text)
if fence:
candidates.append(fence.group("body").strip())
span = _first_brace_span(text)
if span is not None:
candidates.append(span)
for candidate in candidates:
if not candidate:
continue
try:
parsed = json.loads(candidate)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(parsed, dict):
return parsed
return None
def _coerce_mermaid_edits(value: Any) -> list[dict[str, Any]]:
"""Coerce the optional ``mermaid_edits`` into a clean list of edit dicts.
Each kept entry must carry a non-empty ``mermaid`` string; an anchor is kept
when present (either ``macro_id`` or ``anchor``). Anything malformed is
dropped so a garbled edit can never silently corrupt a macro update.
"""
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
return []
edits: list[dict[str, Any]] = []
for item in value:
if not isinstance(item, Mapping):
continue
mermaid = item.get("mermaid")
if not isinstance(mermaid, str) or not mermaid.strip():
continue
edit: dict[str, Any] = {"mermaid": mermaid.strip()}
macro_id = item.get("macro_id")
anchor = item.get("anchor")
if isinstance(macro_id, str) and macro_id.strip():
edit["macro_id"] = macro_id.strip()
if isinstance(anchor, str) and anchor.strip():
edit["anchor"] = anchor.strip()
edits.append(edit)
return edits
def parse_confluence_reply(text: str) -> dict[str, Any]:
"""Parse the model reply into a validated ``confluence_draft`` dict.
Returns ``{title, body_storage, page_id?, mermaid_edits?}``:
* ``title`` — non-empty page title (required);
* ``body_storage`` — the Confluence storage-format body (required);
* ``page_id`` — included only when the model named an existing page;
* ``mermaid_edits`` — included only when at least one well-formed edit was
supplied.
Raises :class:`ConfluenceDraftError` when the reply is empty, not a JSON
object, or missing a usable ``title`` / ``body_storage`` — a garbled draft
must fail loudly so the node never advances an empty page write.
"""
data = _extract_json_object(text or "")
if data is None:
raise ConfluenceDraftError(
"confluence draft reply was empty or not a JSON object"
)
title = data.get("title")
if not isinstance(title, str) or not title.strip():
raise ConfluenceDraftError("confluence draft is missing a non-empty 'title'")
body = data.get("body_storage")
if not isinstance(body, str) or not body.strip():
raise ConfluenceDraftError(
"confluence draft is missing a non-empty 'body_storage'"
)
draft: dict[str, Any] = {
"title": title.strip(),
"body_storage": body,
}
page_id = data.get("page_id")
if isinstance(page_id, (str, int)) and str(page_id).strip():
page_id_str = str(page_id).strip()
# Confluence content ids are numeric. Reject anything else: a non-numeric
# value (e.g. "123?expand=foo" or "123/child/456") would, if interpolated
# into the REST path, rewrite the authenticated request — a request-path
# injection carrying the service-account credential.
if not re.fullmatch(r"[0-9]+", page_id_str):
raise ConfluenceDraftError(
"confluence draft 'page_id' must be numeric (a Confluence content id)"
)
draft["page_id"] = page_id_str
mermaid_edits = _coerce_mermaid_edits(data.get("mermaid_edits"))
if mermaid_edits:
draft["mermaid_edits"] = mermaid_edits
return draft

View file

@ -63,6 +63,12 @@ class Phase(Enum):
REVIEW = "review"
BUILD = "build"
VERIFY = "verify"
# Direct-Confluence task lane (task_kind == "confluence"): draft the page
# change, gate it for owner approve / request_changes, then write it. The
# terminals reuse the shared DONE / PARKED phases.
CONF_DRAFT = "conf_draft"
CONF_GATE = "conf_gate"
CONF_WRITE = "conf_write"
PARKED = "parked"
DONE = "done"
@ -129,6 +135,19 @@ class TaskRecord:
# Set when a pipeline node raised during resume and the coordinator failed
# the task (mirrors PipelineState.failure_reason). Empty on a healthy task.
failure_reason: str = ""
# Direct-Confluence lane state (mirrors the PipelineState keys below). All
# defaulted so an existing record (no Confluence lane) round-trips unchanged.
# ``task_kind`` "" keeps the default clarify->plan->review path; "confluence"
# routes through CONF_DRAFT->CONF_GATE->CONF_WRITE. ``confluence_draft`` is the
# drafted page change awaiting the gate; ``confluence_feedback`` the owner's
# request_changes notes the redraft reads; ``confluence_gate_visits`` the
# ceiling on draft<->gate revision loops (mirrors plan_gate_visits);
# ``confluence_result`` the written-page outcome (id, version, url).
task_kind: str = ""
confluence_draft: dict[str, Any] | None = None
confluence_feedback: str = ""
confluence_gate_visits: int = 0
confluence_result: dict[str, Any] | None = None
class PipelineState(TypedDict, total=False):
@ -185,6 +204,24 @@ class PipelineState(TypedDict, total=False):
# short "ExcType: message" so the failure notification can say what broke.
# Absent on a healthy task.
failure_reason: str
# Intake discriminator routing a task into a non-default lane. "" (default,
# absent) keeps the existing clarify->plan->review path; "confluence" routes
# a direct Confluence-documentation task through CONF_DRAFT->CONF_GATE->
# CONF_WRITE.
task_kind: str
# The drafted Confluence page change awaiting the gate (title, body, space,
# parent, target page id, etc.). None until CONF_DRAFT produces it.
confluence_draft: dict | None
# Free-text owner feedback captured at CONF_GATE on a request_changes
# decision; CONF_DRAFT reads it to revise the draft. Empty when none.
confluence_feedback: str
# Count of CONF_GATE human-decision visits consumed for THIS task. The
# combined ceiling on confluence draft<->gate revision loops so a
# perpetually-revised page can never spin forever (mirrors plan_gate_visits).
confluence_gate_visits: int
# The result of the CONF_WRITE Confluence API call (page id, version, url).
# None until the page has been written.
confluence_result: dict | None
def task_to_dict(record: TaskRecord) -> dict[str, Any]:
@ -217,6 +254,12 @@ def task_from_dict(data: dict[str, Any]) -> TaskRecord:
transport=data.get("transport", ""),
created_at=data.get("created_at"),
updated_at=data.get("updated_at"),
failure_reason=data.get("failure_reason", ""),
task_kind=data.get("task_kind", ""),
confluence_draft=data.get("confluence_draft"),
confluence_feedback=data.get("confluence_feedback", ""),
confluence_gate_visits=data.get("confluence_gate_visits", 0),
confluence_result=data.get("confluence_result"),
)

View file

@ -99,6 +99,31 @@ NODE_META: dict[str, dict[str, Any]] = {
"kind": "phase",
"gated": True,
},
# Confluence-writer lane (opt-in, gated behind AGENT_TEAM_CONFLUENCE_ENABLED):
# draft -> human approval gate -> (dry-run) write. Its own "docs" process tree;
# reached from intake (Flow A: task_kind=confluence) and from verify (Flow B:
# document a shipped change). gated=True so the UI dims it in the default deploy.
graph_mod.CONF_DRAFT_NODE: {
"label": "Conf Draft",
"agent": "Claude (sub)",
"tree": "docs",
"kind": "phase",
"gated": True,
},
graph_mod.CONF_GATE: {
"label": "Conf Gate",
"agent": "Owner",
"tree": "docs",
"kind": "gate", # human approval interrupt before any write
"gated": True,
},
graph_mod.CONF_WRITE_NODE: {
"label": "Conf Write",
"agent": "Confluence",
"tree": "docs",
"kind": "phase",
"gated": True,
},
}
# Process trees, in display order. ``root`` flags the tree that owns intake (the
@ -106,6 +131,7 @@ NODE_META: dict[str, dict[str, Any]] = {
TREES: tuple[dict[str, Any], ...] = (
{"id": "core", "label": "Core", "root": True},
{"id": "sdlc", "label": "SDLC Pipeline", "root": False},
{"id": "docs", "label": "Documentation", "root": False},
)
# Default tree/meta for a graph node with no NODE_META entry (a newly added
@ -129,6 +155,9 @@ PHASE_TO_NODE: dict[str, str] = {
Phase.REVIEW.value: graph_mod.REVIEW,
Phase.BUILD.value: graph_mod.BUILD_NODE,
Phase.VERIFY.value: graph_mod.VERIFY_NODE,
Phase.CONF_DRAFT.value: graph_mod.CONF_DRAFT_NODE,
Phase.CONF_GATE.value: graph_mod.CONF_GATE,
Phase.CONF_WRITE.value: graph_mod.CONF_WRITE_NODE,
}
@ -159,6 +188,7 @@ def _maximal_compiled() -> Any:
route_review=_stub_route,
build_verify=(_stub_node, _stub_node, _stub_route),
dispatch_node=_stub_node,
confluence=True, # include the Confluence-writer lane in the drawn shape
)

View file

@ -0,0 +1,546 @@
"""Unit tests for agent_team.confluence.client (OAuth 2LO + basic auth + dry-run updater).
github-advanced-security[bot] commented 2026-06-24 23:25:20 +00:00 (Migrated from github.com)
Review

CodeQL / Incomplete URL substring sanitization

The string https://seahaven.atlassian.net may be at an arbitrary position in the sanitized URL.

Show more details

## CodeQL / Incomplete URL substring sanitization The string [https://seahaven.atlassian.net](1) may be at an arbitrary position in the sanitized URL. [Show more details](https://github.com/Sea-Haven-Industries/orchestrator/security/code-scanning/7)
Fully hermetic: the HTTP transport is dependency-injected with an in-memory
fake :class:`HttpClient`, so no network call, OAuth token, or live Confluence is
exercised. Credentials are injected via ``monkeypatch.setenv``/``delenv`` and
never read from a real environment.
"""
from __future__ import annotations
import base64
import json
from typing import Any
from urllib import parse as _urlparse
import pytest
from agent_team.nodes.confluence_writer_llm import (
ConfluenceDraftError,
parse_confluence_reply,
)
from agent_team.confluence.client import (
ACCESSIBLE_RESOURCES_URL,
ATLASSIAN_API_BASE,
DEFAULT_OAUTH_TOKEN_URL,
ConfluenceAuth,
ConfluenceClient,
ConfluenceError,
PlannedPageUpdate,
body_diff,
count_storage_macros,
init_auth,
storage_macro_signature,
)
# --------------------------------------------------------------------------- #
# Fakes / fixtures
# --------------------------------------------------------------------------- #
_CONFLUENCE_ENV_KEYS = (
"CONFLUENCE_OAUTH_CLIENT_ID",
"CONFLUENCE_OAUTH_CLIENT_SECRET",
"CONFLUENCE_OAUTH_TOKEN_URL",
"CONFLUENCE_CLOUD_ID",
"CONFLUENCE_BASE_URL",
"CONFLUENCE_EMAIL",
"CONFLUENCE_API_TOKEN",
)
class FakeHttp:
"""In-memory :class:`HttpClient` recording calls and returning scripted
``(status, body)`` per verb.
``responses`` maps a verb to a list of scripted ``(status, body)`` tuples,
consumed in order; a single tuple is reused for every call to that verb.
"""
def __init__(
self,
*,
get: Any = None,
post: Any = None,
put: Any = None,
) -> None:
self._scripts: dict[str, Any] = {"get": get, "post": post, "put": put}
self.calls: list[dict[str, Any]] = []
def _next(self, verb: str) -> tuple[int, Any]:
script = self._scripts[verb]
if script is None:
raise AssertionError(f"unexpected {verb.upper()} call")
if isinstance(script, list):
return script.pop(0)
return script
def get(
self, url: str, *, headers: dict[str, str]
) -> tuple[int, dict[str, Any] | bytes]:
self.calls.append({"verb": "get", "url": url, "headers": headers})
return self._next("get")
def post(
self, url: str, *, headers: dict[str, str], data: bytes
) -> tuple[int, dict[str, Any] | bytes]:
self.calls.append(
{"verb": "post", "url": url, "headers": headers, "data": data}
)
return self._next("post")
def put(
self, url: str, *, headers: dict[str, str], data: bytes
) -> tuple[int, dict[str, Any] | bytes]:
self.calls.append({"verb": "put", "url": url, "headers": headers, "data": data})
return self._next("put")
def _clear_confluence_env(monkeypatch: pytest.MonkeyPatch) -> None:
for key in _CONFLUENCE_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
def _storage_page(page_id: str, version: int, value: str) -> dict[str, Any]:
return {
"id": page_id,
"title": "Existing Title",
"version": {"number": version},
"body": {"storage": {"representation": "storage", "value": value}},
}
# --------------------------------------------------------------------------- #
# OAuth 2LO path
# --------------------------------------------------------------------------- #
def test_oauth_token_post_and_cloud_id_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
monkeypatch.setenv("CONFLUENCE_CLOUD_ID", "cloud-123")
http = FakeHttp(post=(200, {"access_token": "tok-abc"}))
auth = init_auth(http)
assert auth.mode == "oauth"
assert auth.base == f"{ATLASSIAN_API_BASE}/cloud-123"
assert auth.base == "https://api.atlassian.com/ex/confluence/cloud-123"
assert auth.headers()["Authorization"] == "Bearer tok-abc"
# Exactly one POST (the token request) — cloudId came from env, no GET.
(post_call,) = [c for c in http.calls if c["verb"] == "post"]
assert post_call["url"] == DEFAULT_OAUTH_TOKEN_URL
form = dict(_urlparse.parse_qsl(post_call["data"].decode("utf-8")))
assert form["grant_type"] == "client_credentials"
assert form["client_id"] == "cid"
assert form["client_secret"] == "secret"
assert not [c for c in http.calls if c["verb"] == "get"]
def test_oauth_cloud_id_resolved_from_accessible_resources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net")
resources = [
{"id": "other-cloud", "url": "https://other.atlassian.net"},
{"id": "match-cloud", "url": "https://seahaven.atlassian.net"},
]
http = FakeHttp(
post=(200, {"access_token": "tok-xyz"}),
get=(200, resources),
)
auth = init_auth(http)
# Site URL matches the second resource, so its id wins.
assert auth.base == f"{ATLASSIAN_API_BASE}/match-cloud"
get_call = next(c for c in http.calls if c["verb"] == "get")
assert get_call["url"] == ACCESSIBLE_RESOURCES_URL
assert get_call["headers"]["Authorization"] == "Bearer tok-xyz"
def test_oauth_cloud_id_falls_back_to_first_resource(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
# No CONFLUENCE_BASE_URL -> no site match -> first resource id wins.
resources = [{"id": "first-cloud", "url": "https://a.atlassian.net"}]
http = FakeHttp(post=(200, {"access_token": "t"}), get=(200, resources))
auth = init_auth(http)
assert auth.base == f"{ATLASSIAN_API_BASE}/first-cloud"
def test_oauth_token_request_failure_raises_skip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
http = FakeHttp(post=(401, {"error": "invalid_client"}))
with pytest.raises(ConfluenceError) as exc:
init_auth(http)
assert exc.value.status == 0 # conf_api_init-style "skip, no false alarm".
def test_oauth_unresolvable_cloud_id_raises_skip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
http = FakeHttp(post=(200, {"access_token": "t"}), get=(200, []))
with pytest.raises(ConfluenceError) as exc:
init_auth(http)
assert exc.value.status == 0
def test_oauth_token_url_override(monkeypatch: pytest.MonkeyPatch) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
monkeypatch.setenv("CONFLUENCE_CLOUD_ID", "cloud-1")
monkeypatch.setenv("CONFLUENCE_OAUTH_TOKEN_URL", "https://custom.example/oauth")
http = FakeHttp(post=(200, {"access_token": "t"}))
init_auth(http)
post_call = next(c for c in http.calls if c["verb"] == "post")
assert post_call["url"] == "https://custom.example/oauth"
# --------------------------------------------------------------------------- #
# Basic-auth fallback
# --------------------------------------------------------------------------- #
def test_basic_auth_fallback_when_oauth_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net/")
monkeypatch.setenv("CONFLUENCE_EMAIL", "adam@seahavenind.com")
monkeypatch.setenv("CONFLUENCE_API_TOKEN", "api-tok")
http = FakeHttp() # No HTTP call expected for basic-auth resolution.
auth = init_auth(http)
assert auth.mode == "basic"
# Trailing slash stripped.
assert auth.base == "https://seahaven.atlassian.net"
expected = base64.b64encode(b"adam@seahavenind.com:api-tok").decode("ascii")
assert auth.headers()["Authorization"] == f"Basic {expected}"
assert http.calls == []
def test_oauth_wins_over_basic_when_both_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
monkeypatch.setenv("CONFLUENCE_CLOUD_ID", "cloud-1")
monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net")
monkeypatch.setenv("CONFLUENCE_EMAIL", "adam@seahavenind.com")
monkeypatch.setenv("CONFLUENCE_API_TOKEN", "api-tok")
http = FakeHttp(post=(200, {"access_token": "tok"}))
auth = init_auth(http)
assert auth.mode == "oauth"
# --------------------------------------------------------------------------- #
# Missing-creds path (detectable skip, no crash)
# --------------------------------------------------------------------------- #
def test_missing_creds_raises_skip_status_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
http = FakeHttp()
with pytest.raises(ConfluenceError) as exc:
init_auth(http)
assert exc.value.status == 0 # mirrors conf_api_init returning non-zero.
assert http.calls == []
def test_incomplete_basic_creds_raises_skip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_confluence_env(monkeypatch)
# Base URL + email but NO api token -> not fully configured -> skip.
monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net")
monkeypatch.setenv("CONFLUENCE_EMAIL", "adam@seahavenind.com")
with pytest.raises(ConfluenceError) as exc:
init_auth(FakeHttp())
assert exc.value.status == 0
def test_client_uses_injected_env_mapping() -> None:
# No monkeypatch: the explicit env mapping is read at call time, not import.
env = {
"CONFLUENCE_BASE_URL": "https://seahaven.atlassian.net",
"CONFLUENCE_EMAIL": "adam@seahavenind.com",
"CONFLUENCE_API_TOKEN": "tok",
}
page = _storage_page("100", 4, "<p>hi</p>")
http = FakeHttp(get=(200, page))
client = ConfluenceClient(http=http, env=env)
fetched = client.get_page("100")
assert fetched["version"]["number"] == 4
# Assert the request host EXACTLY (scheme+netloc parsed), not a string prefix:
# a `.startswith("https://seahaven.atlassian.net")` check passes for a spoofed
# host like `https://seahaven.atlassian.net.evil.com` (CodeQL: incomplete URL
# substring sanitization). Compare the parsed components instead.
parts = _urlparse.urlsplit(http.calls[0]["url"])
assert (parts.scheme, parts.netloc) == ("https", "seahaven.atlassian.net")
assert parts.path.startswith("/wiki/api/v2/pages/100")
# --------------------------------------------------------------------------- #
# get_page
# --------------------------------------------------------------------------- #
def test_get_page_parses_storage_format() -> None:
page = _storage_page("12345", 7, "<p>Current body</p>")
http = FakeHttp(get=(200, page))
auth = ConfluenceAuth(
mode="oauth",
base="https://api.atlassian.com/ex/confluence/cloud-1",
_headers={"Authorization": "Bearer t", "Accept": "application/json"},
)
client = ConfluenceClient(http=http, auth=auth)
result = client.get_page("12345")
assert result["version"]["number"] == 7
assert result["body"]["storage"]["value"] == "<p>Current body</p>"
(call,) = http.calls
assert call["url"] == (
"https://api.atlassian.com/ex/confluence/cloud-1"
"/wiki/api/v2/pages/12345?body-format=storage"
)
def test_get_page_encodes_injected_id_in_request_path() -> None:
# Defense-in-depth: a malformed (injected) id must not appear verbatim in the
# request path — it would otherwise rewrite the authenticated request
# (request-path injection carrying the service-account credential).
page = _storage_page("123", 1, "<p>x</p>")
http = FakeHttp(get=(200, page))
auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net")
client = ConfluenceClient(http=http, auth=auth)
injected = "123?body-format=atlas_doc_format"
client.get_page(injected)
(call,) = http.calls
# The raw injected query must NOT survive as a literal path/query segment.
assert injected not in call["url"]
# The whole id is percent-encoded into a single path segment; the "?" that
# would start a new query string is escaped to %3F and cannot inject.
assert (
"/pages/123%3Fbody-format%3Datlas_doc_format?body-format=storage" in call["url"]
)
def test_get_page_non_2xx_raises_with_status() -> None:
http = FakeHttp(get=(404, {"message": "Not Found"}))
auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net")
client = ConfluenceClient(http=http, auth=auth)
with pytest.raises(ConfluenceError) as exc:
client.get_page("999")
assert exc.value.status == 404
assert "Not Found" in exc.value.body
# --------------------------------------------------------------------------- #
# update_page — dry-run (default) issues NO PUT
# --------------------------------------------------------------------------- #
def _basic_client(http: FakeHttp) -> ConfluenceClient:
auth = ConfluenceAuth(
mode="basic",
base="https://seahaven.atlassian.net",
_headers={"Authorization": "Basic x", "Accept": "application/json"},
)
return ConfluenceClient(http=http, auth=auth)
def test_update_page_dry_run_default_issues_no_put() -> None:
current = _storage_page("777", 3, "<p>old</p>")
http = FakeHttp(get=(200, current)) # PUT script omitted -> would assert.
client = _basic_client(http)
planned = client.update_page("777", "New Title", "<p>new</p>", 3)
assert isinstance(planned, PlannedPageUpdate)
assert planned.applied is False
assert planned.new_version == 4 # current + 1
assert planned.current_version == 3
assert planned.body_storage == "<p>new</p>"
# A unified diff against the current body shows the change.
assert "<p>old</p>" in planned.body_delta
assert "<p>new</p>" in planned.body_delta
# Only the read (GET) happened — NO PUT.
assert [c["verb"] for c in http.calls] == ["get"]
def test_update_page_dry_run_diff_against_empty_when_read_fails() -> None:
# get_page returns 404 -> client swallows and diffs against an empty baseline.
http = FakeHttp(get=(404, {"message": "gone"}))
client = _basic_client(http)
planned = client.update_page("404id", "T", "<p>brand new</p>", 0)
assert planned.applied is False
assert planned.new_version == 1
assert "<p>brand new</p>" in planned.body_delta
# --------------------------------------------------------------------------- #
# update_page — apply=True issues the PUT
# --------------------------------------------------------------------------- #
def test_update_page_apply_true_issues_put() -> None:
current = _storage_page("555", 9, "<p>old</p>")
updated = _storage_page("555", 10, "<p>updated</p>")
http = FakeHttp(get=(200, current), put=(200, updated))
client = _basic_client(http)
result = client.update_page("555", "Title", "<p>updated</p>", 9, apply=True)
assert result.applied is True
assert result.new_version == 10
put_call = next(c for c in http.calls if c["verb"] == "put")
assert put_call["url"] == "https://seahaven.atlassian.net/wiki/api/v2/pages/555"
payload = json.loads(put_call["data"].decode("utf-8"))
assert payload["id"] == "555"
assert payload["status"] == "current"
assert payload["title"] == "Title"
assert payload["version"]["number"] == 10
assert payload["body"]["representation"] == "storage"
assert payload["body"]["value"] == "<p>updated</p>"
assert put_call["headers"]["Content-Type"] == "application/json"
def test_update_page_apply_true_non_2xx_put_raises() -> None:
current = _storage_page("321", 1, "<p>x</p>")
http = FakeHttp(get=(200, current), put=(409, {"message": "version conflict"}))
client = _basic_client(http)
with pytest.raises(ConfluenceError) as exc:
client.update_page("321", "T", "<p>y</p>", 1, apply=True)
assert exc.value.status == 409
assert "version conflict" in exc.value.body
# --------------------------------------------------------------------------- #
# Pure planning helper
# --------------------------------------------------------------------------- #
def test_parse_confluence_reply_rejects_non_numeric_page_id() -> None:
# Layer-1 guard: an LLM-supplied page_id that is not a bare numeric content
# id (here carrying a request-path-injection payload) must fail the draft.
reply = json.dumps(
{
"title": "T",
"body_storage": "<p>b</p>",
"page_id": "123?body-format=atlas_doc_format",
}
)
with pytest.raises(ConfluenceDraftError):
parse_confluence_reply(reply)
def test_parse_confluence_reply_accepts_numeric_page_id() -> None:
reply = json.dumps({"title": "T", "body_storage": "<p>b</p>", "page_id": "123456"})
draft = parse_confluence_reply(reply)
assert draft["page_id"] == "123456"
def test_body_diff_empty_when_identical() -> None:
assert body_diff("<p>same</p>", "<p>same</p>", page_id="1") == ""
def test_body_diff_shows_added_and_removed() -> None:
delta = body_diff("line a\nline b\n", "line a\nline c\n", page_id="99")
assert "page/99@current" in delta
assert "page/99@planned" in delta
assert "-line b" in delta
assert "+line c" in delta
# --------------------------------------------------------------------------- #
# count_storage_macros + page_has_macros (macro-preservation support)
# --------------------------------------------------------------------------- #
def test_count_storage_macros_counts_structured_and_adf_extensions() -> None:
body = (
'<ac:structured-macro ac:name="mermaid-cloud"/>'
"<p>text</p>"
'<AC:STRUCTURED-MACRO ac:name="info"/>' # case-insensitive
"<ac:adf-extension>x</ac:adf-extension>"
)
assert count_storage_macros(body) == 3
assert count_storage_macros("") == 0
assert count_storage_macros("<p>no macros here</p>") == 0
def test_storage_macro_signature_keys_by_identity() -> None:
body = (
'<ac:structured-macro ac:name="mermaid-cloud"/>'
'<ac:structured-macro ac:name="info"/>'
"<ac:structured-macro>x</ac:structured-macro>" # unnamed
"<ac:adf-extension>y</ac:adf-extension>"
)
sig = storage_macro_signature(body)
assert sig["macro:mermaid-cloud"] == 1
assert sig["macro:info"] == 1
assert sig["macro:_unnamed"] == 1
assert sig["adf-extension"] == 1
assert storage_macro_signature("") == {}
def test_page_has_macros_true_when_body_carries_macro() -> None:
page = _storage_page("1540098", 7, '<ac:structured-macro ac:name="mermaid"/>')
http = FakeHttp(get=(200, page))
auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net")
client = ConfluenceClient(http=http, auth=auth)
assert client.page_has_macros("1540098") is True
def test_page_has_macros_false_for_plain_body() -> None:
page = _storage_page("100", 1, "<p>plain</p>")
http = FakeHttp(get=(200, page))
auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net")
client = ConfluenceClient(http=http, auth=auth)
assert client.page_has_macros("100") is False
def test_oauth_cloud_id_ambiguous_multiple_resources_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# AUTHZ-CONF-02: with no site_url to disambiguate and MULTIPLE accessible
# sites, refuse to silently pick the first — require CONFLUENCE_CLOUD_ID.
_clear_confluence_env(monkeypatch)
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret")
resources = [
{"id": "site-a", "url": "https://a.atlassian.net"},
{"id": "site-b", "url": "https://b.atlassian.net"},
]
http = FakeHttp(post=(200, {"access_token": "t"}), get=(200, resources))
with pytest.raises(ConfluenceError, match="multiple accessible Confluence sites"):
init_auth(http)

View file

@ -0,0 +1,186 @@
"""Integration tests for the Confluence-writer sub-pipeline wired through the
COORDINATOR (Confluence Flow A — direct documentation task).
These pin the two confirmed-defect contracts:
* **Gate delivery (CRITICAL).** With ``confluence=True`` a task started via
:meth:`Coordinator.start_task(task_kind="confluence")` routes INTAKE straight
to CONF_DRAFT and suspends at CONF_GATE on a ``kind='confluence_approval'``
interrupt. The coordinator MUST deliver that gate — open a durable
``pending_questions`` row with ``kind='confluence_approval'`` AND post the
approve / request_changes / abandon presentation to the transport. (Before the
fix the gate was never delivered: the coordinator branched only on
``PLAN_DECISION_KIND`` and then KeyError-ed / fell through, so the task
suspended forever with no human notified and no ledger row.)
* **Flag OFF = byte-identical to today (MEDIUM).** With ``confluence=False`` the
compiled graph has NO ``conf_*`` vertices and a ``task_kind="confluence"`` task
flows through the ordinary clarify path (INTAKE -> CLARIFY), so existing daemon
behaviour is unchanged when the flag is unset.
The Confluence draft node is AGENTIC (it calls ``claude_invoke``); the billing
seam is stubbed to return a fixed JSON draft so no SDK / network is touched.
"""
from __future__ import annotations
import queue
from pathlib import Path
from typing import Any
import pytest
try: # InMemorySaver is the modern name; fall back on older langgraph.
from langgraph.checkpoint.memory import InMemorySaver as _Saver
except ImportError: # pragma: no cover - environment-dependent
from langgraph.checkpoint.memory import MemorySaver as _Saver
from agent_team import graph as graph_mod
from agent_team.billing import ClaudeResult
from agent_team.coordinator import Coordinator
from agent_team.db.schema import connect, init_db
from agent_team.transport.base import QuestionSet, Transport
class FakeTransport(Transport):
"""Record-only transport (mirrors tests/test_coordinator.py's fake)."""
def __init__(self) -> None:
self.posted: list[QuestionSet] = []
self.thread_tss: list[str | None] = []
def post_question(
self,
*,
thread_id: str,
question_id: str,
turn: int,
question_set: QuestionSet,
deadline: str,
thread_ts: str | None = None,
) -> str:
self.posted.append(question_set)
self.thread_tss.append(thread_ts)
return f"fake:{question_id}"
def parse_answer(self, raw: Any) -> tuple[str, Any, str]:
return raw["question_id"], raw["answer"], raw.get("via", "fake")
_DRAFT_JSON = (
'{"title": "Agent-Team Architecture", '
'"body_storage": "<p>How the daemon works.</p>"}'
)
@pytest.fixture(autouse=True)
def _stub_confluence_draft_invoke(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub the agentic draft call so CONF_DRAFT returns a fixed draft (no SDK)."""
def _fake_invoke(prompt: str, **_kw: Any) -> ClaudeResult:
return ClaudeResult(text=_DRAFT_JSON, mode="subscription")
monkeypatch.setattr(
"agent_team.nodes.confluence_writer.claude_invoke", _fake_invoke
)
def _make_coordinator(
db_path: Path, *, confluence: bool, transport: Transport | None = None
) -> Coordinator:
saver = _Saver()
return Coordinator(
db_path=db_path,
transport=transport or FakeTransport(),
build_clarify_node=lambda: graph_mod.clarify_node,
build_checkpointer=lambda _path: saver,
resume_queue=queue.Queue(),
confluence=confluence,
)
@pytest.fixture()
def db_path(tmp_path: Path) -> Path:
path = tmp_path / "state" / "agent_team.sqlite"
init_db(path)
return path
def _open_rows(db_path: Path) -> list[dict[str, Any]]:
conn = connect(db_path)
try:
rows = conn.execute(
"SELECT * FROM pending_questions WHERE status='open'"
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
# --------------------------------------------------------------------------- #
# (a) Flag ON: confluence task routes to CONF_DRAFT and the gate is delivered.
# --------------------------------------------------------------------------- #
def test_confluence_flow_a_delivers_approval_gate(db_path: Path) -> None:
transport = FakeTransport()
coord = _make_coordinator(db_path, confluence=True, transport=transport)
coord.setup()
thread_id = coord.start_task(
task_text="document the architecture",
transport_name="slack",
task_kind="confluence",
)
assert thread_id
# The task suspended at CONF_GATE on a confluence_approval interrupt (NOT the
# clarifier) — Flow A routed INTAKE straight to CONF_DRAFT.
pending = graph_mod.pending_question(coord.graph, thread_id=thread_id)
assert pending is not None
assert pending["kind"] == graph_mod.CONFLUENCE_APPROVAL_KIND
# The gate was DELIVERED: a durable open ledger row with the confluence kind.
rows = _open_rows(db_path)
assert len(rows) == 1
assert rows[0]["kind"] == graph_mod.CONFLUENCE_APPROVAL_KIND
assert rows[0]["thread_id"] == thread_id
# AND the approve/request_changes/abandon presentation was posted to the
# transport, tagged with the confluence-approval discriminator so the Slack
# adapter renders decision buttons.
assert len(transport.posted) == 1
assert transport.posted[0].context.get("kind") == graph_mod.CONFLUENCE_APPROVAL_KIND
# --------------------------------------------------------------------------- #
# (b) Flag OFF: no conf_* nodes; a confluence task uses the ordinary clarify path.
# --------------------------------------------------------------------------- #
def test_confluence_off_has_no_conf_nodes_and_ignores_task_kind(
db_path: Path,
) -> None:
transport = FakeTransport()
coord = _make_coordinator(db_path, confluence=False, transport=transport)
coord.setup()
# The compiled graph has NO conf_* vertices when the flag is off.
nodes = set(coord.graph.get_graph().nodes)
assert graph_mod.CONF_DRAFT_NODE not in nodes
assert graph_mod.CONF_GATE not in nodes
assert graph_mod.CONF_WRITE_NODE not in nodes
# A task_kind="confluence" task is harmless: it flows INTAKE -> CLARIFY and
# suspends on the ordinary clarifier question (no confluence kind).
thread_id = coord.start_task(
task_text="document the architecture",
transport_name="slack",
task_kind="confluence",
)
pending = graph_mod.pending_question(coord.graph, thread_id=thread_id)
assert pending is not None
assert pending.get("kind") != graph_mod.CONFLUENCE_APPROVAL_KIND
rows = _open_rows(db_path)
assert len(rows) == 1
assert rows[0]["kind"] == "clarify"

View file

@ -0,0 +1,401 @@
"""End-to-end Confluence-stage tests through a compiled graph + SQLite checkpointer.
These drive the Confluence-writer sub-pipeline through the WHOLE graph wiring
composed by :func:`agent_team.graph.build_graph` (with ``confluence=True``), over
a REAL on-disk SQLite checkpointer so the durable suspend/resume can be proven
across a *fresh graph instance* that shares the same checkpoint DB. Only the
``billing.claude_invoke`` seam and the Confluence client are faked — no live
model, no Confluence, no network.
Coverage:
* **Flow A** — ``start_task(..., task_kind="confluence")`` routes INTAKE straight
to CONF_DRAFT (NOT the clarifier), suspends at CONF_GATE; an ``approve`` resume
drives CONF_WRITE -> END. The suspend persists across a brand-new graph object
sharing the checkpointer DB, and a DUPLICATE resume against the now-settled
thread is a no-op (the terminal state is unchanged).
* **Flow B** — the post-build documentation edge: when the build/verify subgraph
is wired, the verifier's approved (PR) terminus feeds CONF_DRAFT. Exercised by
graph STRUCTURE (the edge exists) rather than driving the heavy P3 stack.
* **Regression** — ``build_graph()`` with ``confluence=False`` wires NO conf_*
nodes (the node set is unchanged), proving the flag gates the whole stage.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from agent_team import billing
from agent_team.billing import BillingMode, ClaudeResult
from agent_team.graph import (
CLARIFY,
CONF_DRAFT_NODE,
CONF_GATE,
CONF_WRITE_NODE,
INTAKE,
PLAN,
build_graph,
build_sqlite_checkpointer,
get_pipeline_state,
pending_question,
resume_task,
start_task,
)
from agent_team.task_model import Phase, PipelineState, TaskStatus
# A well-formed draft the faked Claude clarifier/drafter returns; conf_draft_node
# parses this JSON into the confluence_draft dict.
_VALID_DRAFT = {
"title": "AWS Architecture Map — agent-team",
"body_storage": "<p>The coordinator daemon runs on the R720.</p>",
"page_id": "1540098",
}
# --------------------------------------------------------------------------- #
# Fixtures / fakes (mirror tests/test_confluence_writer.py + test_graph.py).
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def _restore_invoker():
"""Restore the billing module invoker after each test (mirrors siblings)."""
original = billing._invoker
yield
billing._invoker = original
@pytest.fixture(autouse=True)
def _clear_apply_env(monkeypatch):
"""Ensure the live-write apply flag never leaks from the host environment."""
monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_APPLY", raising=False)
def _bind_invoker(reply: str) -> None:
"""Bind a fake Claude invoker returning ``reply`` (no live model)."""
def _fake(prompt: str, *, mode: BillingMode, **kw: Any) -> ClaudeResult:
return ClaudeResult(text=reply, mode=mode, usage={"input_tokens": 1})
billing.set_invoker(_fake)
@pytest.fixture()
def sqlite_db(tmp_path: Path) -> Path:
"""A tmp SQLite checkpoint DB path (one per test)."""
return tmp_path / "state" / "checkpoints.sqlite"
# --------------------------------------------------------------------------- #
# Flow A — direct Confluence doc task: INTAKE -> CONF_DRAFT (skip clarify).
# --------------------------------------------------------------------------- #
def test_flow_a_routes_intake_to_conf_draft_and_suspends_at_gate(
sqlite_db: Path,
) -> None:
"""task_kind='confluence' skips the clarifier and suspends at the CONF_GATE.
A direct documentation task routes INTAKE straight to CONF_DRAFT (NOT
CLARIFY), drafts the page (faked Claude), and suspends on the Confluence
human gate's ``interrupt()`` — proven by the pending interrupt's
``confluence_approval`` kind, NOT a clarifier question-set.
"""
_bind_invoker(json.dumps(_VALID_DRAFT))
cm = build_sqlite_checkpointer(sqlite_db)
with cm as saver:
graph = build_graph(checkpointer=saver, confluence=True)
thread_id, state = start_task(
graph, transport="slack", task="document the stack", task_kind="confluence"
)
# Suspended at the Confluence gate, not the clarifier.
assert "__interrupt__" in state
payload = pending_question(graph, thread_id=thread_id)
assert payload is not None
# The gate payload carries the confluence-approval kind + the draft. It
# ALSO carries a ``question_set`` (the approve/request_changes/abandon
# decision surface the coordinator delivers — mirroring the plan-decision
# gate) whose context discriminator marks it as a confluence approval, so
# it is NOT mistaken for a clarifier question-set.
assert payload["kind"] == "confluence_approval"
assert payload["question_set"].context.get("kind") == "confluence_approval"
assert payload["confluence_draft"]["title"] == _VALID_DRAFT["title"]
# The draft was reached WITHOUT visiting the clarifier (no qa_history).
live = get_pipeline_state(graph, thread_id=thread_id)
assert live.get("qa_history", []) == []
assert live["task_kind"] == "confluence"
def test_flow_a_default_kind_still_routes_to_clarify(sqlite_db: Path) -> None:
"""The default (empty) task_kind still goes to the clarifier, not CONF_DRAFT.
Proves route_after_intake only diverts ``confluence`` tasks — every other
task keeps the original INTAKE -> CLARIFY behaviour even with the flag on.
"""
_bind_invoker(json.dumps(_VALID_DRAFT))
cm = build_sqlite_checkpointer(sqlite_db)
with cm as saver:
graph = build_graph(checkpointer=saver, confluence=True)
thread_id, state = start_task(graph, transport="slack", task="do a thing")
assert "__interrupt__" in state
payload = pending_question(graph, thread_id=thread_id)
assert payload is not None
# A clarifier suspend: a question-set, no confluence kind.
assert "question_set" in payload
assert payload.get("kind") != "confluence_approval"
def test_flow_a_durable_suspend_resume_across_fresh_graph(sqlite_db: Path) -> None:
"""The Flow-A suspend persists across a NEW graph object sharing the DB.
A first graph instance starts the task and suspends at the gate. A brand-new
graph instance — same on-disk SQLite checkpointer, fresh compile — resumes the
SAME thread with an ``approve`` decision, drives CONF_DRAFT -> CONF_GATE ->
CONF_WRITE -> END, and the task settles DONE (dry-run write, nothing applied).
This is the durability proof: only the shared checkpoint DB carries the state.
"""
_bind_invoker(json.dumps(_VALID_DRAFT))
# Instance #1: start + suspend at the gate, then drop the graph entirely.
cm1 = build_sqlite_checkpointer(sqlite_db)
with cm1 as saver1:
graph1 = build_graph(checkpointer=saver1, confluence=True)
thread_id, state1 = start_task(
graph1, transport="slack", task="doc it", task_kind="confluence"
)
assert "__interrupt__" in state1
assert pending_question(graph1, thread_id=thread_id) is not None
# Instance #2: a FRESH checkpointer connection + freshly compiled graph over
# the SAME DB file. It must see the suspended thread and resume it.
cm2 = build_sqlite_checkpointer(sqlite_db)
with cm2 as saver2:
graph2 = build_graph(checkpointer=saver2, confluence=True)
# The pending gate is visible purely from the shared checkpoint DB.
resumed_payload = pending_question(graph2, thread_id=thread_id)
assert resumed_payload is not None
assert resumed_payload["kind"] == "confluence_approval"
final = resume_task(
graph2, thread_id=thread_id, answer={"decision": "approve", "notes": ""}
)
# CONF_WRITE ran (dry-run by default) and the task is terminal DONE.
assert final["status"] == TaskStatus.DONE.value
assert final["confluence_result"]["applied"] is False
assert final["confluence_result"]["dry_run"] is True
assert pending_question(graph2, thread_id=thread_id) is None
def test_flow_a_duplicate_resume_is_noop(sqlite_db: Path) -> None:
"""A DUPLICATE approve resume against the settled thread changes nothing.
Once the gate is approved and the task settles DONE, replaying the same
``Command(resume=...)`` must NOT re-open the gate or mutate the terminal
state — there is no open interrupt to consume, so it is an idempotent no-op.
"""
_bind_invoker(json.dumps(_VALID_DRAFT))
cm = build_sqlite_checkpointer(sqlite_db)
with cm as saver:
graph = build_graph(checkpointer=saver, confluence=True)
thread_id, _ = start_task(
graph, transport="slack", task="doc it", task_kind="confluence"
)
first = resume_task(
graph, thread_id=thread_id, answer={"decision": "approve", "notes": ""}
)
assert first["status"] == TaskStatus.DONE.value
first_result = first["confluence_result"]
assert pending_question(graph, thread_id=thread_id) is None
# Replay the same resume: no open gate -> no state change.
second = resume_task(
graph, thread_id=thread_id, answer={"decision": "approve", "notes": ""}
)
assert second["status"] == TaskStatus.DONE.value
assert second["confluence_result"] == first_result
assert pending_question(graph, thread_id=thread_id) is None
def test_flow_a_request_changes_loops_back_to_draft(sqlite_db: Path) -> None:
"""A request_changes decision loops the gate back to CONF_DRAFT (redraft).
Proves the gate's revise route is wired CONF_GATE -> CONF_DRAFT: the task
re-drafts (faked Claude again) and re-suspends at the gate, with the human
feedback folded into state and the visit count bumped.
"""
_bind_invoker(json.dumps(_VALID_DRAFT))
cm = build_sqlite_checkpointer(sqlite_db)
with cm as saver:
graph = build_graph(checkpointer=saver, confluence=True)
thread_id, _ = start_task(
graph, transport="slack", task="doc it", task_kind="confluence"
)
state = resume_task(
graph,
thread_id=thread_id,
answer={"decision": "request_changes", "notes": "tighten the intro"},
)
# Looped back through CONF_DRAFT and re-suspended at the gate.
assert "__interrupt__" in state
payload = pending_question(graph, thread_id=thread_id)
assert payload is not None
assert payload["kind"] == "confluence_approval"
live = get_pipeline_state(graph, thread_id=thread_id)
assert live["confluence_feedback"] == "tighten the intro"
assert live["confluence_gate_visits"] >= 1
# --------------------------------------------------------------------------- #
# Flow B — post-build doc edge: approved-build terminus feeds CONF_DRAFT.
# --------------------------------------------------------------------------- #
def test_flow_b_approved_build_terminus_routes_into_conf_draft(
restore_review_invoker_b,
) -> None:
"""When build_verify is wired with confluence=True, the verifier's approved
(PR) terminus is repointed from END into CONF_DRAFT (Flow B).
Exercised via graph STRUCTURE rather than driving the heavy P3 stack: we
assert the VERIFY_NODE -> CONF_DRAFT edge exists (and that without
confluence the same wiring lands VERIFY at END instead), which is the
load-bearing Flow-B claim.
"""
from agent_team.graph import VERIFY_NODE
from agent_team.nodes import review_loop
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
review_loop.set_review_invoker(lambda prompt, **kw: "VERDICT: APPROVE\nok")
build_verify = (
make_build_node(diff_builder=lambda *, plan, config: "diff"),
make_verify_node(VerifierConfig(expected_run_id="r1")),
route_after_verify,
)
def _plan_to_review(state: PipelineState) -> PipelineState:
return PipelineState(
plan={"summary": "p"},
current_phase=Phase.REVIEW.value,
status=TaskStatus.ACTIVE.value,
)
graph = build_graph(
live_plan_node=_plan_to_review,
review_node=review_loop.bind_review_node(),
route_review=review_loop.route_after_review,
build_verify=build_verify,
confluence=True,
)
g = graph.get_graph()
nodes = set(g.nodes)
assert {VERIFY_NODE, CONF_DRAFT_NODE, CONF_GATE, CONF_WRITE_NODE} <= nodes
edges = {(e.source, e.target) for e in g.edges}
# Flow B: the verifier's approved terminus feeds the Confluence draft so a
# shipped change documents itself before the graph ends.
assert (VERIFY_NODE, CONF_DRAFT_NODE) in edges
def test_flow_b_without_confluence_verify_terminus_is_not_conf_draft(
restore_review_invoker_b,
) -> None:
"""With confluence=False, the same P3 wiring lands VERIFY at END, not the
Confluence draft — the Flow-B repoint is gated on the flag."""
from agent_team.graph import VERIFY_NODE
from agent_team.nodes import review_loop
from agent_team.nodes.build_verify_subgraph import (
make_build_node,
make_verify_node,
route_after_verify,
)
from agent_team.nodes.verifier import VerifierConfig
review_loop.set_review_invoker(lambda prompt, **kw: "VERDICT: APPROVE\nok")
build_verify = (
make_build_node(diff_builder=lambda *, plan, config: "diff"),
make_verify_node(VerifierConfig(expected_run_id="r1")),
route_after_verify,
)
def _plan_to_review(state: PipelineState) -> PipelineState:
return PipelineState(
plan={"summary": "p"},
current_phase=Phase.REVIEW.value,
status=TaskStatus.ACTIVE.value,
)
graph = build_graph(
live_plan_node=_plan_to_review,
review_node=review_loop.bind_review_node(),
route_review=review_loop.route_after_review,
build_verify=build_verify,
confluence=False,
)
g = graph.get_graph()
nodes = set(g.nodes)
# No Confluence nodes exist, so the approved terminus cannot feed CONF_DRAFT.
assert CONF_DRAFT_NODE not in nodes
edges = {(e.source, e.target) for e in g.edges}
assert (VERIFY_NODE, CONF_DRAFT_NODE) not in edges
@pytest.fixture()
def restore_review_invoker_b():
"""Save/restore the review-loop module-global invoker around Flow-B tests."""
from agent_team.nodes import review_loop
saved = review_loop._review_invoker
yield
review_loop._review_invoker = saved
# --------------------------------------------------------------------------- #
# Regression — confluence=False wires NO conf_* nodes (flag-gating).
# --------------------------------------------------------------------------- #
def test_confluence_disabled_wires_no_conf_nodes() -> None:
"""build_graph() with confluence off (the default) has NO conf_* vertices.
The node set is byte-for-byte the P1 set (intake/clarify/plan); none of the
Confluence stage exists, proving the entire sub-pipeline is flag-gated.
"""
graph = build_graph()
nodes = set(graph.get_graph().nodes)
# The plain P1 nodes are present.
assert {INTAKE, CLARIFY, PLAN} <= nodes
# NONE of the Confluence vertices are wired.
assert CONF_DRAFT_NODE not in nodes
assert CONF_GATE not in nodes
assert CONF_WRITE_NODE not in nodes
assert not ({CONF_DRAFT_NODE, CONF_GATE, CONF_WRITE_NODE} & nodes)
def test_confluence_enabled_adds_exactly_the_conf_nodes() -> None:
"""Turning the flag on adds precisely the three Confluence vertices to the
otherwise-unchanged P1 node set (no other topology drift)."""
base_nodes = set(build_graph().get_graph().nodes)
conf_nodes = set(build_graph(confluence=True).get_graph().nodes)
added = conf_nodes - base_nodes
assert added == {CONF_DRAFT_NODE, CONF_GATE, CONF_WRITE_NODE}

View file

@ -0,0 +1,182 @@
"""Unit tests for agent_team.confluence.mermaid (ADF-only Mermaid editor, D7).
Pure in-memory module: no seams, no network, no Claude. Fixtures are small
inline ADF dicts. The load-bearing assertions are the page-1540098 safety
invariants documented in the module:
* a NO-OP edit set yields an EMPTY revert-diff and mutates nothing;
* a real edit yields a non-empty revert-diff and an edited ``new_adf`` while
leaving the caller's inputs untouched (dry-run default);
* a zero-macro document signals ``skip_mermaid``.
"""
from __future__ import annotations
import copy
from agent_team.confluence.mermaid import (
MermaidEdit,
count_mermaid_macros,
plan_mermaid_edits,
)
def _mermaid_macro(local_id: str, source: str) -> dict:
"""A weweave mermaid-cloud extension node carrying ``source``."""
return {
"type": "extension",
"attrs": {
"extensionType": "com.weweave.mermaid",
"extensionKey": "mermaid-cloud",
"localId": local_id,
"parameters": {"macroParams": {"code": {"value": source}}},
},
}
def _doc(*macros: dict) -> dict:
"""An ADF doc wrapping ``macros`` with some non-mermaid filler content."""
return {
"version": 1,
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": "intro"}],
},
*macros,
],
}
# --- count_mermaid_macros -------------------------------------------------
def test_count_returns_n_for_n_macros() -> None:
doc = _doc(
_mermaid_macro("a", "graph TD; A-->B"),
_mermaid_macro("b", "graph TD; C-->D"),
_mermaid_macro("c", "graph TD; E-->F"),
)
assert count_mermaid_macros(doc) == 3
def test_count_returns_zero_when_no_macros() -> None:
doc = {
"version": 1,
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "no diagrams"}]},
{
"type": "extension",
"attrs": {
"extensionType": "com.atlassian.confluence.macro.core",
"extensionKey": "info",
"parameters": {"macroParams": {}},
},
},
],
}
assert count_mermaid_macros(doc) == 0
# --- NO-OP safety invariant (page 1540098) --------------------------------
def test_noop_empty_edits_yields_empty_revert_and_no_mutation() -> None:
doc = _doc(_mermaid_macro("a", "graph TD; A-->B"))
before = copy.deepcopy(doc)
result = plan_mermaid_edits(doc, [])
# EMPTY revert-diff is the safety invariant.
assert result.revert_diff == []
assert result.changed is False
assert result.skip_mermaid is False
assert result.macro_count == 1
# Writes nothing: the caller's input is byte-for-byte unchanged.
assert doc == before
def test_noop_identical_source_edit_yields_empty_revert() -> None:
doc = _doc(_mermaid_macro("a", "graph TD; A-->B"))
before = copy.deepcopy(doc)
# An edit whose new_source equals the current source is a no-op.
result = plan_mermaid_edits(
doc, [MermaidEdit(macro_key="a", new_source="graph TD; A-->B")]
)
assert result.revert_diff == []
assert result.changed is False
assert result.unmatched_keys == []
assert doc == before
# --- real edit -> non-empty revert-diff, edited new_adf, inputs untouched --
def test_real_edit_produces_revert_diff_and_edited_new_adf() -> None:
doc = _doc(_mermaid_macro("a", "graph TD; A-->B"))
doc_before = copy.deepcopy(doc)
edits = [MermaidEdit(macro_key="a", new_source="graph TD; X-->Y")]
edits_before = copy.deepcopy(edits)
result = plan_mermaid_edits(doc, edits, apply=False)
# Non-empty, reversible revert-diff capturing old + new.
assert result.changed is True
assert len(result.revert_diff) == 1
op = result.revert_diff[0]
assert op.macro_key == "a"
assert op.old_source == "graph TD; A-->B"
assert op.new_source == "graph TD; X-->Y"
assert result.unmatched_keys == []
# new_adf reflects the change; macro count is preserved.
new_macro = result.new_adf["content"][1]
assert (
new_macro["attrs"]["parameters"]["macroParams"]["code"]["value"]
== "graph TD; X-->Y"
)
assert count_mermaid_macros(result.new_adf) == 1
# DRY-RUN DEFAULT: apply=False leaves both inputs unmutated.
assert doc == doc_before
assert edits == edits_before
assert result.new_adf is not doc
def test_unmatched_key_is_surfaced_not_raised() -> None:
doc = _doc(_mermaid_macro("a", "graph TD; A-->B"))
before = copy.deepcopy(doc)
result = plan_mermaid_edits(doc, [MermaidEdit(macro_key="nope", new_source="x")])
assert result.unmatched_keys == ["nope"]
assert result.revert_diff == []
assert result.changed is False
assert doc == before
# --- zero-macro contract: skip_mermaid ------------------------------------
def test_zero_macro_doc_signals_skip_mermaid() -> None:
doc = {
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "hi"}]}],
}
before = copy.deepcopy(doc)
result = plan_mermaid_edits(doc, [MermaidEdit(macro_key="a", new_source="x")])
# Contract: zero macros -> skip_mermaid True so the caller falls back to a
# storage-format body update (D7) rather than an empty ADF edit.
assert result.skip_mermaid is True
assert result.macro_count == 0
assert result.revert_diff == []
# Unmatched edit keys are still surfaced.
assert result.unmatched_keys == ["a"]
assert doc == before

View file

@ -0,0 +1,678 @@
"""Unit tests for agent_team.nodes.confluence_writer (CONF_DRAFT/GATE/WRITE).
Covers the three LangGraph nodes plus the conditional-edge router:
* :func:`conf_draft_node` — the reasoning->JSON draft call. Pins the post-#60
contract: a small turn headroom (``max_turns == 4``, like the planner) and
TOOLS-OFF (no allowed_tools / budget) — the high-cap + read-only-tools approach
was reverted live in PR #61 (feedback_claude_sdk_single_shot).
* :func:`conf_gate_node` — the resumable human gate. Driven through a real
in-memory LangGraph (interrupt + ``Command(resume=...)``) to assert the
interrupt payload ``kind`` and the approve / request_changes / abandon /
unrecognized / ceiling routing.
* :func:`conf_write_node` — dry-run-by-default vs. apply-flag live write through
an INJECTED fake ConfluenceClient; the mermaid path routing.
* :func:`route_after_conf_gate` — approve / revise / terminal mapping.
"""
from __future__ import annotations
import json
from typing import Any
import pytest
try: # InMemorySaver is the modern name; fall back on older langgraph.
from langgraph.checkpoint.memory import InMemorySaver as _Saver
except ImportError: # pragma: no cover - environment-dependent
from langgraph.checkpoint.memory import MemorySaver as _Saver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
from agent_team import billing
from agent_team.billing import BillingMode, ClaudeResult
from agent_team.nodes import confluence_writer as cw
from agent_team.nodes.confluence_writer import (
CONFLUENCE_APPROVAL_KIND,
MAX_CONFLUENCE_GATE_VISITS,
ConfluenceWriteError,
conf_draft_node,
conf_gate_node,
conf_write_node,
route_after_conf_gate,
)
from agent_team.task_model import Phase, PipelineState, TaskStatus
# --------------------------------------------------------------------------- #
# Fixtures / helpers
# --------------------------------------------------------------------------- #
_VALID_DRAFT = {
"title": "AWS Architecture Map — agent-team",
"body_storage": "<p>The coordinator daemon runs on the R720.</p>",
"page_id": "1540098",
}
@pytest.fixture(autouse=True)
def _restore_invoker():
"""Restore the module invoker after each test (mirrors test_planner)."""
original = billing._invoker
yield
billing._invoker = original
@pytest.fixture(autouse=True)
def _clear_apply_env(monkeypatch):
"""Ensure the apply/allowlist env never leaks in from the host environment."""
monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_APPLY", raising=False)
monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", raising=False)
def _bind_invoker(reply: str) -> list[dict[str, Any]]:
"""Bind a fake Claude invoker returning ``reply``; capture its calls."""
calls: list[dict[str, Any]] = []
def _fake(prompt: str, *, mode: BillingMode, **kw: Any) -> ClaudeResult:
calls.append({"prompt": prompt, "mode": mode, "kw": kw})
return ClaudeResult(text=reply, mode=mode, usage={"input_tokens": 1})
billing.set_invoker(_fake)
return calls
def _state(**overrides: Any) -> PipelineState:
base: PipelineState = PipelineState(
thread_id="t-1",
status=TaskStatus.ACTIVE.value,
current_phase=cw.CONF_DRAFT_PHASE,
)
base.update(overrides) # type: ignore[typeddict-item]
return base
class _FakeUpdateOutcome:
"""Stand-in for the client's PlannedPageUpdate (carries ``.applied``)."""
def __init__(self, *, page_id, applied: bool) -> None:
self.page_id = page_id
self.applied = applied
class FakeConfluenceClient:
"""In-memory ConfluenceClient seam: records writes, no network.
Mirrors the REAL injected-client shape :func:`conf_write_node` expects:
``update_page(page_id, title, body_storage, version_number, *, apply=False)``
(the live path passes ``apply=True`` and a ``version_number`` it reads off
``get_page``), and an optional ``page_has_macros`` capability drives the
mermaid-routing branch. ``get_page`` returns the current version so the node
can derive ``version_number``.
"""
def __init__(self, *, has_macros: bool = False, current_version: int = 7) -> None:
self.update_calls: list[dict[str, Any]] = []
self.macro_checks: list[Any] = []
self.get_page_calls: list[Any] = []
self._has_macros = has_macros
self._current_version = current_version
def get_page(self, page_id) -> dict[str, Any]:
self.get_page_calls.append(page_id)
return {
"id": page_id,
"version": {"number": self._current_version},
"body": {"storage": {"value": "<p>old</p>"}},
}
def update_page(
self, *, page_id, title, body_storage, version_number, apply=False
) -> _FakeUpdateOutcome:
self.update_calls.append(
{
"page_id": page_id,
"title": title,
"body_storage": body_storage,
"version_number": version_number,
"apply": apply,
}
)
return _FakeUpdateOutcome(page_id=page_id or "new-123", applied=apply)
def page_has_macros(self, page_id) -> bool:
self.macro_checks.append(page_id)
return self._has_macros
class FakeAdfConfluenceClient(FakeConfluenceClient):
"""Fake client that ALSO exposes the ADF seam the mermaid live path needs.
``get_page_adf`` returns a parsed ADF doc; ``update_page_adf`` records the
persisted doc and returns an outcome carrying ``.applied`` so the mermaid
branch reports its applied state honestly.
"""
def __init__(self, *, adf: dict[str, Any] | None = None, **kw: Any) -> None:
super().__init__(**kw)
self.adf_get_calls: list[Any] = []
self.adf_put_calls: list[dict[str, Any]] = []
self._adf = adf or {"type": "doc", "version": 1, "content": []}
def get_page_adf(self, page_id) -> dict[str, Any]:
self.adf_get_calls.append(page_id)
return self._adf
def update_page_adf(self, page_id, new_adf) -> _FakeUpdateOutcome:
self.adf_put_calls.append({"page_id": page_id, "new_adf": new_adf})
return _FakeUpdateOutcome(page_id=page_id, applied=True)
# A minimal single-node graph wrapping conf_gate_node so interrupt()/resume work
# against a real checkpointer (mirrors the e2e harness, lighter).
def _gate_graph():
builder: StateGraph = StateGraph(PipelineState)
builder.add_node("gate", conf_gate_node)
builder.add_edge(START, "gate")
builder.add_edge("gate", END)
return builder.compile(checkpointer=_Saver())
def _run_to_interrupt(graph, state: PipelineState, thread_id: str = "t-1") -> dict:
"""Invoke the gate graph; return the single interrupt payload."""
cfg = {"configurable": {"thread_id": thread_id}}
result = graph.invoke(state, cfg)
interrupts = result["__interrupt__"]
assert len(interrupts) == 1
return interrupts[0].value
def _resume(graph, decision: Any, thread_id: str = "t-1") -> dict:
"""Resume the suspended gate graph with ``decision``; return final state."""
cfg = {"configurable": {"thread_id": thread_id}}
return graph.invoke(Command(resume=decision), cfg)
# --------------------------------------------------------------------------- #
# conf_draft_node — agentic-invoker contract (PR #61)
# --------------------------------------------------------------------------- #
def test_conf_draft_node_builds_draft_and_advances_to_gate() -> None:
_bind_invoker(json.dumps(_VALID_DRAFT))
out = conf_draft_node(_state(task="document the agent-team stack"))
assert out["current_phase"] == cw.CONF_GATE_PHASE
assert out["status"] == TaskStatus.ACTIVE.value
assert out["confluence_draft"]["title"] == _VALID_DRAFT["title"]
assert out["confluence_draft"]["page_id"] == "1540098"
def test_conf_draft_node_passes_reasoning_turn_headroom() -> None:
# The single-shot default (max_turns=1) crashes reasoning nodes; the draft
# asks for the same small headroom the planner uses (4) — NOT a high agentic
# cap (the merged #60 fix / PR #61 reverted the high-cap + tools approach).
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="x"))
assert calls[0]["kw"].get("max_turns", 1) == 4
def test_conf_draft_node_runs_tools_off() -> None:
# Load-bearing pin (memory feedback_claude_sdk_single_shot): the draft is a
# reasoning->JSON node and must run TOOLS-OFF. Giving it repo tools was proven
# live to exhaust the turn cap / return narration (#60). Any repo context goes
# INTO the prompt, never via Claude tools — so no allowed_tools, no budget.
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="x"))
assert not calls[0]["kw"].get("allowed_tools")
assert "budget_usd" not in calls[0]["kw"]
def test_conf_draft_node_forwards_config_to_billing_seam() -> None:
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="x"), config={"billing_mode": "api"})
assert calls[0]["mode"] is BillingMode.API
def test_conf_draft_node_sends_task_into_prompt() -> None:
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
conf_draft_node(_state(task="UNIQUE-DOC-MARKER"))
assert "UNIQUE-DOC-MARKER" in calls[0]["prompt"]
def test_conf_draft_node_garbled_reply_raises() -> None:
from agent_team.nodes.confluence_writer_llm import ConfluenceDraftError
_bind_invoker("not json at all")
with pytest.raises(ConfluenceDraftError):
conf_draft_node(_state(task="x"))
# --------------------------------------------------------------------------- #
# conf_gate_node — interrupt payload + resume routing (real graph)
# --------------------------------------------------------------------------- #
def test_conf_gate_interrupts_with_confluence_kind() -> None:
graph = _gate_graph()
payload = _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
assert payload["kind"] == CONFLUENCE_APPROVAL_KIND
assert payload["kind"] == "confluence_approval"
assert payload["thread_id"] == "t-1"
assert payload["confluence_draft"]["title"] == _VALID_DRAFT["title"]
assert "preview" in payload and _VALID_DRAFT["title"] in payload["preview"]
# The gate turn lives in the high disjoint namespace (>= the plan-gate base).
assert payload["turn"] >= cw._CONF_GATE_TURN_BASE
# The coordinator notify path requires a question_set; it must carry the same
# ids/turn and a single decision question whose prompt is the draft preview.
qs = payload["question_set"]
assert qs.thread_id == "t-1"
assert qs.question_id == payload["question_id"]
assert qs.turn == payload["turn"]
assert len(qs.questions) == 1
assert _VALID_DRAFT["title"] in qs.questions[0]
assert "approve" in qs.questions[0] and "abandon" in qs.questions[0]
assert qs.context["kind"] == CONFLUENCE_APPROVAL_KIND
def test_conf_gate_approve_routes_to_write() -> None:
graph = _gate_graph()
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
out = _resume(graph, {"decision": "approve", "notes": ""})
assert out["current_phase"] == cw.CONF_WRITE_PHASE
assert out["status"] == TaskStatus.ACTIVE.value
assert out["confluence_gate_visits"] == 1
def test_conf_gate_request_changes_loops_to_draft_with_feedback() -> None:
graph = _gate_graph()
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
out = _resume(graph, {"decision": "request_changes", "notes": "fix the title"})
assert out["current_phase"] == cw.CONF_DRAFT_PHASE
assert out["status"] == TaskStatus.ACTIVE.value
assert out["confluence_feedback"] == "fix the title"
assert out["confluence_gate_visits"] == 1
def test_conf_gate_abandon_fails_with_reason() -> None:
graph = _gate_graph()
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
out = _resume(graph, {"decision": "abandon", "notes": "drop it"})
assert out["status"] == TaskStatus.FAILED.value
assert out["current_phase"] == Phase.PARKED.value
assert "abandoned" in (out.get("failure_reason") or "")
def test_conf_gate_unrecognized_decision_maps_to_request_changes() -> None:
# Free-text prose (no recognized verb) must fail SAFE to request_changes —
# never an accidental approve or silent abandon (mirrors the plan gate).
graph = _gate_graph()
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
out = _resume(graph, "please tighten the architecture section")
assert out["current_phase"] == cw.CONF_DRAFT_PHASE
assert out["status"] == TaskStatus.ACTIVE.value
assert "tighten the architecture section" in out["confluence_feedback"]
def test_conf_gate_ceiling_parks_without_interrupt() -> None:
# At the visit ceiling the gate does NOT interrupt: it returns terminal PARKED
# so the human loop always terminates.
out = conf_gate_node(
_state(
confluence_draft=dict(_VALID_DRAFT),
confluence_gate_visits=MAX_CONFLUENCE_GATE_VISITS,
)
)
assert out["status"] == TaskStatus.PARKED.value
assert out["current_phase"] == Phase.PARKED.value
assert "ceiling" in (out.get("failure_reason") or "")
def test_conf_gate_visits_bump_monotonically_across_loops() -> None:
graph = _gate_graph()
_run_to_interrupt(
graph,
_state(confluence_draft=dict(_VALID_DRAFT), confluence_gate_visits=1),
thread_id="t-loop",
)
out = _resume(graph, {"decision": "approve"}, thread_id="t-loop")
assert out["confluence_gate_visits"] == 2
# --------------------------------------------------------------------------- #
# route_after_conf_gate
# --------------------------------------------------------------------------- #
def test_route_after_conf_gate_approve() -> None:
state = _state(status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_WRITE_PHASE)
assert route_after_conf_gate(state) == "approve"
def test_route_after_conf_gate_revise() -> None:
state = _state(status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_DRAFT_PHASE)
assert route_after_conf_gate(state) == "revise"
def test_route_after_conf_gate_terminal_on_failed() -> None:
state = _state(status=TaskStatus.FAILED.value, current_phase=Phase.PARKED.value)
assert route_after_conf_gate(state) == "terminal"
def test_route_after_conf_gate_terminal_on_parked() -> None:
state = _state(status=TaskStatus.PARKED.value, current_phase=Phase.PARKED.value)
assert route_after_conf_gate(state) == "terminal"
# --------------------------------------------------------------------------- #
# conf_write_node — dry-run default vs. apply flag, injected client
# --------------------------------------------------------------------------- #
def test_conf_write_dry_run_writes_nothing_by_default() -> None:
client = FakeConfluenceClient()
out = conf_write_node(_state(confluence_draft=dict(_VALID_DRAFT)), client=client)
assert client.update_calls == [] # NOTHING was written
assert out["status"] == TaskStatus.DONE.value
assert out["current_phase"] == cw.CONF_DONE_PHASE
result = out["confluence_result"]
assert result["applied"] is False
assert result["dry_run"] is True
assert result["action"] == "update"
assert "planned_change" in result and "revert_diff" in result
def test_conf_write_dry_run_create_action_when_no_page_id() -> None:
draft = {k: v for k, v in _VALID_DRAFT.items() if k != "page_id"}
out = conf_write_node(_state(confluence_draft=draft), client=FakeConfluenceClient())
assert out["confluence_result"]["action"] == "create"
def test_conf_write_apply_via_config_calls_client() -> None:
client = FakeConfluenceClient()
out = conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=client,
)
assert len(client.update_calls) == 1
call = client.update_calls[0]
assert call["page_id"] == "1540098"
# The live path MUST pass apply=True and a version_number (read off get_page);
# without both the client's update_page raises / never writes (the defect).
assert call["apply"] is True
assert call["version_number"] == 7 # FakeConfluenceClient.get_page current
assert client.get_page_calls == ["1540098"]
result = out["confluence_result"]
# ``applied`` is DERIVED from the returned outcome (.applied), not hardcoded.
assert result["applied"] is True
assert result["dry_run"] is False
assert out["status"] == TaskStatus.DONE.value
def test_conf_write_apply_via_env_calls_client(monkeypatch) -> None:
monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_APPLY", "1")
client = FakeConfluenceClient()
conf_write_node(_state(confluence_draft=dict(_VALID_DRAFT)), client=client)
assert len(client.update_calls) == 1
def test_conf_write_missing_draft_raises() -> None:
with pytest.raises(ConfluenceWriteError):
conf_write_node(_state(confluence_draft={"title": "only title"}))
def test_conf_write_mermaid_path_only_when_edits_and_macros(monkeypatch) -> None:
# The mermaid route fires ONLY when the draft has edits AND the page carries
# macros. plan_mermaid_edits is PURE ADF: (adf, edits, *, apply=False). We
# assert routing by patching it and checking it received the parsed ADF plus
# MermaidEdit objects converted from the draft dicts.
import agent_team.confluence.mermaid as mermaid_mod
routed: dict[str, Any] = {}
def _fake_plan(adf, edits=None, *, apply=False):
routed["called"] = True
routed["adf"] = adf
routed["edits"] = edits
routed["apply"] = apply
return mermaid_mod.MermaidEditResult(
macro_count=1, new_adf=adf, revert_diff=[], skip_mermaid=False
)
monkeypatch.setattr(mermaid_mod, "plan_mermaid_edits", _fake_plan)
draft = dict(_VALID_DRAFT)
draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}]
adf_doc = {"type": "doc", "version": 1, "content": ["macro"]}
client = FakeAdfConfluenceClient(has_macros=True, adf=adf_doc)
out = conf_write_node(
_state(confluence_draft=draft),
config={"confluence_apply": True},
client=client,
)
assert routed.get("called") is True
assert routed["apply"] is True
assert routed["adf"] is adf_doc # pure ADF fetched via get_page_adf
# draft edit dicts were converted to MermaidEdit(macro_key, new_source).
assert routed["edits"][0].macro_key == "m1"
assert routed["edits"][0].new_source == "graph TD; A-->B"
assert client.adf_get_calls == ["1540098"]
assert len(client.adf_put_calls) == 1 # persisted via update_page_adf
assert client.update_calls == [] # NOT routed through storage update_page
assert out["confluence_result"]["applied"] is True
def test_conf_write_mermaid_apply_without_adf_seam_raises() -> None:
# The base fake client has NO ADF persistence (get_page_adf/update_page_adf):
# a Mermaid live apply MUST fail loudly rather than falsely record success
# (ADF-only edits cannot round-trip through storage without dropping macros).
draft = dict(_VALID_DRAFT)
draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}]
client = FakeConfluenceClient(has_macros=True)
with pytest.raises(ConfluenceWriteError, match="ADF persistence"):
conf_write_node(
_state(confluence_draft=draft),
config={"confluence_apply": True},
client=client,
)
def test_conf_write_applied_reflects_outcome_not_hardcoded() -> None:
# The result's ``applied`` is DERIVED from the returned outcome, so a client
# that declines to apply is reported as applied=False (never a FALSE success).
class _DeclineClient(FakeConfluenceClient):
def update_page(
self, *, page_id, title, body_storage, version_number, apply=False
):
self.update_calls.append({"page_id": page_id, "apply": apply})
return _FakeUpdateOutcome(page_id=page_id, applied=False)
out = conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=_DeclineClient(),
)
assert out["confluence_result"]["applied"] is False
assert out["confluence_result"]["dry_run"] is False
def test_conf_write_no_mermaid_route_when_page_lacks_macros() -> None:
# Edits present but the page has NO macros: fall back to a plain body update.
draft = dict(_VALID_DRAFT)
draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}]
client = FakeConfluenceClient(has_macros=False)
conf_write_node(
_state(confluence_draft=draft),
config={"confluence_apply": True},
client=client,
)
assert len(client.update_calls) == 1 # fell back to update_page
assert client.macro_checks == ["1540098"] # the probe ran
def test_conf_write_no_mermaid_route_without_edits() -> None:
# No mermaid edits at all: plain body update even on a macro page.
client = FakeConfluenceClient(has_macros=True)
conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=client,
)
assert len(client.update_calls) == 1
def test_conf_write_client_failure_normalized_to_write_error() -> None:
class _BoomClient(FakeConfluenceClient):
def update_page(self, **kw):
raise RuntimeError("network down")
with pytest.raises(ConfluenceWriteError, match="confluence write failed"):
conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=_BoomClient(),
)
# --------------------------------------------------------------------------- #
# Macro-preservation guard — a storage write must NEVER drop diagram macros
# (the page-1540098 data-loss class; security-review BLOCKER fix)
# --------------------------------------------------------------------------- #
class _MacroBodyClient(FakeConfluenceClient):
"""Fake whose current page body carries Confluence macros (storage XHTML)."""
def __init__(self, *, current_macros: int = 1, **kw: Any) -> None:
super().__init__(**kw)
self._body = "".join(
f'<ac:structured-macro ac:name="mermaid-cloud" id="m{i}">'
f'<ac:parameter ac:name="code">graph TD; A-->B{i}</ac:parameter>'
"</ac:structured-macro>"
for i in range(current_macros)
)
def get_page(self, page_id) -> dict[str, Any]:
self.get_page_calls.append(page_id)
return {
"id": page_id,
"version": {"number": self._current_version},
"body": {"storage": {"value": self._body}},
}
def test_conf_write_storage_refuses_to_drop_macros() -> None:
# BLOCKER fix: the live page has a Mermaid macro; the model-authored body
# (plain <p>) has none. A wholesale storage PUT would erase the diagram, so
# the write must REFUSE rather than overwrite (no update_page issued).
client = _MacroBodyClient(current_macros=1)
with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"):
conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=client,
)
assert client.update_calls == [] # nothing was written
def test_conf_write_storage_refuses_macro_swap_at_equal_count() -> None:
# Identity-aware guard: the proposed body DROPS the Mermaid macro but ADDS an
# unrelated macro, keeping the raw count equal (1 -> 1). A count-only guard
# would wave this through; the signature guard must still refuse.
client = _MacroBodyClient(current_macros=1) # current: one mermaid-cloud macro
draft = dict(_VALID_DRAFT)
draft["body_storage"] = (
'<ac:structured-macro ac:name="info"><ac:rich-text-body>'
"<p>note</p></ac:rich-text-body></ac:structured-macro>"
)
with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"):
conf_write_node(
_state(confluence_draft=draft),
config={"confluence_apply": True},
client=client,
)
assert client.update_calls == []
def test_conf_write_storage_allows_when_macros_preserved() -> None:
# When the new body reproduces the macro count, the write proceeds.
client = _MacroBodyClient(current_macros=1)
draft = dict(_VALID_DRAFT)
draft["body_storage"] = (
'<ac:structured-macro ac:name="mermaid-cloud" id="m0">'
'<ac:parameter ac:name="code">graph TD; A-->B0_edited</ac:parameter>'
"</ac:structured-macro>"
)
conf_write_node(
_state(confluence_draft=draft),
config={"confluence_apply": True},
client=client,
)
assert len(client.update_calls) == 1
def test_page_has_macros_fails_closed_without_capability() -> None:
# A client with NO page_has_macros method must be probed via the storage body
# and detect macros there (fail-closed), routing a macro page with edits into
# the ADF path (which raises here, since this client has no ADF seam) rather
# than the destructive storage overwrite.
class _NoCapMacroClient(_MacroBodyClient):
page_has_macros = None # type: ignore[assignment]
draft = dict(_VALID_DRAFT)
draft["mermaid_edits"] = [{"macro_id": "m0", "mermaid": "graph TD; A-->B"}]
with pytest.raises(ConfluenceWriteError, match="ADF persistence"):
conf_write_node(
_state(confluence_draft=draft),
config={"confluence_apply": True},
client=_NoCapMacroClient(),
)
# --------------------------------------------------------------------------- #
# Page allowlist (AUTHZ-CONF-01 defense-in-depth) + fail-honest applied default
# --------------------------------------------------------------------------- #
def test_conf_write_refuses_page_outside_allowlist(monkeypatch) -> None:
monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", "999,1234")
client = FakeConfluenceClient()
with pytest.raises(ConfluenceWriteError, match="not in the .*allowlist"):
conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)), # page_id 1540098
config={"confluence_apply": True},
client=client,
)
assert client.update_calls == []
def test_conf_write_allows_page_on_allowlist(monkeypatch) -> None:
monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", "1540098, 999")
client = FakeConfluenceClient()
conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=client,
)
assert len(client.update_calls) == 1
def test_conf_write_applied_defaults_false_when_attr_missing() -> None:
# Fail-honest: an outcome object lacking ``.applied`` must NOT be reported as
# a successful write (was: defaulted True).
class _AttrlessOutcome:
page_id = "1540098"
class _AttrlessClient(FakeConfluenceClient):
def update_page(self, **kw):
self.update_calls.append(kw)
return _AttrlessOutcome()
out = conf_write_node(
_state(confluence_draft=dict(_VALID_DRAFT)),
config={"confluence_apply": True},
client=_AttrlessClient(),
)
assert out["confluence_result"]["applied"] is False

View file

@ -0,0 +1,79 @@
"""Guard: every ``pending_questions`` ``kind`` has a coordinator delivery path.
A human-gate interrupt ``kind`` is NOT delivered for free. The responder/notify
path is not kind-agnostic: a ``kind`` added to the ``pending_questions`` CHECK
without a coordinator branch makes the task suspend FOREVER — no Slack post, no
durable ledger row, the human can never answer. That was a real CRITICAL gap
when ``confluence_approval`` was first wired (the node + graph were complete but
the coordinator only branched on ``plan_decision``). See the memory
``reference_agent_team_extension_gotchas``.
This test pins the invariant from the schema (the single source of truth for
allowed kinds): every non-default kind must have a ``*_KIND`` constant in
``agent_team.graph`` AND an explicit reference to it in ``coordinator.py`` (its
delivery branch). A new kind therefore cannot ship without its delivery wiring.
"""
from __future__ import annotations
import re
from pathlib import Path
from agent_team import coordinator as coordinator_mod
from agent_team import graph as graph_mod
from agent_team.db import schema as schema_mod
# ``clarify`` is delivered by the DEFAULT notify path (the fallthrough in
# ``_post_resume_followups`` / ``start_task``), so it needs no dedicated branch.
# Every OTHER kind must be keyed on its own ``graph.*_KIND`` constant.
_DEFAULT_DELIVERED_KINDS = {"clarify"}
def _declared_kinds() -> set[str]:
"""The kind values the ``pending_questions`` CHECK allows (source of truth)."""
match = re.search(r"kind IN \(([^)]*)\)", schema_mod.PENDING_QUESTIONS_DDL)
assert match, (
"could not find the pending_questions kind CHECK in PENDING_QUESTIONS_DDL"
)
return set(re.findall(r"'([^']+)'", match.group(1)))
def _kind_constant_names() -> dict[str, str]:
"""Map each kind VALUE to the ``graph.*_KIND`` constant NAME that holds it."""
out: dict[str, str] = {}
for name in dir(graph_mod):
if name.endswith("_KIND"):
value = getattr(graph_mod, name)
if isinstance(value, str):
out[value] = name
return out
def test_declared_kinds_parse_sane() -> None:
# Guards the regex itself: if PENDING_QUESTIONS_DDL is reshaped and the CHECK
# stops parsing, this fails loudly instead of silently passing an empty set.
kinds = _declared_kinds()
assert {"clarify", "plan_decision"} <= kinds
def test_every_pending_question_kind_has_coordinator_delivery() -> None:
kinds = _declared_kinds()
constants = _kind_constant_names()
coordinator_src = Path(coordinator_mod.__file__).read_text()
for kind in sorted(kinds - _DEFAULT_DELIVERED_KINDS):
const = constants.get(kind)
assert const, (
f"pending_questions kind {kind!r} has no `*_KIND` constant in "
"agent_team.graph — define one and give it a coordinator delivery "
"branch (see reference_agent_team_extension_gotchas)."
)
assert f"graph_mod.{const}" in coordinator_src, (
f"pending_questions kind {kind!r} ({const}) has no delivery branch in "
"coordinator.py — a kind added to the CHECK without a coordinator "
"branch suspends the task forever (no Slack post, no ledger row). Add "
"a branch keyed on graph_mod." + (const or "<KIND>") + " in "
"_post_resume_followups, start_task, AND the redelivery sweep, and "
"make the interrupt payload carry a question_set "
"(see reference_agent_team_extension_gotchas)."
)

View file

@ -8,7 +8,7 @@ from pathlib import Path
import pytest
from agent_team.db.schema import connect, init_db
from agent_team.db.schema import SCHEMA_VERSION, connect, init_db, migrate
from agent_team.ledger import (
QUESTION_STATES,
PendingQuestion,
@ -415,3 +415,350 @@ def test_concurrent_answer_single_winner_via_ledger(tmp_path: Path) -> None:
t.join()
assert sorted(results) == [False, True]
# --------------------------------------------------------------------------
# v5 schema migration: WIDEN pending_questions.kind CHECK to add
# 'confluence_approval' via the 12-step table rebuild (db/schema.py).
# --------------------------------------------------------------------------
# The OLD (pre-v5) pending_questions DDL, whose kind CHECK accepts only
# ('clarify', 'plan_decision'). Used to build a v4-stamped DB so the v4 -> v5
# upgrade can be exercised against a realistic starting point.
_PENDING_QUESTIONS_DDL_V4 = """
CREATE TABLE pending_questions (
question_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
turn INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('open', 'answered', 'expired', 'superseded')),
transport TEXT NOT NULL,
channel_ref TEXT,
posted_at TEXT,
deadline_at TEXT,
answer_json TEXT,
answered_at TEXT,
answered_via TEXT,
kind TEXT NOT NULL DEFAULT 'clarify'
CHECK (kind IN ('clarify', 'plan_decision'))
)
""".strip()
_PENDING_QUESTIONS_INDEXES_V4 = [
"CREATE INDEX idx_pending_questions_thread ON pending_questions (thread_id, turn)",
"CREATE INDEX idx_pending_questions_status ON pending_questions (status)",
"CREATE UNIQUE INDEX uq_pending_questions_open_channel_ref "
"ON pending_questions (channel_ref) "
"WHERE channel_ref IS NOT NULL AND status = 'open'",
]
def _build_v4_db(db: Path) -> None:
"""Create a DB stamped at schema_version=4 with the OLD kind CHECK.
Mirrors the production-shaped v4 state: pending_questions carrying only
('clarify', 'plan_decision') in its kind CHECK, all three pending_questions
indexes present, and the schema_meta version row pinned at 4.
"""
conn = connect(db)
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_meta ("
"id INTEGER PRIMARY KEY CHECK (id = 1), "
"schema_version INTEGER NOT NULL)"
)
conn.execute(_PENDING_QUESTIONS_DDL_V4)
for stmt in _PENDING_QUESTIONS_INDEXES_V4:
conn.execute(stmt)
conn.execute("INSERT INTO schema_meta (id, schema_version) VALUES (1, 4)")
finally:
conn.close()
def _kind_check_accepts(conn: sqlite3.Connection, kind: str) -> bool:
"""True if inserting a pending_questions row with ``kind`` is permitted.
Probes the live CHECK constraint by attempting an insert and rolling it
back; an IntegrityError means the CHECK rejected the value.
"""
try:
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES (?, 't', 0, 'open', 'slack', ?)",
(f"probe-{kind}", kind),
)
except sqlite3.IntegrityError:
return False
conn.execute(
"DELETE FROM pending_questions WHERE question_id = ?", (f"probe-{kind}",)
)
return True
def _schema_version(conn: sqlite3.Connection) -> int:
row = conn.execute("SELECT schema_version FROM schema_meta WHERE id = 1").fetchone()
return int(row["schema_version"])
def _index_names(conn: sqlite3.Connection) -> set[str]:
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type = 'index' AND tbl_name = 'pending_questions'"
).fetchall()
return {row["name"] for row in rows}
def test_fresh_init_db_stamps_v5_and_accepts_confluence_approval(
tmp_path: Path,
) -> None:
"""A fresh init_db lands at SCHEMA_VERSION and accepts 'confluence_approval'."""
db = tmp_path / "fresh.sqlite"
init_db(db)
conn = connect(db)
try:
assert SCHEMA_VERSION == 5
assert _schema_version(conn) == 5
assert _kind_check_accepts(conn, "confluence_approval") is True
assert _kind_check_accepts(conn, "clarify") is True
assert _kind_check_accepts(conn, "plan_decision") is True
assert _kind_check_accepts(conn, "nope") is False
finally:
conn.close()
def test_v4_to_v5_preserves_rows_and_widens_kind_check(tmp_path: Path) -> None:
"""v4 -> v5: existing rows survive verbatim; widened CHECK takes effect."""
db = tmp_path / "upgrade.sqlite"
_build_v4_db(db)
# Seed pre-existing v4 rows: a clarify and a plan_decision, with full
# column coverage so the rebuild's `INSERT ... SELECT *` copy is exercised.
seed = connect(db)
try:
assert _schema_version(seed) == 4
seed.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref, "
"posted_at, deadline_at, kind) "
"VALUES ('q-clar', 't1', 0, 'open', 'slack', 'ts-1', "
"'2026-06-17T01:00:00+00:00', '2026-06-17T02:00:00+00:00', 'clarify')"
)
seed.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, answer_json, "
"answered_at, answered_via, kind) "
"VALUES ('q-plan', 't2', 1, 'answered', 'github', '{\"d\":\"go\"}', "
"'2026-06-17T03:00:00+00:00', 'github', 'plan_decision')"
)
# Pre-rebuild the OLD CHECK must reject confluence_approval.
assert _kind_check_accepts(seed, "confluence_approval") is False
finally:
seed.close()
# Run the migration.
mig = connect(db)
try:
migrate(mig)
finally:
mig.close()
conn = connect(db)
try:
assert _schema_version(conn) == 5
# Existing rows preserved intact (every column round-trips).
clar = conn.execute(
"SELECT * FROM pending_questions WHERE question_id = 'q-clar'"
).fetchone()
assert clar is not None
assert clar["thread_id"] == "t1"
assert clar["turn"] == 0
assert clar["status"] == "open"
assert clar["transport"] == "slack"
assert clar["channel_ref"] == "ts-1"
assert clar["posted_at"] == "2026-06-17T01:00:00+00:00"
assert clar["deadline_at"] == "2026-06-17T02:00:00+00:00"
assert clar["kind"] == "clarify"
plan = conn.execute(
"SELECT * FROM pending_questions WHERE question_id = 'q-plan'"
).fetchone()
assert plan is not None
assert plan["status"] == "answered"
assert plan["transport"] == "github"
assert plan["answer_json"] == '{"d":"go"}'
assert plan["answered_at"] == "2026-06-17T03:00:00+00:00"
assert plan["answered_via"] == "github"
assert plan["kind"] == "plan_decision"
# Widened CHECK: confluence_approval now accepted, garbage still rejected.
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES ('q-conf', 't3', 0, 'open', 'slack', 'confluence_approval')"
)
got = conn.execute(
"SELECT kind FROM pending_questions WHERE question_id = 'q-conf'"
).fetchone()
assert got["kind"] == "confluence_approval"
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES ('q-bad', 't4', 0, 'open', 'slack', 'nope')"
)
finally:
conn.close()
def test_v4_to_v5_rebuild_preserves_partial_unique_channel_ref_index(
tmp_path: Path,
) -> None:
"""The partial unique index survives the rebuild and still enforces."""
db = tmp_path / "uq.sqlite"
_build_v4_db(db)
mig = connect(db)
try:
migrate(mig)
finally:
mig.close()
conn = connect(db)
try:
# Index exists after the rebuild.
assert "uq_pending_questions_open_channel_ref" in _index_names(conn)
# It still enforces: two OPEN rows cannot share a non-null channel_ref.
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref) "
"VALUES ('a', 't', 0, 'open', 'slack', 'dup-ts')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref) "
"VALUES ('b', 't', 1, 'open', 'slack', 'dup-ts')"
)
finally:
conn.close()
def test_v5_rebuild_is_atomic_failure_rolls_back_to_intact_table(
tmp_path: Path,
) -> None:
"""A failure mid-rebuild must roll back: the ORIGINAL table and rows survive,
version stays 4, and a subsequent migrate() recovers cleanly (no crash-loop).
Without the explicit BEGIN IMMEDIATE wrapping the swap, connect()'s autocommit
mode commits the DROP TABLE pending_questions on its own; a failure after that
point destroys the live ledger and leaves an orphan pending_questions_new that
crash-loops the next migrate(). We inject a failure at the index-creation step
(which runs AFTER the DROP/RENAME inside the txn) and assert the rebuild was
atomic.
"""
db = tmp_path / "atomic.sqlite"
_build_v4_db(db)
seed = connect(db)
try:
seed.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, channel_ref, kind) "
"VALUES ('survivor', 't1', 0, 'open', 'slack', 'ts-keep', 'clarify')"
)
finally:
seed.close()
# Patch the index splitter the v5 block calls AFTER the DROP/RENAME so the
# rebuild fails mid-flight, exercising the rollback path.
import agent_team.db.schema as schema_mod
original_split = schema_mod._split_statements
def _boom(ddl: str) -> list[str]:
if "idx_pending_questions" in ddl:
raise RuntimeError("injected mid-rebuild crash")
return original_split(ddl)
mig = connect(db)
try:
schema_mod._split_statements = _boom
try:
with pytest.raises(RuntimeError, match="injected mid-rebuild crash"):
migrate(mig)
finally:
schema_mod._split_statements = original_split
finally:
mig.close()
# Rollback held: original table intact, the row survives, version still 4,
# and NO orphan pending_questions_new remains to crash the retry.
conn = connect(db)
try:
assert _schema_version(conn) == 4
tables = {
row["name"]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
assert "pending_questions" in tables
assert "pending_questions_new" not in tables
row = conn.execute(
"SELECT thread_id, channel_ref, kind FROM pending_questions "
"WHERE question_id = 'survivor'"
).fetchone()
assert row is not None
assert row["thread_id"] == "t1"
assert row["channel_ref"] == "ts-keep"
assert row["kind"] == "clarify"
# Pre-rebuild CHECK still in force: the widening did not partially apply.
assert _kind_check_accepts(conn, "confluence_approval") is False
finally:
conn.close()
# Retry recovers cleanly to v5 with the row still present.
retry = connect(db)
try:
migrate(retry)
assert _schema_version(retry) == 5
assert _kind_check_accepts(retry, "confluence_approval") is True
row = retry.execute(
"SELECT kind FROM pending_questions WHERE question_id = 'survivor'"
).fetchone()
assert row["kind"] == "clarify"
finally:
retry.close()
def test_v5_migrate_is_idempotent(tmp_path: Path) -> None:
"""Re-running migrate on a v5 DB is a no-op that keeps rows and version."""
db = tmp_path / "idem.sqlite"
_build_v4_db(db)
first = connect(db)
try:
migrate(first)
first.execute(
"INSERT INTO pending_questions "
"(question_id, thread_id, turn, status, transport, kind) "
"VALUES ('keep', 't', 0, 'open', 'slack', 'confluence_approval')"
)
finally:
first.close()
again = connect(db)
try:
migrate(again)
assert _schema_version(again) == 5
row = again.execute(
"SELECT kind FROM pending_questions WHERE question_id = 'keep'"
).fetchone()
assert row["kind"] == "confluence_approval"
finally:
again.close()

View file

@ -31,6 +31,9 @@ def test_phase_members() -> None:
"REVIEW",
"BUILD",
"VERIFY",
"CONF_DRAFT",
"CONF_GATE",
"CONF_WRITE",
"PARKED",
"DONE",
}

View file

@ -33,14 +33,41 @@ def test_topology_has_expected_nodes_and_trees() -> None:
assert by_id["build_node"]["gated"] is True
def test_confluence_lane_in_topology() -> None:
"""The Confluence-writer lane renders as its own 'docs' tree (flag-gated)."""
topo = build_topology()
ids = {n["id"] for n in topo["nodes"]}
assert {"conf_draft", "conf_gate", "conf_write"} <= ids
tree_ids = {t["id"] for t in topo["trees"]}
assert "docs" in tree_ids
by_id = {n["id"]: n for n in topo["nodes"]}
for nid in ("conf_draft", "conf_gate", "conf_write"):
assert by_id[nid]["tree"] == "docs"
assert by_id[nid]["gated"] is True # opt-in / flag-gated -> dimmed
assert by_id["conf_gate"]["kind"] == "gate" # human approval interrupt
assert by_id["conf_draft"]["kind"] == "phase"
assert by_id["conf_write"]["kind"] == "phase"
def test_confluence_phase_to_node_mapping() -> None:
assert node_for_phase("conf_draft") == "conf_draft"
assert node_for_phase("conf_gate") == "conf_gate"
assert node_for_phase("conf_write") == "conf_write"
def test_edges_classified_spine_branch_loopback() -> None:
topo = build_topology()
kinds = {(e["from"], e["to"]): e["kind"] for e in topo["edges"]}
assert kinds[("intake", "clarify")] == "spine"
# intake is a conditional branch point once the docs lane exists: it routes a
# normal task to clarify and a task_kind=confluence task to conf_draft.
assert kinds[("intake", "clarify")] == "branch"
assert kinds[("intake", "conf_draft")] == "branch"
assert kinds[("clarify", "plan")] == "spine"
# review -> plan is the revise loop; verify -> build_node is the build loop.
# review -> plan is the revise loop; verify -> build_node is the build loop;
# conf_gate -> conf_draft is the request-changes redraft loop.
assert kinds[("review", "plan")] == "loopback"
assert kinds[("verify_node", "build_node")] == "loopback"
assert kinds[("conf_gate", "conf_draft")] == "loopback"
# No edge references the framework terminals.
nodes = {n["id"] for n in topo["nodes"]}
for e in topo["edges"]:

View file

@ -0,0 +1,7 @@
dist
node_modules
coverage
package-lock.json
*.log
playwright-report
test-results

View file

@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80
}

View file

@ -0,0 +1,74 @@
import { test, expect } from "@playwright/test";
// Minimal browser smoke: the dashboard shell mounts and renders live `/api/state`
// into the Board. The backend is fully mocked via route fulfilment so the test
// needs no coordinator, DB, or network — it asserts the read path end to end
// (fetch → state → Board card), which a unit/jsdom test does not exercise.
const TOPOLOGY = {
trees: [{ id: "main", label: "Main", root: true }],
nodes: [
{
id: "plan",
label: "Plan",
agent: "planner",
tree: "main",
kind: "stage",
gated: false,
},
],
edges: [],
};
const SMOKE_TASK = {
thread_id: "t-smoke-0001",
short_id: "t-smoke",
task: "Playwright smoke task",
current_phase: "plan",
status: "active",
waiting: false,
waiting_since: null,
};
const STATE = {
ok: true,
error: null,
generated_at: "2026-06-24T00:00:00Z",
db_path: ":memory:",
stages: [
{
key: "plan",
label: "Plan",
agent: "planner",
phases: ["plan"],
gated: false,
role_node: true,
state: "active",
count: 1,
tasks: [SMOKE_TASK],
},
],
tasks: [SMOKE_TASK],
waiting: [],
question_counts: {},
summary: { active: 1, parked: 0, waiting: 0, open_questions: 0, total: 1 },
budget: { available: false },
nodes: { plan: { state: "active", count: 1 } },
};
test("dashboard Board renders live state from a mocked /api", async ({
page,
}) => {
await page.route("**/api/topology*", (route) =>
route.fulfill({ json: TOPOLOGY }),
);
await page.route("**/api/state*", (route) => route.fulfill({ json: STATE }));
await page.goto("/");
// App shell mounted: the Board / Pipeline tabs render.
await expect(page.getByRole("tab", { name: /board/i })).toBeVisible();
// The mocked live state flowed through fetch → state → the Board column.
await expect(page.getByText("Plan", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Playwright smoke task")).toBeVisible();
});

View file

@ -0,0 +1,51 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
// Flat config for the agent-team dashboard SPA (Vite + React + TS). Lints the
// `src/` tree only; build/test config files are excluded. `eslint-config-prettier`
// is applied LAST so Prettier owns all formatting (no rule conflicts with
// `format:check`).
export default tseslint.config(
{
ignores: [
"dist",
"coverage",
"node_modules",
"playwright-report",
"test-results",
],
},
{
files: ["src/**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
{
// shadcn/ui primitives intentionally co-export their `cva` variants
// (e.g. `buttonVariants`) alongside the component — the documented pattern;
// they are static library primitives, not HMR-sensitive app screens.
files: ["src/components/ui/**/*.{ts,tsx}"],
rules: {
"react-refresh/only-export-components": "off",
},
},
prettier,
);

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,11 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -b"
"typecheck": "tsc -b",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"test:e2e": "playwright test"
},
"dependencies": {
"@dagrejs/dagre": "1.1.4",
@ -31,6 +35,8 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@eslint/js": "9.39.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.1",
"@types/node": "20.14.15",
@ -38,11 +44,18 @@
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "4.3.1",
"autoprefixer": "^10.5.1",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"jsdom": "24.1.3",
"postcss": "^8.5.15",
"prettier": "3.6.2",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.5.4",
"typescript-eslint": "8.46.0",
"vite": "5.4.8",
"vitest": "1.6.0"
}

View file

@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
// Browser smoke for the read-only dashboard SPA. The e2e mocks the `/api`
// backend (Playwright route fulfilment), so no live coordinator / DB is needed.
// The webServer builds + serves the production bundle via `vite preview`.
const PORT = 4173;
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: `http://localhost:${PORT}`,
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `npm run build && npm run preview -- --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View file

@ -116,4 +116,6 @@ export const fetchTopology = (): Promise<Topology> =>
getJSON<Topology>("/api/topology");
export const fetchTask = (threadId: string): Promise<TaskDetail> =>
getJSON<TaskDetail>(`/api/task/${encodeURIComponent(threadId)}?ts=${Date.now()}`);
getJSON<TaskDetail>(
`/api/task/${encodeURIComponent(threadId)}?ts=${Date.now()}`,
);

View file

@ -16,9 +16,30 @@ const TOPO: Topology = {
{ id: "sdlc", label: "SDLC" },
],
nodes: [
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
{ id: "clarify", label: "Clarify", agent: "coordinator", tree: "core", kind: "gate", gated: true },
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
{
id: "intake",
label: "Intake",
agent: "coordinator",
tree: "core",
kind: "phase",
gated: false,
},
{
id: "clarify",
label: "Clarify",
agent: "coordinator",
tree: "core",
kind: "gate",
gated: true,
},
{
id: "plan",
label: "Plan",
agent: "Claude",
tree: "sdlc",
kind: "phase",
gated: false,
},
],
edges: [],
};

View file

@ -11,7 +11,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { TaskCard } from "./TaskCard";
import { buildTreeByNode, filterTasks, phaseToNode } from "../taskFilters";
@ -146,7 +150,10 @@ export function Board({
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Filter by status">
<SelectTrigger
className="h-8 w-40 text-sm"
aria-label="Filter by status"
>
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
@ -159,11 +166,16 @@ export function Board({
</Select>
<Select value={tree} onValueChange={setTree}>
<SelectTrigger className="h-8 w-36 text-sm" aria-label="Filter by tree">
<SelectTrigger
className="h-8 w-36 text-sm"
aria-label="Filter by tree"
>
<SelectValue placeholder="All trees" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-sm">All trees</SelectItem>
<SelectItem value="all" className="text-sm">
All trees
</SelectItem>
{(topology?.trees ?? []).map((t) => (
<SelectItem key={t.id} value={t.id} className="text-sm">
{t.label}

View file

@ -1,7 +1,11 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "reactflow";
import { Lock, RotateCcw } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { RFData } from "../layout";
@ -19,7 +23,8 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
"relative flex w-[184px] h-[72px] flex-col items-center justify-center gap-0.5 rounded-lg border bg-card px-3 py-2 text-center",
// State-driven border + tint
state === "active" && "border-status-active bg-status-active-bg",
state === "awaiting_human" && "border-status-waiting bg-status-waiting-bg",
state === "awaiting_human" &&
"border-status-waiting bg-status-waiting-bg",
state === "parked" && "border-status-parked bg-status-parked-bg",
state === "idle" && "border-border bg-card",
// Path highlight
@ -49,7 +54,10 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
{meta.kind === "gate" && (
<span className="inline-flex" title="human gate">
<Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
<Lock
className="h-3 w-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</span>
)}

View file

@ -36,7 +36,8 @@ export function PipelineMap({
[topology, state, pathNodeIds, loopedEdges],
);
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
const handleNodeClick: NodeMouseHandler = (_evt, node) =>
onNodeClick(node.id);
return (
<div className="h-full w-full" aria-label="pipeline map">

View file

@ -12,8 +12,26 @@ const DETAIL: TaskDetail = {
current_phase: "done",
partial: false,
timeline: [
{ from_phase: null, to_phase: "intake", entered_at: "2026-06-23T00:00:00+00:00", exited_at: "2026-06-23T00:00:01+00:00", duration_s: 1, status: "active", note: null, cost_usd: 0 },
{ from_phase: "intake", to_phase: "plan", entered_at: "2026-06-23T00:00:01+00:00", exited_at: null, duration_s: null, status: "active", note: null, cost_usd: 0.12 },
{
from_phase: null,
to_phase: "intake",
entered_at: "2026-06-23T00:00:00+00:00",
exited_at: "2026-06-23T00:00:01+00:00",
duration_s: 1,
status: "active",
note: null,
cost_usd: 0,
},
{
from_phase: "intake",
to_phase: "plan",
entered_at: "2026-06-23T00:00:01+00:00",
exited_at: null,
duration_s: null,
status: "active",
note: null,
cost_usd: 0.12,
},
],
qa_history: [{ turn: 0, answer: "scope it" }],
review_verdicts: [],
@ -24,7 +42,14 @@ const DETAIL: TaskDetail = {
describe("TaskDrawer", () => {
it("renders the timeline steps with the task description and spend", () => {
render(<TaskDrawer detail={DETAIL} loading={false} error={null} onClose={() => {}} />);
render(
<TaskDrawer
detail={DETAIL}
loading={false}
error={null}
onClose={() => {}}
/>,
);
expect(screen.getByText("remediate CVE-2026-1")).toBeInTheDocument();
expect(screen.getByText("intake")).toBeInTheDocument();
expect(screen.getByText("plan")).toBeInTheDocument();
@ -46,7 +71,12 @@ describe("TaskDrawer", () => {
it("renders nothing when idle (no detail, not loading)", () => {
const { container } = render(
<TaskDrawer detail={null} loading={false} error={null} onClose={() => {}} />,
<TaskDrawer
detail={null}
loading={false}
error={null}
onClose={() => {}}
/>,
);
expect(container).toBeEmptyDOMElement();
});
@ -63,7 +93,9 @@ describe("TaskDrawer", () => {
},
],
};
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
render(
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
);
expect(screen.getByText("Missing phase").tagName).toBe("STRONG");
expect(screen.getByText("run the tests").closest("li")).not.toBeNull();
expect(screen.getByText("request changes")).toBeInTheDocument();
@ -74,10 +106,14 @@ describe("TaskDrawer", () => {
...DETAIL,
plan: {
summary: "Add a smoke test",
phases: [{ name: "Recon", steps: ["read conftest", "read test_graph"] }],
phases: [
{ name: "Recon", steps: ["read conftest", "read test_graph"] },
],
},
};
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
render(
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
);
expect(screen.getByText("Recon")).toBeInTheDocument();
expect(screen.getByText("read conftest").closest("li")).not.toBeNull();
});
@ -85,9 +121,14 @@ describe("TaskDrawer", () => {
it("renders both string and dict Q&A entries", () => {
const d: TaskDetail = {
...DETAIL,
qa_history: ["plain answer text", { turn: 1, answer: "dict answer text" }],
qa_history: [
"plain answer text",
{ turn: 1, answer: "dict answer text" },
],
};
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
render(
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
);
expect(screen.getByText("plain answer text")).toBeInTheDocument();
expect(screen.getByText("dict answer text")).toBeInTheDocument();
});

View file

@ -38,8 +38,7 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
if (!detail && !loading && !error) return null;
const isWaiting = detail?.ok && detail.status === "waiting_human";
const isClarifyGate =
isWaiting && detail?.current_phase === "clarify";
const isClarifyGate = isWaiting && detail?.current_phase === "clarify";
return (
<section
@ -167,7 +166,9 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
: "text-muted-foreground",
)}
>
{open ? "in progress" : fmtDuration(step.duration_s)}
{open
? "in progress"
: fmtDuration(step.duration_s)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
@ -177,7 +178,10 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
</span>
)}
{step.status && (
<Badge variant={statusVariant(step.status)} className="text-[10px] py-0 px-1.5">
<Badge
variant={statusVariant(step.status)}
className="text-[10px] py-0 px-1.5"
>
{step.status}
</Badge>
)}
@ -278,7 +282,11 @@ function CollapsibleSection({
}
function asText(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
return typeof v === "string"
? v
: v == null
? ""
: JSON.stringify(v, null, 2);
}
// Q&A: each entry is the answer text for a turn (a string), or an older
@ -289,9 +297,12 @@ function QASection({ entries }: { entries: unknown[] }) {
<div className="space-y-2">
{entries.map((entry, i) => {
const obj =
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
entry && typeof entry === "object"
? (entry as Record<string, unknown>)
: null;
const turn = obj && "turn" in obj ? Number(obj.turn) : i;
const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry);
const answer =
obj && "answer" in obj ? asText(obj.answer) : asText(entry);
return (
<Card key={i} className="bg-background">
<CardHeader className="pb-1 pt-2 px-3">
@ -316,7 +327,9 @@ function VerdictsSection({ entries }: { entries: unknown[] }) {
<div className="space-y-2">
{entries.map((entry, i) => {
const v =
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
entry && typeof entry === "object"
? (entry as Record<string, unknown>)
: {};
const verdict = asText(v.verdict) || "verdict";
const round = "round_index" in v ? Number(v.round_index) : i + 1;
const outcome = asText(v.outcome);
@ -358,7 +371,9 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
const phases = Array.isArray(plan.phases) ? plan.phases : null;
const extra = Object.entries(plan).filter(
([k, val]) =>
k !== "summary" && k !== "phases" && (typeof val !== "object" || val === null),
k !== "summary" &&
k !== "phases" &&
(typeof val !== "object" || val === null),
);
if (!summary && !phases) {
@ -409,7 +424,9 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
<dl className="space-y-1">
{extra.map(([k, val]) => (
<div key={k} className="flex gap-2 text-xs">
<dt className="text-muted-foreground font-medium shrink-0">{k}</dt>
<dt className="text-muted-foreground font-medium shrink-0">
{k}
</dt>
<dd className="text-foreground truncate">{asText(val)}</dd>
</div>
))}

View file

@ -93,7 +93,12 @@ export function TopBar({ state, connected, generatedAt }: Props) {
c.bgCls,
)}
>
<span className={cn("text-sm font-bold leading-none tabular-nums", c.numCls)}>
<span
className={cn(
"text-sm font-bold leading-none tabular-nums",
c.numCls,
)}
>
{summary ? summary[c.key] : "–"}
</span>
<span className="text-[9px] font-semibold tracking-widest text-muted-foreground leading-none mt-0.5 whitespace-nowrap">

View file

@ -44,7 +44,8 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
position === "popper" &&
"w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@ -76,10 +77,4 @@ const SelectItem = React.forwardRef<
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
};
export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem };

View file

@ -8,10 +8,38 @@ const TOPO: Topology = {
{ id: "sdlc", label: "SDLC", root: false },
],
nodes: [
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
{ id: "clarify", label: "Clarify", agent: "Claude", tree: "sdlc", kind: "gate", gated: false },
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
{ id: "review", label: "Review", agent: "GPT-4.1", tree: "sdlc", kind: "phase", gated: false },
{
id: "intake",
label: "Intake",
agent: "coordinator",
tree: "core",
kind: "phase",
gated: false,
},
{
id: "clarify",
label: "Clarify",
agent: "Claude",
tree: "sdlc",
kind: "gate",
gated: false,
},
{
id: "plan",
label: "Plan",
agent: "Claude",
tree: "sdlc",
kind: "phase",
gated: false,
},
{
id: "review",
label: "Review",
agent: "GPT-4.1",
tree: "sdlc",
kind: "phase",
gated: false,
},
],
edges: [
{ from: "intake", to: "clarify", kind: "spine" },
@ -36,7 +64,9 @@ describe("layout", () => {
it("populates loopBackTo on the loop-back source node (drives the ↺ chip)", () => {
const { nodes } = layout(TOPO, {}, null);
expect(nodes.find((n) => n.id === "review")!.data.loopBackTo).toEqual(["plan"]);
expect(nodes.find((n) => n.id === "review")!.data.loopBackTo).toEqual([
"plan",
]);
expect(nodes.find((n) => n.id === "plan")!.data.loopBackTo).toEqual([]);
});
@ -52,7 +82,11 @@ describe("layout", () => {
});
it("applies live state + count to the matching node", () => {
const { nodes } = layout(TOPO, { clarify: { state: "awaiting_human", count: 2 } }, null);
const { nodes } = layout(
TOPO,
{ clarify: { state: "awaiting_human", count: 2 } },
null,
);
const clarify = nodes.find((n) => n.id === "clarify")!;
expect(clarify.data.state).toBe("awaiting_human");
expect(clarify.data.count).toBe(2);

View file

@ -35,12 +35,19 @@ export function layout(
loopedEdges: Set<string> | null = null,
): { nodes: Node<RFData>[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 });
g.setGraph({
rankdir: "LR",
nodesep: 36,
ranksep: 72,
marginx: 24,
marginy: 24,
});
g.setDefaultEdgeLabel(() => ({}));
// Lay out using ONLY forward edges, so the backward loop-backs don't distort
// the left-to-right ranking.
for (const n of topo.nodes) g.setNode(n.id, { width: NODE_W, height: NODE_H });
for (const n of topo.nodes)
g.setNode(n.id, { width: NODE_W, height: NODE_H });
for (const e of topo.edges) {
if (FORWARD.has(e.kind)) g.setEdge(e.from, e.to);
}
@ -83,7 +90,8 @@ export function layout(
target: e.to,
animated: true,
style: { stroke: "#7a5cff", strokeWidth: 2, strokeDasharray: "5 4" },
label: e.to === "plan" ? "↺ revised" : "↺ retried",
label:
e.to === "plan" || e.to === "conf_draft" ? "↺ revised" : "↺ retried",
labelStyle: { fill: "#9b8bff", fontWeight: 700 },
type: "smoothstep",
zIndex: 5,

View file

@ -47,7 +47,10 @@ describe("phaseToNode", () => {
describe("filterTasks", () => {
it("returns all tasks when no filters applied", () => {
const tasks = [task(), task({ thread_id: "bbbb2222", short_id: "bbbb2222" })];
const tasks = [
task(),
task({ thread_id: "bbbb2222", short_id: "bbbb2222" }),
];
expect(filterTasks(tasks, BASE_PARAMS)).toHaveLength(2);
});
@ -73,7 +76,9 @@ describe("filterTasks", () => {
it("query matching is case-insensitive", () => {
const tasks = [task({ task: "Remediate CVE" })];
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" })).toHaveLength(1);
expect(
filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" }),
).toHaveLength(1);
});
it("filters by status", () => {
@ -91,13 +96,15 @@ describe("filterTasks", () => {
task({ status: "active" }),
task({ thread_id: "bbbb", short_id: "bbbb", status: "done" }),
];
expect(filterTasks(tasks, { ...BASE_PARAMS, status: "all" })).toHaveLength(2);
expect(filterTasks(tasks, { ...BASE_PARAMS, status: "all" })).toHaveLength(
2,
);
});
it("filters by tree", () => {
const tasks = [
task({ current_phase: "intake" }), // core tree
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }), // sdlc tree
task({ current_phase: "intake" }), // core tree
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }), // sdlc tree
];
const result = filterTasks(tasks, { ...BASE_PARAMS, tree: "sdlc" });
expect(result).toHaveLength(1);
@ -119,7 +126,10 @@ describe("filterTasks", () => {
task({ current_phase: "build" }),
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "build_node" });
const result = filterTasks(tasks, {
...BASE_PARAMS,
nodeFilter: "build_node",
});
expect(result).toHaveLength(1);
expect(result[0].current_phase).toBe("build");
});
@ -127,17 +137,33 @@ describe("filterTasks", () => {
it("combines query + status filters", () => {
const tasks = [
task({ task: "fix login", status: "active" }),
task({ thread_id: "b", short_id: "b", task: "fix login", status: "parked" }),
task({ thread_id: "c", short_id: "c", task: "other task", status: "active" }),
task({
thread_id: "b",
short_id: "b",
task: "fix login",
status: "parked",
}),
task({
thread_id: "c",
short_id: "c",
task: "other task",
status: "active",
}),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "fix login", status: "active" });
const result = filterTasks(tasks, {
...BASE_PARAMS,
query: "fix login",
status: "active",
});
expect(result).toHaveLength(1);
expect(result[0].thread_id).toBe("aaaa1111");
});
it("returns empty array when nothing matches", () => {
const tasks = [task()];
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "nonexistent" })).toHaveLength(0);
expect(
filterTasks(tasks, { ...BASE_PARAMS, query: "nonexistent" }),
).toHaveLength(0);
});
it("handles empty task list", () => {

View file

@ -15,7 +15,9 @@ export function phaseToNode(phase: string): string {
}
/** Build a node-id -> tree-id lookup from topology. */
export function buildTreeByNode(topology: Topology | null): Record<string, string> {
export function buildTreeByNode(
topology: Topology | null,
): Record<string, string> {
const m: Record<string, string> = {};
for (const n of topology?.nodes ?? []) m[n.id] = n.tree;
return m;
@ -23,14 +25,17 @@ export function buildTreeByNode(topology: Topology | null): Record<string, strin
export interface FilterParams {
query: string;
status: string; // "all" | task status value
tree: string; // "all" | tree id
status: string; // "all" | task status value
tree: string; // "all" | tree id
nodeFilter: string | null;
treeByNode: Record<string, string>;
}
/** Filter an array of TaskView objects by the given params. */
export function filterTasks(tasks: TaskView[], params: FilterParams): TaskView[] {
export function filterTasks(
tasks: TaskView[],
params: FilterParams,
): TaskView[] {
const { query, status, tree, nodeFilter, treeByNode } = params;
const q = query.trim().toLowerCase();

View file

@ -62,5 +62,9 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
// Unit tests live under src/. Scope vitest there so it does NOT pick up the
// Playwright e2e specs in e2e/ (they import @playwright/test — the Playwright
// runner, not vitest — and fail to load under `vitest run`).
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});