feat(agent-team): Confluence-writer node (draft → approve gate → dry-run write) [flag-gated] #66
43 changed files with 7122 additions and 88 deletions
18
.github/workflows/ci-web.yaml
vendored
18
.github/workflows/ci-web.yaml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
29
agent-team/agent_team/confluence/__init__.py
Normal file
29
agent-team/agent_team/confluence/__init__.py
Normal 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",
|
||||
]
|
||||
633
agent-team/agent_team/confluence/client.py
Normal file
633
agent-team/agent_team/confluence/client.py
Normal 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)
|
||||
454
agent-team/agent_team/confluence/mermaid.py
Normal file
454
agent-team/agent_team/confluence/mermaid.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
772
agent-team/agent_team/nodes/confluence_writer.py
Normal file
772
agent-team/agent_team/nodes/confluence_writer.py
Normal 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)}
|
||||
384
agent-team/agent_team/nodes/confluence_writer_llm.py
Normal file
384
agent-team/agent_team/nodes/confluence_writer_llm.py
Normal 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
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
546
agent-team/tests/test_confluence_client.py
Normal file
546
agent-team/tests/test_confluence_client.py
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
"""Unit tests for agent_team.confluence.client (OAuth 2LO + basic auth + dry-run updater).
|
||||
|
|
||||
|
||||
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)
|
||||
186
agent-team/tests/test_confluence_coordinator.py
Normal file
186
agent-team/tests/test_confluence_coordinator.py
Normal 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"
|
||||
401
agent-team/tests/test_confluence_e2e.py
Normal file
401
agent-team/tests/test_confluence_e2e.py
Normal 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}
|
||||
182
agent-team/tests/test_confluence_mermaid.py
Normal file
182
agent-team/tests/test_confluence_mermaid.py
Normal 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
|
||||
678
agent-team/tests/test_confluence_writer.py
Normal file
678
agent-team/tests/test_confluence_writer.py
Normal 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
|
||||
79
agent-team/tests/test_gate_kind_coverage.py
Normal file
79
agent-team/tests/test_gate_kind_coverage.py
Normal 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)."
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ def test_phase_members() -> None:
|
|||
"REVIEW",
|
||||
"BUILD",
|
||||
"VERIFY",
|
||||
"CONF_DRAFT",
|
||||
"CONF_GATE",
|
||||
"CONF_WRITE",
|
||||
"PARKED",
|
||||
"DONE",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
7
agent-team/web/.prettierignore
Normal file
7
agent-team/web/.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
dist
|
||||
node_modules
|
||||
coverage
|
||||
package-lock.json
|
||||
*.log
|
||||
playwright-report
|
||||
test-results
|
||||
6
agent-team/web/.prettierrc.json
Normal file
6
agent-team/web/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80
|
||||
}
|
||||
74
agent-team/web/e2e/dashboard.smoke.spec.ts
Normal file
74
agent-team/web/e2e/dashboard.smoke.spec.ts
Normal 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();
|
||||
});
|
||||
51
agent-team/web/eslint.config.js
Normal file
51
agent-team/web/eslint.config.js
Normal 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,
|
||||
);
|
||||
1364
agent-team/web/package-lock.json
generated
1364
agent-team/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
26
agent-team/web/playwright.config.ts
Normal file
26
agent-team/web/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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()}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Reference in a new issue
CodeQL / Incomplete URL substring sanitization
The string https://seahaven.atlassian.net may be at an arbitrary position in the sanitized URL.
Show more details