Merge pull request 'feat(dashboard): agent-scoped e/p, drop discover-and-prompt path' (#41) from chunk-4-agent-scoped-edits into main
This commit was merged in pull request #41.
This commit is contained in:
@@ -29,7 +29,6 @@ from ..backend.docker.capability_apply import (
|
|||||||
)
|
)
|
||||||
from ..backend.docker.bottle_state import read_metadata
|
from ..backend.docker.bottle_state import read_metadata
|
||||||
from ..backend.docker.compose import (
|
from ..backend.docker.compose import (
|
||||||
COMPOSE_PROJECT_PREFIX,
|
|
||||||
compose_project_name,
|
compose_project_name,
|
||||||
list_active_slugs,
|
list_active_slugs,
|
||||||
)
|
)
|
||||||
@@ -85,42 +84,6 @@ class QueuedProposal:
|
|||||||
queue_dir: Path
|
queue_dir: Path
|
||||||
|
|
||||||
|
|
||||||
def _discover_active_with_service(service: str) -> list[str]:
|
|
||||||
"""Slugs of bottles whose compose project is up AND has the
|
|
||||||
named service container running. PRD 0018 chunk 5 grounded the
|
|
||||||
discovery on `docker compose ls` so all the dashboard verbs
|
|
||||||
agree with the cleanup CLI about what's running. A second
|
|
||||||
`docker ps` filter narrows by service label — a bottle without
|
|
||||||
egress routes has no egress service, and the operator-edit
|
|
||||||
flow shouldn't offer it for routes editing."""
|
|
||||||
slugs = list_active_slugs()
|
|
||||||
if not slugs:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "ps",
|
|
||||||
"--filter", f"label=com.docker.compose.service={service}",
|
|
||||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}{service}-",
|
|
||||||
"--format", "{{.Names}}",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return []
|
|
||||||
if r.returncode != 0:
|
|
||||||
return []
|
|
||||||
prefix = f"{COMPOSE_PROJECT_PREFIX}{service}-"
|
|
||||||
out: list[str] = []
|
|
||||||
for line in (r.stdout or "").splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith(prefix):
|
|
||||||
slug = line[len(prefix):]
|
|
||||||
if slug in slugs:
|
|
||||||
out.append(slug)
|
|
||||||
return sorted(set(out))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ActiveAgent:
|
class ActiveAgent:
|
||||||
"""One running bottle, as the agents pane displays it (PRD
|
"""One running bottle, as the agents pane displays it (PRD
|
||||||
@@ -194,16 +157,6 @@ def discover_active_agents() -> list[ActiveAgent]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def discover_egress_slugs() -> list[str]:
|
|
||||||
"""Slugs of bottles with a running egress sidecar. Used by
|
|
||||||
the operator-initiated `routes edit` verb."""
|
|
||||||
return _discover_active_with_service("egress")
|
|
||||||
|
|
||||||
|
|
||||||
def discover_pipelock_slugs() -> list[str]:
|
|
||||||
"""Slugs of bottles with a running pipelock sidecar. Used by
|
|
||||||
the operator-initiated `pipelock edit` verb."""
|
|
||||||
return _discover_active_with_service("pipelock")
|
|
||||||
|
|
||||||
|
|
||||||
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
||||||
@@ -582,11 +535,19 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
if key == 9: # Tab
|
if key == 9: # Tab
|
||||||
focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS
|
focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS
|
||||||
continue
|
continue
|
||||||
if key == ord("e"):
|
if key in (ord("e"), ord("p")):
|
||||||
status_line = _operator_edit_routes_flow(stdscr)
|
# PRD 0019 chunk 4: agent-scoped edits. Only fire when
|
||||||
continue
|
# the agents pane is focused on a real selection;
|
||||||
if key == ord("p"):
|
# otherwise no-op with a status hint. The pre-PRD
|
||||||
status_line = _operator_edit_allowlist_flow(stdscr)
|
# discover-and-prompt scaffolding is gone.
|
||||||
|
selected_obj = _selected_agent(focus, agents, selected_agent)
|
||||||
|
if selected_obj is None:
|
||||||
|
status_line = "no agent selected; Tab into the agents pane first"
|
||||||
|
continue
|
||||||
|
if key == ord("e"):
|
||||||
|
status_line = _operator_edit_routes_flow(stdscr, selected_obj)
|
||||||
|
else:
|
||||||
|
status_line = _operator_edit_allowlist_flow(stdscr, selected_obj)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if focus == PANE_AGENTS:
|
if focus == PANE_AGENTS:
|
||||||
@@ -737,7 +698,7 @@ def _render(
|
|||||||
|
|
||||||
footer = (
|
footer = (
|
||||||
"[Tab] switch pane [j/k] move [Enter] view "
|
"[Tab] switch pane [j/k] move [Enter] view "
|
||||||
"[a/m/r] proposal [e/p] edit [q] quit"
|
"[a/m/r] proposal [e/p] edit selected agent [q] quit"
|
||||||
)
|
)
|
||||||
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
||||||
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
||||||
@@ -758,7 +719,7 @@ def _selection_status(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Status-line text for the idle state. Surfaces the agents-
|
"""Status-line text for the idle state. Surfaces the agents-
|
||||||
pane selection so the operator can tell what an agent-scoped
|
pane selection so the operator can tell what an agent-scoped
|
||||||
edit verb (chunk 4) would target."""
|
edit verb would target."""
|
||||||
if focus != PANE_AGENTS:
|
if focus != PANE_AGENTS:
|
||||||
return ""
|
return ""
|
||||||
if not agents:
|
if not agents:
|
||||||
@@ -768,6 +729,21 @@ def _selection_status(
|
|||||||
return "[no agent selected]"
|
return "[no agent selected]"
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_agent(
|
||||||
|
focus: str, agents: list[ActiveAgent], selected_agent: int,
|
||||||
|
) -> ActiveAgent | None:
|
||||||
|
"""The selected agent to scope `e` / `p` to, or None if no
|
||||||
|
selection is valid (proposals pane focused, no active agents,
|
||||||
|
or selection out of bounds)."""
|
||||||
|
if focus != PANE_AGENTS:
|
||||||
|
return None
|
||||||
|
if not agents:
|
||||||
|
return None
|
||||||
|
if 0 <= selected_agent < len(agents):
|
||||||
|
return agents[selected_agent]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _format_agent_row(a: ActiveAgent, maxw: int) -> str:
|
def _format_agent_row(a: ActiveAgent, maxw: int) -> str:
|
||||||
"""One-line agent row: ` <slug> <agent_name> started <HH:MM:SS>
|
"""One-line agent row: ` <slug> <agent_name> started <HH:MM:SS>
|
||||||
[<sidecars>]`. The `agent` service is filtered out of the
|
[<sidecars>]`. The `agent` service is filtered out of the
|
||||||
@@ -923,27 +899,37 @@ def _suffix_for_tool(tool: str) -> str:
|
|||||||
return ".txt"
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str:
|
def _operator_edit_routes_flow(
|
||||||
"""Operator-initiated routes.yaml edit. Discover running
|
stdscr: "curses._CursesWindow", agent: ActiveAgent,
|
||||||
egress sidecars, pick one (single → use directly; multi →
|
) -> str:
|
||||||
prompt), fetch the current routes, open in $EDITOR, apply on
|
"""Operator-initiated routes.yaml edit, scoped to `agent`.
|
||||||
save. Returns a status-line message."""
|
PRD 0019: selection in the agents pane is the only way to
|
||||||
|
invoke this — the discover-and-prompt scaffolding is gone.
|
||||||
|
Refuses if the agent has no running egress sidecar."""
|
||||||
return _operator_edit_flow(
|
return _operator_edit_flow(
|
||||||
stdscr,
|
stdscr,
|
||||||
|
agent=agent,
|
||||||
|
required_service="egress",
|
||||||
label="routes",
|
label="routes",
|
||||||
discover=discover_egress_slugs,
|
|
||||||
fetch=fetch_current_routes,
|
fetch=fetch_current_routes,
|
||||||
apply=operator_edit_routes,
|
apply=operator_edit_routes,
|
||||||
suffix=".yaml",
|
suffix=".yaml",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _operator_edit_allowlist_flow(stdscr: "curses._CursesWindow") -> str:
|
def _operator_edit_allowlist_flow(
|
||||||
"""Operator-initiated pipelock allowlist edit."""
|
stdscr: "curses._CursesWindow", agent: ActiveAgent,
|
||||||
|
) -> str:
|
||||||
|
"""Operator-initiated pipelock allowlist edit, scoped to `agent`.
|
||||||
|
Pipelock is always present on an active bottle (no toggle in the
|
||||||
|
manifest) so the required-service check is belt-and-braces but
|
||||||
|
surfaces a clear error in the race-window case where compose up
|
||||||
|
is mid-flight."""
|
||||||
return _operator_edit_flow(
|
return _operator_edit_flow(
|
||||||
stdscr,
|
stdscr,
|
||||||
|
agent=agent,
|
||||||
|
required_service="pipelock",
|
||||||
label="pipelock",
|
label="pipelock",
|
||||||
discover=discover_pipelock_slugs,
|
|
||||||
fetch=fetch_current_allowlist,
|
fetch=fetch_current_allowlist,
|
||||||
apply=operator_edit_allowlist,
|
apply=operator_edit_allowlist,
|
||||||
suffix=".txt",
|
suffix=".txt",
|
||||||
@@ -953,27 +939,23 @@ def _operator_edit_allowlist_flow(stdscr: "curses._CursesWindow") -> str:
|
|||||||
def _operator_edit_flow(
|
def _operator_edit_flow(
|
||||||
stdscr: "curses._CursesWindow",
|
stdscr: "curses._CursesWindow",
|
||||||
*,
|
*,
|
||||||
|
agent: ActiveAgent,
|
||||||
|
required_service: str,
|
||||||
label: str,
|
label: str,
|
||||||
discover,
|
|
||||||
fetch,
|
fetch,
|
||||||
apply,
|
apply,
|
||||||
suffix: str,
|
suffix: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Shared scaffolding for the routes-edit + pipelock-edit verbs.
|
"""Shared scaffolding for the routes-edit + pipelock-edit verbs.
|
||||||
`discover` returns running-sidecar slugs; `fetch(slug)` returns
|
`fetch(slug)` returns the current operator-facing config;
|
||||||
the current operator-facing config; `apply(slug, new)` does the
|
`apply(slug, new)` does the write + restart/SIGHUP and writes
|
||||||
write + restart/SIGHUP and writes the audit entry."""
|
the audit entry."""
|
||||||
slugs = discover()
|
if required_service not in agent.services:
|
||||||
if not slugs:
|
return (
|
||||||
return f"no running {label} sidecars to edit"
|
f"[{agent.slug}] has no running {required_service} sidecar; "
|
||||||
if len(slugs) == 1:
|
f"nothing to edit"
|
||||||
slug = slugs[0]
|
)
|
||||||
else:
|
slug = agent.slug
|
||||||
slug = _prompt(stdscr, f"bottle ({', '.join(slugs)}): ")
|
|
||||||
if not slug:
|
|
||||||
return f"{label} edit aborted"
|
|
||||||
if slug not in slugs:
|
|
||||||
return f"unknown bottle {slug!r}"
|
|
||||||
try:
|
try:
|
||||||
current = fetch(slug)
|
current = fetch(slug)
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
|
|||||||
@@ -490,24 +490,6 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual([], read_audit_entries("egress", "dev"))
|
self.assertEqual([], read_audit_entries("egress", "dev"))
|
||||||
|
|
||||||
|
|
||||||
class TestDiscoverEgressSlugs(unittest.TestCase):
|
|
||||||
"""Slug-extraction parsing — exercises only the parsing path; the
|
|
||||||
docker ps invocation itself is environment-dependent (and tested
|
|
||||||
implicitly by the integration test)."""
|
|
||||||
|
|
||||||
def test_returns_empty_when_docker_unavailable(self):
|
|
||||||
# Force a failure by setting PATH to a dir with no docker
|
|
||||||
# binary. The discover helper swallows the non-zero rc.
|
|
||||||
import os
|
|
||||||
original = os.environ.get("PATH", "")
|
|
||||||
os.environ["PATH"] = "/nonexistent-no-docker-here"
|
|
||||||
try:
|
|
||||||
self.assertEqual([], dashboard.discover_egress_slugs())
|
|
||||||
self.assertEqual([], dashboard.discover_pipelock_slugs())
|
|
||||||
finally:
|
|
||||||
os.environ["PATH"] = original
|
|
||||||
|
|
||||||
|
|
||||||
class TestOperatorEditAllowlist(_FakeHomeMixin, unittest.TestCase):
|
class TestOperatorEditAllowlist(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""PRD 0015 Phase 3: operator-initiated pipelock allowlist edit."""
|
"""PRD 0015 Phase 3: operator-initiated pipelock allowlist edit."""
|
||||||
|
|
||||||
|
|||||||
@@ -256,5 +256,89 @@ class TestSelectionStatus(unittest.TestCase):
|
|||||||
self.assertEqual("[no agent selected]", s)
|
self.assertEqual("[no agent selected]", s)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectedAgent(unittest.TestCase):
|
||||||
|
"""`_selected_agent` is what chunk 4's e/p key handlers use to
|
||||||
|
decide whether to fire and which agent to target."""
|
||||||
|
|
||||||
|
def _agent(self, slug: str, services: tuple[str, ...] = ()) -> dashboard.ActiveAgent:
|
||||||
|
return dashboard.ActiveAgent(
|
||||||
|
slug=slug, agent_name="x", started_at="", services=services,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_none_when_proposals_focused(self):
|
||||||
|
agents = [self._agent("a-1")]
|
||||||
|
self.assertIsNone(
|
||||||
|
dashboard._selected_agent(dashboard.PANE_PROPOSALS, agents, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_none_when_no_agents(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
dashboard._selected_agent(dashboard.PANE_AGENTS, [], 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_indexed_agent_when_in_range(self):
|
||||||
|
agents = [self._agent("a-1"), self._agent("b-2")]
|
||||||
|
result = dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 1)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
assert result is not None # for type checker
|
||||||
|
self.assertEqual("b-2", result.slug)
|
||||||
|
|
||||||
|
def test_none_when_index_out_of_range(self):
|
||||||
|
agents = [self._agent("only")]
|
||||||
|
self.assertIsNone(
|
||||||
|
dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 99),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
"""Chunk-4 contract: the edit flow refuses when the selected
|
||||||
|
agent doesn't have the required sidecar running. The discover-
|
||||||
|
and-prompt scaffolding is gone, so the gating happens here
|
||||||
|
instead of in the key handler."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
|
def _agent(self, services: tuple[str, ...]) -> dashboard.ActiveAgent:
|
||||||
|
return dashboard.ActiveAgent(
|
||||||
|
slug="dev-abc12",
|
||||||
|
agent_name="impl",
|
||||||
|
started_at="",
|
||||||
|
services=services,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_routes_edit_refuses_without_egress(self):
|
||||||
|
# Bottle without bottle.egress.routes → no egress sidecar.
|
||||||
|
msg = dashboard._operator_edit_flow(
|
||||||
|
stdscr=None, # type: ignore[arg-type]
|
||||||
|
agent=self._agent(("pipelock", "supervise")),
|
||||||
|
required_service="egress",
|
||||||
|
label="routes",
|
||||||
|
fetch=lambda _: "x",
|
||||||
|
apply=lambda _slug, _content: None,
|
||||||
|
suffix=".yaml",
|
||||||
|
)
|
||||||
|
self.assertIn("no running egress sidecar", msg)
|
||||||
|
self.assertIn("dev-abc12", msg)
|
||||||
|
|
||||||
|
def test_pipelock_edit_refuses_when_pipelock_missing(self):
|
||||||
|
# Belt-and-braces — pipelock should always be there, but
|
||||||
|
# the race window between `compose up` and `docker ps`
|
||||||
|
# update is real.
|
||||||
|
msg = dashboard._operator_edit_flow(
|
||||||
|
stdscr=None, # type: ignore[arg-type]
|
||||||
|
agent=self._agent(()),
|
||||||
|
required_service="pipelock",
|
||||||
|
label="pipelock",
|
||||||
|
fetch=lambda _: "x",
|
||||||
|
apply=lambda _slug, _content: None,
|
||||||
|
suffix=".txt",
|
||||||
|
)
|
||||||
|
self.assertIn("no running pipelock sidecar", msg)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user