From 6e4a9f606f7e494615af3c6e87425510957cc21d Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 01:11:54 -0400 Subject: [PATCH] feat(dashboard): discover_active_agents helper + ActiveAgent dataclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0019 chunk 1. New `discover_active_agents()` in dashboard.py returns one `ActiveAgent(slug, agent_name, started_at, services)` per currently-running compose project: - Slugs come from `list_active_slugs()` (chunk-5 shared helper). - The service set per project comes from ONE label-filtered `docker ps` call (PRD open question #1: avoids N per-bottle `compose ps` invocations on each 1s refresh tick). - agent_name + started_at come from each bottle's metadata.json; "?" / "" fallbacks when the file is missing so the row renders rather than vanishes. Not wired into the TUI yet — chunk 2 renders the agents pane. The parser (`_parse_services_by_project`) is split out as a pure function so the conditional-input shape can be unit-tested without docker. --- claude_bottle/cli/dashboard.py | 75 +++++++++ tests/unit/test_dashboard_active_agents.py | 176 +++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 tests/unit/test_dashboard_active_agents.py diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 28998bf..a541248 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -27,8 +27,10 @@ from ..backend.docker.capability_apply import ( CapabilityApplyError, apply_capability_change, ) +from ..backend.docker.bottle_state import read_metadata from ..backend.docker.compose import ( COMPOSE_PROJECT_PREFIX, + compose_project_name, list_active_slugs, ) from ..backend.docker.egress_apply import ( @@ -119,6 +121,79 @@ def _discover_active_with_service(service: str) -> list[str]: return sorted(set(out)) +@dataclass(frozen=True) +class ActiveAgent: + """One running bottle, as the agents pane displays it (PRD + 0019). `services` is the set of sidecar service names + currently up for this bottle, used to gate which edit verbs + apply (no `egress` → `routes edit` is meaningless).""" + + slug: str + agent_name: str # from metadata.json; "?" if missing + started_at: str # ISO 8601 from metadata.json; "" if missing + services: tuple[str, ...] # alphabetical, e.g. ("egress", "pipelock", "supervise") + + +def _parse_services_by_project(stdout: str) -> dict[str, set[str]]: + """Parse `docker ps` output formatted as + `\\t` (one line per container) + into a `{project: {service, ...}}` mapping. Pure function for + testing — the docker invocation is in the caller.""" + out: dict[str, set[str]] = {} + for line in stdout.splitlines(): + project, _, service = line.partition("\t") + if not project or not service: + continue + out.setdefault(project, set()).add(service) + return out + + +def _query_services_by_project() -> dict[str, set[str]]: + """One `docker ps` call → `{project: {service, ...}}`. PRD + 0019 open question #1 picked this shape over per-bottle + `compose ps` calls — for hosts with N bottles, this is one + subprocess instead of N per refresh tick.""" + try: + r = subprocess.run( + [ + "docker", "ps", + "--filter", "label=com.docker.compose.project", + "--format", + '{{.Label "com.docker.compose.project"}}' + "\t" + '{{.Label "com.docker.compose.service"}}', + ], + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return {} + if r.returncode != 0: + return {} + return _parse_services_by_project(r.stdout or "") + + +def discover_active_agents() -> list[ActiveAgent]: + """All currently-running claude-bottle compose projects with + their metadata + service set. Returns [] when docker isn't + reachable. PRD 0019.""" + slugs = list_active_slugs() + if not slugs: + return [] + services_by_project = _query_services_by_project() + out: list[ActiveAgent] = [] + for slug in slugs: + project = compose_project_name(slug) + services = services_by_project.get(project, set()) + metadata = read_metadata(slug) + out.append(ActiveAgent( + slug=slug, + agent_name=metadata.agent_name if metadata else "?", + started_at=metadata.started_at if metadata else "", + services=tuple(sorted(services)), + )) + return out + + def discover_egress_slugs() -> list[str]: """Slugs of bottles with a running egress sidecar. Used by the operator-initiated `routes edit` verb.""" diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py new file mode 100644 index 0000000..bbd2d0c --- /dev/null +++ b/tests/unit/test_dashboard_active_agents.py @@ -0,0 +1,176 @@ +"""Unit: dashboard.discover_active_agents (PRD 0019 chunk 1). + +The full discover function fans out to `docker compose ls`, `docker +ps`, and per-bottle metadata.json reads — too much for a unit test. +Tests split into: + + - Parser tests for `_parse_services_by_project`: pure function, no + I/O, deterministic on its input string. + - Integration-shaped tests that monkeypatch the slug list + + services map and read metadata from a fake home, then assert + the assembled `ActiveAgent` shape. + +The actual `docker ps` invocation is exercised by manual probing +during development and the (real-docker) integration tests; here +we lock down the shape contract so a regression surfaces in unit CI. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from claude_bottle import supervise +from claude_bottle.backend.docker import bottle_state +from claude_bottle.cli import dashboard + + +class TestParseServicesByProject(unittest.TestCase): + def test_empty_input(self): + self.assertEqual({}, dashboard._parse_services_by_project("")) + + def test_one_container(self): + out = dashboard._parse_services_by_project( + "claude-bottle-dev-abc\tegress\n" + ) + self.assertEqual({"claude-bottle-dev-abc": {"egress"}}, out) + + def test_multiple_services_per_project(self): + out = dashboard._parse_services_by_project( + "claude-bottle-dev-abc\tegress\n" + "claude-bottle-dev-abc\tpipelock\n" + "claude-bottle-dev-abc\tsupervise\n" + ) + self.assertEqual( + {"claude-bottle-dev-abc": {"egress", "pipelock", "supervise"}}, + out, + ) + + def test_multiple_projects(self): + out = dashboard._parse_services_by_project( + "proj-a\tegress\n" + "proj-b\tpipelock\n" + "proj-a\tsupervise\n" + ) + self.assertEqual( + {"proj-a": {"egress", "supervise"}, "proj-b": {"pipelock"}}, + out, + ) + + def test_skips_lines_missing_either_field(self): + # Defends against unlabeled containers slipping into the + # output (the filter should prevent it, but be robust). + out = dashboard._parse_services_by_project( + "claude-bottle-dev-abc\tegress\n" + "no-tab-here\n" + "\tmissing-project\n" + "missing-service\t\n" + ) + self.assertEqual({"claude-bottle-dev-abc": {"egress"}}, out) + + +class _FakeHomeMixin: + def _setup_fake_home(self) -> None: + self._tmp = tempfile.TemporaryDirectory(prefix="dashboard-aa-test.") + original = supervise.claude_bottle_root + + def fake_root() -> Path: + return Path(self._tmp.name) / ".claude-bottle" + + supervise.claude_bottle_root = fake_root # type: ignore[assignment] + self._restore_home = lambda: setattr(supervise, "claude_bottle_root", original) + + def _teardown_fake_home(self) -> None: + self._restore_home() + self._tmp.cleanup() + + +class TestDiscoverActiveAgents(_FakeHomeMixin, unittest.TestCase): + def setUp(self) -> None: + self._setup_fake_home() + self._orig_slugs = dashboard.list_active_slugs + self._orig_services = dashboard._query_services_by_project + + def tearDown(self) -> None: + dashboard.list_active_slugs = self._orig_slugs + dashboard._query_services_by_project = self._orig_services + self._teardown_fake_home() + + def _stub(self, slugs: list[str], services_by_project: dict[str, set[str]]) -> None: + dashboard.list_active_slugs = lambda: slugs + dashboard._query_services_by_project = lambda: services_by_project + + def test_no_active_slugs_returns_empty(self): + self._stub([], {}) + self.assertEqual([], dashboard.discover_active_agents()) + + def test_assembles_from_metadata_and_services(self): + bottle_state.write_metadata(bottle_state.BottleMetadata( + identity="dev-abc", + agent_name="implementer", + cwd="", + copy_cwd=False, + started_at="2026-05-26T03:00:00+00:00", + compose_project="claude-bottle-dev-abc", + )) + self._stub( + ["dev-abc"], + {"claude-bottle-dev-abc": {"pipelock", "egress", "supervise"}}, + ) + agents = dashboard.discover_active_agents() + self.assertEqual(1, len(agents)) + a = agents[0] + self.assertEqual("dev-abc", a.slug) + self.assertEqual("implementer", a.agent_name) + self.assertEqual("2026-05-26T03:00:00+00:00", a.started_at) + self.assertEqual(("egress", "pipelock", "supervise"), a.services) + + def test_missing_metadata_renders_question_mark(self): + # State dir doesn't exist for this slug — agent_name falls + # back to "?" rather than dropping the row. + self._stub(["mystery-zzz"], {"claude-bottle-mystery-zzz": {"pipelock"}}) + agents = dashboard.discover_active_agents() + self.assertEqual(1, len(agents)) + self.assertEqual("?", agents[0].agent_name) + self.assertEqual("", agents[0].started_at) + self.assertEqual(("pipelock",), agents[0].services) + + def test_no_services_for_project_yields_empty_tuple(self): + # Race window between `compose up` returning and the actual + # containers being listed in `docker ps` — render the row + # but with no services. + bottle_state.write_metadata(bottle_state.BottleMetadata( + identity="warming-up", + agent_name="researcher", + cwd="", + copy_cwd=False, + started_at="2026-05-26T03:05:00+00:00", + compose_project="claude-bottle-warming-up", + )) + self._stub(["warming-up"], {}) + agents = dashboard.discover_active_agents() + self.assertEqual((), agents[0].services) + + def test_preserves_slug_order(self): + for slug in ("z-1", "a-1", "m-1"): + bottle_state.write_metadata(bottle_state.BottleMetadata( + identity=slug, + agent_name=slug.split("-")[0], + cwd="", + copy_cwd=False, + started_at="t", + compose_project=f"claude-bottle-{slug}", + )) + # list_active_slugs returns sorted; preserve that order in + # the output. + self._stub(["a-1", "m-1", "z-1"], {}) + agents = dashboard.discover_active_agents() + self.assertEqual( + ["a-1", "m-1", "z-1"], + [a.slug for a in agents], + ) + + +if __name__ == "__main__": + unittest.main()