feat(approve): pass pending_approval_detail through cluster live-bulk

Threads the field added by Chunk 1 through the console's live-bulk
endpoint so coord tree UI can read it without a separate per-child
fetch. Three touchpoints:

- _CLUSTER_WS_LIVE_KEYS gains the new key so _fetch_live_block's
  projection forwards it from the upstream /dashboard response on
  node-backed child rows.
- _coordinator_live_snapshot synthesizes the same shape from
  ConsoleCoordinatorUI._pending_approval for in-process coord
  rows (no upstream /dashboard exists on the console pseudo-node).
- One source of truth: SessionUIBase.serialize_pending_approval_detail.

Both branches now emit the same 12-key live block; coord judge isn't
wired today so coord-self judge_verdict is always None — flagged in
the plan as a stretch follow-up.

Plan: docs/design/inline-child-approvals.md (chunk 2 of 4).
This commit is contained in:
Patrick Buckley
2026-04-27 09:00:24 -07:00
committed by Patrick Buckley
parent fbb9be27f9
commit 7d2d7db9d2
3 changed files with 116 additions and 0 deletions
+52
View File
@@ -1710,6 +1710,58 @@ def test_cluster_inspect_node_backed_success(storage):
assert live["pending_approval"] is False
def test_cluster_inspect_node_backed_pending_approval_detail_passes_through(storage):
"""Node /dashboard returns pending_approval_detail → live block carries
it through verbatim. Regression guard for the projection allowlist
`_CLUSTER_WS_LIVE_KEYS`: dropping the key from the tuple silently
breaks inline approve/deny buttons on remote-node child rows even
though coord rows still work via the in-process synthesis branch."""
mgr = _build_mgr(storage)
ws_id = "f0" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
detail = {
"call_id": "c-bash",
"judge_pending": False,
"items": [
{
"call_id": "c-bash",
"header": "bash",
"preview": "$ rm -rf /tmp/x",
"func_name": "bash",
"approval_label": "bash",
"needs_approval": True,
"error": None,
"heuristic_verdict": None,
"judge_verdict": {
"recommendation": "deny",
"risk_level": "crit",
"tier": "llm",
},
}
],
}
payload = {
"workstreams": [
{
"ws_id": ws_id,
"state": "attention",
"activity_state": "approval",
"activity": "awaiting approval",
"tokens": 100,
"pending_approval_detail": detail,
}
]
}
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(lambda r: httpx.Response(200, json=payload)))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
live = resp.json()["live"]
assert live["pending_approval"] is True # derived bool, existing behavior
assert live["pending_approval_detail"] == detail # full payload, new behavior
def test_cluster_inspect_node_backed_pending_approval_synthesized(storage):
"""activity_state=='approval' from the node synthesizes pending_approval=True."""
mgr = _build_mgr(storage)
+46
View File
@@ -228,6 +228,52 @@ def test_bulk_live_coordinator_row_uses_manager_snapshot(storage):
live = body["results"][ws.id]
assert live is not None
assert "pending_approval" in live
# New field always present on the wire — None when no approval
# is pending so the JS can `key in row` without surprise.
assert "pending_approval_detail" in live
assert live["pending_approval_detail"] is None
def test_bulk_live_coordinator_row_includes_pending_approval_detail(storage):
"""When _pending_approval is set on a coord UI, the live block
surfaces the merged items + judge_verdict payload through the
coord-pseudo-node path. End-to-end equivalent of the dashboard
test in test_server_authz, but for the console live-bulk
endpoint that the coord tree UI actually consumes."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
ws.ui._pending_approval = {
"type": "approve_request",
"items": [
{
"call_id": "c-99",
"header": "spawn_workstream",
"preview": "{...}",
"func_name": "spawn_workstream",
"approval_label": "spawn_workstream",
"needs_approval": True,
}
],
"judge_pending": False,
}
ws.ui._llm_verdicts["c-99"] = {
"recommendation": "approve",
"risk_level": "low",
"tier": "llm",
}
client = _make_client(storage, coord_mgr=mgr)
resp = client.get(
f"/v1/api/cluster/ws/live?ids={ws.id}",
headers=_OWNER_HEADERS,
)
assert resp.status_code == 200
live = resp.json()["results"][ws.id]
assert live["pending_approval"] is True # boolean derived flag
detail = live["pending_approval_detail"]
assert detail is not None
assert detail["call_id"] == "c-99"
assert detail["items"][0]["func_name"] == "spawn_workstream"
assert detail["items"][0]["judge_verdict"]["recommendation"] == "approve"
# ---------------------------------------------------------------------------
+18
View File
@@ -607,6 +607,12 @@ _CLUSTER_WS_LIVE_KEYS = (
"model_alias",
"title",
"name",
# Carries the inline approve/deny payload (items + judge_verdict)
# so coord live-bulk callers can render row-level UI without a
# per-child round-trip. ``None`` when no approval is pending.
# Cross-tenant exposure follows the trusted-team posture documented
# on ``SessionUIBase.serialize_pending_approval_detail``.
"pending_approval_detail",
)
@@ -717,6 +723,17 @@ def _coordinator_live_snapshot(ws: Any) -> dict[str, Any]:
val = getattr(obj, name, "") if obj else ""
return val if isinstance(val, str) else ""
# Coord rows synthesize the same ``pending_approval_detail`` shape
# the node-side dashboard produces — single source of truth via
# ``SessionUIBase.serialize_pending_approval_detail``. The console
# coord LLM judge isn't wired today (``coordinator_ui.py:138``
# hardcodes ``judge_pending=False``), so ``judge_verdict`` will
# always be ``None`` for these rows; the coord-self stretch in
# the plan covers that follow-up. ``ui`` may be ``None`` in
# transient states (newly-created ws before activation); every
# active coord UI is a ``SessionUIBase`` and supports the method.
pending_approval_detail = ui.serialize_pending_approval_detail() if ui is not None else None
return {
"state": ws.state.value if hasattr(ws.state, "value") else str(ws.state),
"tokens": 0,
@@ -729,6 +746,7 @@ def _coordinator_live_snapshot(ws: Any) -> dict[str, Any]:
"title": "",
"name": getattr(ws, "name", "") or "",
"pending_approval": pending_approval,
"pending_approval_detail": pending_approval_detail,
}