diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 5f92b47..3e7dcea 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -19,7 +19,7 @@ backend exposes five methods: cleanup(plan) -> None Actually removes everything described by the cleanup plan. - enumerate_active() -> Sequence[ActiveBottle] + enumerate_active() -> Sequence[ActiveAgent] Return every currently-running bottle on this backend, with enough metadata for callers (CLI `list active`, dashboard agents pane) to render a row. @@ -106,9 +106,11 @@ class ExecResult: @dataclass(frozen=True) -class ActiveBottle: - """One currently-running bottle, as the CLI `list active` and - dashboard agents pane render it. +class ActiveAgent: + """One currently-running agent, as the CLI `list active` and + dashboard agents pane render it. ("Agent" is the project's + consistent name for the thing running inside a bottle — the + bottle is the container, the agent is what runs in it.) Fields are deliberately backend-neutral. `services` is the set of sidecar daemons currently up for this bottle (`pipelock`, @@ -302,12 +304,23 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): """Remove everything described by the cleanup plan.""" @abstractmethod - def enumerate_active(self) -> Sequence[ActiveBottle]: - """Return every currently-running bottle on this backend. + def enumerate_active(self) -> Sequence[ActiveAgent]: + """Return every currently-running agent on this backend. Empty when none. Backend-specific: docker queries `docker compose ls`; smolmachines queries `smolvm machine ls --json` + cross-references its bundle container.""" + @classmethod + @abstractmethod + def is_available(cls) -> bool: + """Whether this backend's runtime prerequisites are satisfied + on the current host. Docker → `docker` on PATH; smolmachines + → `smolvm` on PATH. Used by the cross-backend + `enumerate_active_agents` / `cmd_cleanup` to skip backends + the operator hasn't installed, so a docker-only host + doesn't fail when `cli.py list active` walks past + smolmachines.""" + # Import concrete backend classes AFTER the base types are defined, so # each backend module can pull BottleSpec / BottlePlan / BottleBackend @@ -352,26 +365,45 @@ def known_backend_names() -> tuple[str, ...]: return tuple(sorted(_BACKENDS)) -def enumerate_active_bottles() -> list[ActiveBottle]: - """All currently-running bottles, across every backend. Used by - CLI `list active` and the dashboard's agents pane so neither - has to know which backends exist. Ordered by backend name, - then slug.""" - out: list[ActiveBottle] = [] +def has_backend(name: str) -> bool: + """Whether the named backend's runtime prerequisites are + available on the current host. Cross-backend callers (list, + cleanup) skip unavailable backends so a docker-only host + doesn't fail when the smolmachines backend isn't installed, + and vice versa. + + Returns False for unknown names so callers can pass + arbitrary input without separate validation.""" + if name not in _BACKENDS: + return False + return _BACKENDS[name].is_available() + + +def enumerate_active_agents() -> list[ActiveAgent]: + """All currently-running agents, across every available + backend. Used by CLI `list active` and the dashboard's agents + pane so neither has to know which backends exist. Skips + backends whose `is_available()` reports False. Ordered by + backend name, then by whatever each backend's + `enumerate_active` returns.""" + out: list[ActiveAgent] = [] for name in known_backend_names(): + if not has_backend(name): + continue out.extend(_BACKENDS[name].enumerate_active()) return out __all__ = [ - "ActiveBottle", + "ActiveAgent", "Bottle", "BottleBackend", "BottleCleanupPlan", "BottlePlan", "BottleSpec", "ExecResult", - "enumerate_active_bottles", + "enumerate_active_agents", "get_bottle_backend", + "has_backend", "known_backend_names", ] diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index fc6b663..24f1944 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -1,10 +1,11 @@ """DockerBottleBackend — the Docker implementation of BottleBackend. -This module is a thin façade. The real work lives in three siblings: +This module is a thin façade. The real work lives in four siblings: - - prepare.py — host-side resolution into a DockerBottlePlan - - launch.py — bring-up + teardown context manager - - cleanup.py — orphan enumeration, removal, and active listing + - prepare.py — host-side resolution into a DockerBottlePlan + - launch.py — bring-up + teardown context manager + - cleanup.py — orphan enumeration + removal + - enumerate.py — active-agent listing The base class's `prepare` template runs cross-backend host-side validation before calling `_resolve_plan` here. @@ -12,12 +13,14 @@ validation before calling `_resolve_plan` here. from __future__ import annotations +import shutil from contextlib import contextmanager from pathlib import Path from typing import Generator, Sequence -from .. import ActiveBottle, BottleBackend, BottleSpec +from .. import ActiveAgent, BottleBackend, BottleSpec from . import cleanup as _cleanup +from . import enumerate as _enumerate from . import launch as _launch from . import prepare as _prepare from .bottle import DockerBottle @@ -36,6 +39,15 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup name = "docker" + @classmethod + def is_available(cls) -> bool: + """`docker` on PATH is sufficient; we don't probe `docker info` + eagerly because the cross-backend enumerator runs this on + every `list active` and we'd pay a subprocess per call. A + broken daemon will surface its own error during prepare / + launch.""" + return shutil.which("docker") is not None + def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: return _prepare.resolve_plan(spec, stage_dir=stage_dir) @@ -65,5 +77,5 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def cleanup(self, plan: DockerBottleCleanupPlan) -> None: _cleanup.cleanup(plan) - def enumerate_active(self) -> Sequence[ActiveBottle]: - return _cleanup.enumerate_active() + def enumerate_active(self) -> Sequence[ActiveAgent]: + return _enumerate.enumerate_active() diff --git a/claude_bottle/backend/docker/cleanup.py b/claude_bottle/backend/docker/cleanup.py index 862a086..0487e11 100644 --- a/claude_bottle/backend/docker/cleanup.py +++ b/claude_bottle/backend/docker/cleanup.py @@ -1,4 +1,4 @@ -"""Cleanup + active-listing for the Docker bottle backend. +"""Cleanup for the Docker bottle backend. PRD 0018 chunk 4: cleanup is centered on `docker compose ls`. Pre-compose code paths could leave bare containers / networks @@ -18,8 +18,8 @@ scan, just as a fallback bucket alongside the project list. `cleanup` removes everything in the plan. -`list_active` queries the same compose project namespace and prints -each project's services for ad-hoc inspection. +Active-agent enumeration lives in `backend/docker/enumerate.py` +(mirror of `backend/smolmachines/enumerate.py`). """ from __future__ import annotations @@ -29,16 +29,10 @@ import subprocess from ... import supervise as _supervise from ...log import info, warn -from .. import ActiveBottle from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan -from .bottle_state import bottle_state_dir, is_preserved, read_metadata -from .compose import ( - COMPOSE_PROJECT_PREFIX, - compose_project_name, - list_active_slugs, - list_compose_projects, -) +from .bottle_state import bottle_state_dir, is_preserved +from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects def _list_prefixed_containers() -> list[str]: @@ -167,67 +161,6 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None: warn(f"failed to remove {path}: {e}") -def enumerate_active() -> list[ActiveBottle]: - """All currently-running docker-backed bottles as - `ActiveBottle` records. Backend-agnostic shape — the CLI - `list active` command and the dashboard agents pane both - consume this. Empty list when docker is unreachable or - nothing's running.""" - # docker on PATH? Defensive — `list active` shouldn't die - # just because the docker backend isn't usable on this host. - if shutil.which("docker") is None: - return [] - slugs = list_active_slugs(include_stopped=False) - if not slugs: - return [] - services_by_project = _query_services_by_project() - out: list[ActiveBottle] = [] - for slug in slugs: - project = compose_project_name(slug) - services = services_by_project.get(project, set()) - metadata = read_metadata(slug) - out.append(ActiveBottle( - backend_name="docker", - 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 _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 `_query_services_by_project`.""" - 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, ...}}`. Moved - here from the dashboard so the same query backs the CLI's - `list active` and the dashboard's agents pane.""" - 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 "") +# `enumerate_active` moved to `backend/docker/enumerate.py` to +# mirror the smolmachines layout. Cleanup keeps the orphan +# enumeration; enumeration of live agents is its own concern. diff --git a/claude_bottle/backend/docker/enumerate.py b/claude_bottle/backend/docker/enumerate.py new file mode 100644 index 0000000..d0179a9 --- /dev/null +++ b/claude_bottle/backend/docker/enumerate.py @@ -0,0 +1,80 @@ +"""Active-agent enumeration for the docker backend. + +Mirrors `backend/smolmachines/enumerate.py`: returns +`ActiveAgent` records the CLI `list active` command and the +dashboard agents pane consume. Empty when docker isn't reachable +— gated by `has_backend('docker')` at the cross-backend caller +so this module trusts that docker is available when called. + +The parser (`_parse_services_by_project`) is exposed for direct +unit testing; the docker `docker ps` invocation is in +`_query_services_by_project`.""" + +from __future__ import annotations + +import subprocess + +from .. import ActiveAgent +from .bottle_state import read_metadata +from .compose import compose_project_name, list_active_slugs + + +def enumerate_active() -> list[ActiveAgent]: + """All currently-running docker-backed agents. Caller is + responsible for gating on `has_backend('docker')` if it + matters; if docker is missing the `docker ps` call below + returns an empty list silently.""" + slugs = list_active_slugs(include_stopped=False) + 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( + backend_name="docker", + 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 _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 `_query_services_by_project`.""" + 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, ...}}`. Used + by the CLI's `list active` and the dashboard's agents pane — + one subprocess per refresh tick, not one per bottle.""" + 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 "") diff --git a/claude_bottle/backend/smolmachines/backend.py b/claude_bottle/backend/smolmachines/backend.py index a2e5062..d63362d 100644 --- a/claude_bottle/backend/smolmachines/backend.py +++ b/claude_bottle/backend/smolmachines/backend.py @@ -7,10 +7,11 @@ from contextlib import contextmanager from pathlib import Path from typing import Generator, Sequence -from .. import ActiveBottle, BottleBackend, BottleSpec +from .. import ActiveAgent, BottleBackend, BottleSpec from . import enumerate as _enumerate from . import launch as _launch from . import prepare as _prepare +from . import smolvm as _smolvm from .bottle import SmolmachinesBottle from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_plan import SmolmachinesBottlePlan @@ -29,6 +30,14 @@ class SmolmachinesBottleBackend( name = "smolmachines" + @classmethod + def is_available(cls) -> bool: + """`smolvm` on PATH. The backend additionally needs macOS + for libkrun + TSI, but `enumerate_active` / `cleanup` are + host-shell ops that gracefully no-op on Linux too — the + runtime check happens at `prepare`.""" + return _smolvm.is_available() + def _resolve_plan( self, spec: BottleSpec, *, stage_dir: Path ) -> SmolmachinesBottlePlan: @@ -74,5 +83,5 @@ class SmolmachinesBottleBackend( # Nothing to clean in chunks 1-3 — see # SmolmachinesBottleCleanupPlan docstring. - def enumerate_active(self) -> Sequence[ActiveBottle]: + def enumerate_active(self) -> Sequence[ActiveAgent]: return _enumerate.enumerate_active() diff --git a/claude_bottle/backend/smolmachines/enumerate.py b/claude_bottle/backend/smolmachines/enumerate.py index 2ae5b1c..05f9217 100644 --- a/claude_bottle/backend/smolmachines/enumerate.py +++ b/claude_bottle/backend/smolmachines/enumerate.py @@ -1,26 +1,30 @@ -"""Active-bottle enumeration for the smolmachines backend (PRD +"""Active-agent enumeration for the smolmachines backend (PRD 0023 chunk 4 follow-up + issue #77). -Returns a list of `ActiveBottle` records — same shape the docker +Returns a list of `ActiveAgent` records — same shape the docker backend produces — so CLI `list active` and the dashboard agents pane render both backends through one code path. -A smolmachines bottle is "active" when its smolvm guest is +A smolmachines agent is "active" when its smolvm guest is running. We cross-reference against the per-bottle sidecar bundle container to populate the `services` field (which daemons are up in the bundle); without a bundle we still surface the VM -so the operator can see + clean it up.""" +so the operator can see + clean it up. + +The cross-backend caller gates on `has_backend("smolmachines")` +and `has_backend("docker")`, so this module assumes both are +available when called. Both subprocess calls below still +tolerate "command not on PATH" defensively, but the gate is the +intended access pattern.""" from __future__ import annotations import json -import shutil import subprocess -from .. import ActiveBottle +from .. import ActiveAgent from ..docker.bottle_state import read_metadata from . import sidecar_bundle as _bundle -from . import smolvm as _smolvm # Smolvm VM names produced by prepare are `claude-bottle-`, @@ -29,12 +33,12 @@ from . import smolvm as _smolvm _VM_NAME_PREFIX = "claude-bottle-" -def enumerate_active() -> list[ActiveBottle]: - """All currently-running smolmachines-backed bottles. Empty - list when smolvm isn't on PATH or no matching VMs are - running.""" - if not _smolvm.is_available(): - return [] +def enumerate_active() -> list[ActiveAgent]: + """All currently-running smolmachines-backed agents. Empty + list when no matching VMs are running. Caller is responsible + for gating on `has_backend('smolmachines')` if needed; if + smolvm is missing the `smolvm machine ls` call below returns + nothing silently.""" result = subprocess.run( ["smolvm", "machine", "ls", "--json"], capture_output=True, text=True, check=False, @@ -46,7 +50,7 @@ def enumerate_active() -> list[ActiveBottle]: except json.JSONDecodeError: return [] services_by_slug = _query_bundle_services() - out: list[ActiveBottle] = [] + out: list[ActiveAgent] = [] for m in machines: name = m.get("name") or "" state = m.get("state") or "" @@ -54,7 +58,7 @@ def enumerate_active() -> list[ActiveBottle]: continue slug = name[len(_VM_NAME_PREFIX):] metadata = read_metadata(slug) - out.append(ActiveBottle( + out.append(ActiveAgent( backend_name="smolmachines", slug=slug, agent_name=metadata.agent_name if metadata else "?", @@ -69,8 +73,17 @@ def _query_bundle_services() -> dict[str, tuple[str, ...]]: bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env var. Smolmachines bundles all run the PRD-0024 image with the same daemon set declared via env, so one inspect per bundle - gets us the picture without exec'ing into the container.""" - if shutil.which("docker") is None: + gets us the picture without exec'ing into the container. + + Returns an empty mapping when the docker backend isn't + available — the bundle services field on each ActiveAgent + just shows up empty, matching the docker backend's "starting" + state.""" + # Late import: `has_backend` lives on the backend package's + # __init__, which imports this module transitively. Pulling + # the name in at call time sidesteps the cycle. + from .. import has_backend + if not has_backend("docker"): return {} ps = subprocess.run( ["docker", "ps", diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index eb4a705..ebe7560 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -27,9 +27,9 @@ from pathlib import Path from .. import supervise as _supervise from ..backend import ( - ActiveBottle, + ActiveAgent, BottleSpec, - enumerate_active_bottles, + enumerate_active_agents, get_bottle_backend, known_backend_names, ) @@ -97,22 +97,13 @@ class QueuedProposal: queue_dir: Path -# `ActiveAgent` was PRD-0019's docker-specific row type. It now -# aliases the shared `ActiveBottle` dataclass so the dashboard -# and the CLI `list active` both render the same source of truth. -# Field surface stays compatible (slug / agent_name / started_at -# / services) plus a new `backend_name` so dashboard rows can -# show which backend a bottle came from. -ActiveAgent = ActiveBottle - - -def discover_active_agents() -> list[ActiveBottle]: - """All currently-running bottles across every backend with +def discover_active_agents() -> list[ActiveAgent]: + """All currently-running agents across every backend with their metadata + service set. Returns [] when neither backend is reachable. Backed by the shared - `enumerate_active_bottles` helper so the CLI's + `enumerate_active_agents` helper so the CLI's `./cli.py list active` and this dashboard show the same data.""" - return enumerate_active_bottles() + return enumerate_active_agents() diff --git a/claude_bottle/cli/list.py b/claude_bottle/cli/list.py index a105384..2428282 100644 --- a/claude_bottle/cli/list.py +++ b/claude_bottle/cli/list.py @@ -5,7 +5,7 @@ from __future__ import annotations import argparse import sys -from ..backend import enumerate_active_bottles +from ..backend import enumerate_active_agents from ..manifest import Manifest from ._common import PROG, USER_CWD @@ -23,7 +23,7 @@ def cmd_list(argv: list[str]) -> int: # `active` enumerates every backend (docker + smolmachines) # so smolmachines bottles aren't hidden behind the env var. - active = enumerate_active_bottles() + active = enumerate_active_agents() if not active: print("no active claude-bottle bottles", file=sys.stderr) return 0 diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index e732e91..f6e32ac 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -1,8 +1,8 @@ """Unit: backend selection + cross-backend enumeration (issue #77). `get_bottle_backend(name)` resolves a backend by explicit name, -env var, or default. `enumerate_active_bottles()` walks every -registered backend and concatenates their `ActiveBottle` +env var, or default. `enumerate_active_agents()` walks every +registered backend and concatenates their `ActiveAgent` listings — the CLI and dashboard both go through this so adding a backend lights it up in both places.""" @@ -14,8 +14,8 @@ from unittest.mock import patch from claude_bottle import backend as backend_mod from claude_bottle.backend import ( - ActiveBottle, - enumerate_active_bottles, + ActiveAgent, + enumerate_active_agents, get_bottle_backend, known_backend_names, ) @@ -48,25 +48,29 @@ class TestKnownBackendNames(unittest.TestCase): self.assertEqual(("docker", "smolmachines"), known_backend_names()) -class TestEnumerateActiveBottles(unittest.TestCase): +class TestEnumerateActiveAgents(unittest.TestCase): """Combines each backend's `enumerate_active`. Each backend's implementation has its own tests (`test_docker_enumerate_active`, `test_smolmachines_*`); this just asserts the aggregator stitches them together.""" def test_concatenates_per_backend(self): - a = ActiveBottle( + a = ActiveAgent( backend_name="docker", slug="a-1", agent_name="impl", started_at="", services=("pipelock",), ) - b = ActiveBottle( + b = ActiveAgent( backend_name="smolmachines", slug="b-2", agent_name="research", started_at="", services=(), ) class _FakeBackend: - def __init__(self, items): + def __init__(self, items, available=True): self._items = items + self._available = available + + def is_available(self): + return self._available def enumerate_active(self): return self._items @@ -75,10 +79,13 @@ class TestEnumerateActiveBottles(unittest.TestCase): backend_mod, "_BACKENDS", {"docker": _FakeBackend([a]), "smolmachines": _FakeBackend([b])}, ): - self.assertEqual([a, b], enumerate_active_bottles()) + self.assertEqual([a, b], enumerate_active_agents()) def test_empty_when_no_backends_have_active(self): class _FakeBackend: + def is_available(self): + return True + def enumerate_active(self): return [] @@ -86,7 +93,58 @@ class TestEnumerateActiveBottles(unittest.TestCase): backend_mod, "_BACKENDS", {"docker": _FakeBackend(), "smolmachines": _FakeBackend()}, ): - self.assertEqual([], enumerate_active_bottles()) + self.assertEqual([], enumerate_active_agents()) + + def test_skips_unavailable_backends(self): + # If a backend's runtime isn't installed (smolvm missing on + # a docker-only host, or docker missing on a smolmachines- + # only host), the cross-backend enumerator skips it rather + # than dying — `has_backend` gates the iteration. + present = ActiveAgent( + backend_name="docker", slug="a-1", agent_name="impl", + started_at="", services=(), + ) + hidden = ActiveAgent( + backend_name="smolmachines", slug="x", agent_name="x", + started_at="", services=(), + ) + + class _FakeBackend: + def __init__(self, items, available): + self._items = items + self._available = available + + def is_available(self): + return self._available + + def enumerate_active(self): + return self._items + + with patch.object( + backend_mod, "_BACKENDS", + { + "docker": _FakeBackend([present], available=True), + "smolmachines": _FakeBackend([hidden], available=False), + }, + ): + self.assertEqual([present], enumerate_active_agents()) + + +class TestHasBackend(unittest.TestCase): + def test_known_backend_consults_is_available(self): + class _FakeBackend: + def is_available(self): + return False + + with patch.object( + backend_mod, "_BACKENDS", {"docker": _FakeBackend()}, + ): + from claude_bottle.backend import has_backend + self.assertFalse(has_backend("docker")) + + def test_unknown_backend_returns_false(self): + from claude_bottle.backend import has_backend + self.assertFalse(has_backend("nonexistent")) if __name__ == "__main__": diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index c082577..3bf2b3e 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -2,7 +2,7 @@ The active-bottle enumeration tests moved to `test_docker_enumerate_active.py` (issue #77) — dashboard now -delegates listing to `enumerate_active_bottles` so the parser +delegates listing to `enumerate_active_agents` so the parser and assembly tests live next to the docker backend's implementation. """ diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index 1574ebf..6ba1c9f 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -8,7 +8,7 @@ unit test. Tests split into: 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 `ActiveBottle` shape. + the assembled `ActiveAgent` shape. The actual `docker ps` invocation is exercised by manual probing during development and the (real-docker) integration tests; here @@ -24,21 +24,21 @@ import unittest from pathlib import Path from claude_bottle import supervise -from claude_bottle.backend.docker import bottle_state, cleanup +from claude_bottle.backend.docker import bottle_state, enumerate as _enumerate class TestParseServicesByProject(unittest.TestCase): def test_empty_input(self): - self.assertEqual({}, cleanup._parse_services_by_project("")) + self.assertEqual({}, _enumerate._parse_services_by_project("")) def test_one_container(self): - out = cleanup._parse_services_by_project( + out = _enumerate._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 = cleanup._parse_services_by_project( + out = _enumerate._parse_services_by_project( "claude-bottle-dev-abc\tegress\n" "claude-bottle-dev-abc\tpipelock\n" "claude-bottle-dev-abc\tsupervise\n" @@ -49,7 +49,7 @@ class TestParseServicesByProject(unittest.TestCase): ) def test_multiple_projects(self): - out = cleanup._parse_services_by_project( + out = _enumerate._parse_services_by_project( "proj-a\tegress\n" "proj-b\tpipelock\n" "proj-a\tsupervise\n" @@ -62,7 +62,7 @@ class TestParseServicesByProject(unittest.TestCase): 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 = cleanup._parse_services_by_project( + out = _enumerate._parse_services_by_project( "claude-bottle-dev-abc\tegress\n" "no-tab-here\n" "\tmissing-project\n" @@ -90,28 +90,21 @@ class _FakeHomeMixin: class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): def setUp(self) -> None: self._setup_fake_home() - self._orig_slugs = cleanup.list_active_slugs - self._orig_services = cleanup._query_services_by_project - # Skip the docker-availability gate so tests don't need a - # real docker on PATH. - import shutil - self._orig_which = shutil.which - shutil.which = lambda name: "/usr/bin/" + name if name == "docker" else None + self._orig_slugs = _enumerate.list_active_slugs + self._orig_services = _enumerate._query_services_by_project def tearDown(self) -> None: - cleanup.list_active_slugs = self._orig_slugs - cleanup._query_services_by_project = self._orig_services - import shutil - shutil.which = self._orig_which + _enumerate.list_active_slugs = self._orig_slugs + _enumerate._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: - cleanup.list_active_slugs = lambda **_: slugs - cleanup._query_services_by_project = lambda: services_by_project + _enumerate.list_active_slugs = lambda **_: slugs + _enumerate._query_services_by_project = lambda: services_by_project def test_no_active_slugs_returns_empty(self): self._stub([], {}) - self.assertEqual([], cleanup.enumerate_active()) + self.assertEqual([], _enumerate.enumerate_active()) def test_assembles_from_metadata_and_services(self): bottle_state.write_metadata(bottle_state.BottleMetadata( @@ -126,7 +119,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): ["dev-abc"], {"claude-bottle-dev-abc": {"pipelock", "egress", "supervise"}}, ) - active = cleanup.enumerate_active() + active = _enumerate.enumerate_active() self.assertEqual(1, len(active)) a = active[0] self.assertEqual("docker", a.backend_name) @@ -139,7 +132,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): # 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"}}) - active = cleanup.enumerate_active() + active = _enumerate.enumerate_active() self.assertEqual(1, len(active)) self.assertEqual("?", active[0].agent_name) self.assertEqual("", active[0].started_at) @@ -158,7 +151,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): compose_project="claude-bottle-warming-up", )) self._stub(["warming-up"], {}) - active = cleanup.enumerate_active() + active = _enumerate.enumerate_active() self.assertEqual((), active[0].services) def test_preserves_slug_order(self): @@ -174,20 +167,18 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): # list_active_slugs returns sorted; preserve that order in # the output. self._stub(["a-1", "m-1", "z-1"], {}) - active = cleanup.enumerate_active() + active = _enumerate.enumerate_active() self.assertEqual( ["a-1", "m-1", "z-1"], [a.slug for a in active], ) - def test_noop_when_docker_missing(self): - # Defensive: list active shouldn't die just because docker - # isn't on PATH (smolmachines bottles are still discoverable - # via the other backend's enumerate). - import shutil - shutil.which = lambda _name: None - self._stub(["some-slug"], {}) - self.assertEqual([], cleanup.enumerate_active()) + # `noop when docker missing` lives at the cross-backend gate + # now (`enumerate_active_agents` skips backends whose + # `is_available()` reports False — see + # `test_backend_selection.TestEnumerateActiveAgents`). This + # module assumes docker is available when called, matching the + # smolmachines/enumerate.py contract. if __name__ == "__main__":