feat(supervise): show the bottle's human slug, not its opaque id

Consolidated proposals are keyed by the orchestrator-assigned bottle_id,
so the supervise TUI was rendering a hex id (e.g. 3601cbe883c2786d) as
the bottle name — and the resume hint printed `./cli.py resume <id>`,
which resume can't take (it wants the human identity/slug).

`supervise_pending` now tags each dict with `bottle_label` — the human
slug resolved from registry metadata, falling back to the id when the
bottle is gone. `QueuedProposal` carries the label; every display site
(list rows, detail view, status lines, resume hint) shows it, while
approve/reject still key on `proposal.bottle_slug` (the id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
2026-07-16 22:05:21 -04:00
parent 5f59df9e10
commit b1df380ae1
4 changed files with 75 additions and 10 deletions
+17 -8
View File
@@ -47,9 +47,15 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
@dataclass(frozen=True)
class QueuedProposal:
"""A pending proposal from the supervise queue."""
"""A pending proposal from the supervise queue.
`label` is the operator-facing bottle name (the human slug the
orchestrator resolved from the registry); `proposal.bottle_slug` is the
opaque bottle_id every operator action is keyed by. Display uses `label`;
respond calls use `proposal.bottle_slug`."""
proposal: Proposal
label: str = ""
# A failed operator action (orchestrator unreachable, bottle torn down,
@@ -91,7 +97,10 @@ def _client() -> OrchestratorClient:
def discover_pending() -> list[QueuedProposal]:
"""Collect pending proposals across bottles from the orchestrator."""
out = [
QueuedProposal(proposal=Proposal.from_dict(d))
QueuedProposal(
proposal=Proposal.from_dict(d),
label=str(d.get("bottle_label") or d.get("bottle_slug") or ""),
)
for d in _client().supervise_pending()
]
out.sort(key=lambda q: q.proposal.arrival_timestamp)
@@ -100,8 +109,8 @@ def discover_pending() -> list[QueuedProposal]:
def _approval_status(qp: QueuedProposal, verb: str) -> str:
"""Status-line text after a successful approval."""
base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
base = f"{verb} {qp.proposal.tool} for [{qp.label}]"
return f"{base}; resume: ./cli.py resume {qp.label}"
def _detail_lines(
@@ -112,7 +121,7 @@ def _detail_lines(
"""Return the detail-view body as (text, curses-attr) tuples."""
p = qp.proposal
out: list[tuple[str, int]] = [
(f"bottle: {p.bottle_slug}", 0),
(f"bottle: {qp.label}", 0),
(f"tool: {p.tool}", 0),
(f"id: {p.id}", 0),
(f"arrived: {p.arrival_timestamp}", 0),
@@ -287,7 +296,7 @@ def _list_once() -> int:
for qp in pending:
sys.stdout.write(
f"{qp.proposal.arrival_timestamp} "
f"[{qp.proposal.bottle_slug}] "
f"[{qp.label}] "
f"{qp.proposal.tool} "
f"{qp.proposal.id}\n"
)
@@ -384,7 +393,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
reason = _prompt(stdscr, "reject reason: ")
if reason:
reject(qp, reason=reason)
status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
status_line = f"rejected {qp.proposal.tool} for [{qp.label}]"
else:
status_line = "reject aborted (empty reason)"
@@ -423,7 +432,7 @@ def _render(
cursor = "> " if i == selected else " "
line = (
f"{cursor}{ts_short} "
f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}"
f"[{qp.label}] {p.tool:<18} {p.id[:8]}"
)
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line, w - 1, attr)
+26 -2
View File
@@ -153,8 +153,32 @@ class Orchestrator:
def supervise_pending(self) -> list[dict[str, object]]:
"""All pending proposals across bottles, FIFO, as JSON dicts
(`Proposal.to_dict`, round-trippable via `Proposal.from_dict`)."""
return [p.to_dict() for p in list_all_pending_proposals()]
(`Proposal.to_dict`, round-trippable via `Proposal.from_dict`).
Each dict carries an extra `bottle_label`: the bottle's human slug
resolved from the registry (the proposal itself is keyed by the
orchestrator-assigned bottle_id, which is opaque to an operator). The
CLI renders the label but still responds against `bottle_slug`."""
out: list[dict[str, object]] = []
for p in list_all_pending_proposals():
d = p.to_dict()
d["bottle_label"] = self._label_for(p.bottle_slug)
out.append(d)
return out
def _label_for(self, bottle_slug: str) -> str:
"""The human slug recorded in registry metadata for a proposal's
bottle, or the bottle_slug unchanged when the bottle is gone or has no
recorded slug — so the label is always non-empty."""
rec = self.registry.get(bottle_slug)
if rec is None:
return bottle_slug
try:
meta = json.loads(rec.metadata) if rec.metadata else {}
except ValueError:
meta = {}
slug = meta.get("slug") if isinstance(meta, dict) else None
return slug if isinstance(slug, str) and slug else bottle_slug
def _record_for_slug(self, slug: str) -> BottleRecord | None:
"""The live registry record for a proposal's bottle, or None (e.g. the