refactor(backend): has_backend() helper + docker/enumerate split + ActiveAgent rename
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 42s

Addresses PR #78 review feedback:

- New `has_backend(name)` on the backend package + abstract
  `BottleBackend.is_available()` on each concrete subclass.
  Replaces inline `shutil.which("docker") is None` checks in
  docker/cleanup.py:178 and smolmachines/enumerate.py:73.
  Docker → `shutil.which("docker") is not None`; smolmachines
  → `smolvm.is_available()`. Cross-backend `enumerate_active_
  agents()` skips backends whose `is_available()` is False so a
  docker-only host doesn't fail when iterating past
  smolmachines (and vice versa).
- Move docker `enumerate_active` + parser helpers out of
  cleanup.py into a new `backend/docker/enumerate.py`, mirroring
  the smolmachines/enumerate.py layout. cleanup.py is now
  purely about prepare_cleanup / cleanup; the active-listing
  concern owns its own file.
- Drop the `ActiveAgent = ActiveBottle` alias in dashboard.py.
  The canonical name is `ActiveAgent` (the thing running inside
  a bottle is always called "agent" in this codebase; the bottle
  is the container). Renamed `enumerate_active_bottles` →
  `enumerate_active_agents` to match.

Tests:
- `test_backend_selection.TestEnumerateActiveAgents
  .test_skips_unavailable_backends` locks down the
  `is_available()`-gated iteration.
- New `TestHasBackend` covers `has_backend("docker")` consulting
  the backend's `is_available`, and unknown-name → False.
- Existing tests follow the rename; the docker-availability-
  side-effect test in `test_docker_enumerate_active` moves up
  to the cross-backend layer (where the gate lives now).

607 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 19:03:16 -04:00
parent adff1263d8
commit 3b418580a9
11 changed files with 295 additions and 176 deletions
+46 -14
View File
@@ -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",
]