feat(cli): cross-backend list active + --backend flag + dashboard picker (issue #77)
CLI and dashboard now share one cross-backend abstraction for listing + launching bottles, so adding a backend (docker / smolmachines) lights up in both places without separate wiring. Backend abstraction: - New `ActiveBottle` dataclass (`backend_name`, `slug`, `agent_name`, `started_at`, `services`) replaces the docker-specific `ActiveAgent`. Same field surface for the existing dashboard consumers; `ActiveAgent` becomes a typed alias for source-compat. - New `BottleBackend.enumerate_active() -> Sequence[ActiveBottle]` replaces the old `list_active() -> None` (which printed and returned nothing). Docker implements it via the existing compose query; smolmachines implements it via `smolvm machine ls --json` cross-referenced with each bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env (`backend/smolmachines/ enumerate.py`). - New `enumerate_active_bottles()` and `known_backend_names()` module-level helpers fold every backend into one call. - `get_bottle_backend(name=None)` takes an optional explicit name (precedence: arg > $CLAUDE_BOTTLE_BACKEND > "docker"). CLI: - `./cli.py list active` enumerates every backend, prints tab-separated `<backend>\t<slug>\t<agent>\t<services>`. The smolmachines bottle the user was looking for now shows up. - `./cli.py start` grows `--backend=<docker|smolmachines>` (choices pulled live from `known_backend_names()`). Threaded through `prepare_with_preflight(backend_name=...)` so the resume path picks up the flag too. Dashboard: - Active agents pane lists both backends (the row formatter now prefixes `[docker]` / `[smolmachines]`). - New-agent flow inserts a backend picker modal between agent pick and preflight (`_backend_picker_modal`). Short-circuits when only one backend is registered. - `discover_active_agents()` collapses to `enumerate_active_bottles()`; `_parse_services_by_project` and `_query_services_by_project` move to `backend/docker/cleanup.py` where the docker enumerator owns them. Tests: parser + enumerate-active tests relocated to `test_docker_enumerate_active.py`. New `test_backend_selection.py` covers `get_bottle_backend`, `known_backend_names`, `enumerate_active_bottles`. New `test_cli_start_backend_flag.py` covers `--backend`'s argparse shape + the explicit-over-env precedence. 605 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,12 +19,14 @@ backend exposes five methods:
|
||||
cleanup(plan) -> None
|
||||
Actually removes everything described by the cleanup plan.
|
||||
|
||||
list_active() -> None
|
||||
Print every currently-running bottle on this backend to stderr.
|
||||
enumerate_active() -> Sequence[ActiveBottle]
|
||||
Return every currently-running bottle on this backend, with
|
||||
enough metadata for callers (CLI `list active`, dashboard
|
||||
agents pane) to render a row.
|
||||
|
||||
Selection is driven by CLAUDE_BOTTLE_BACKEND (default "docker"). Per
|
||||
PRD 0003 the manifest does not carry a backend field; the host
|
||||
environment picks.
|
||||
Selection is driven by `--backend` on `start` or
|
||||
CLAUDE_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the
|
||||
manifest does not carry a backend field; the host picks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -103,6 +105,26 @@ class ExecResult:
|
||||
stderr: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveBottle:
|
||||
"""One currently-running bottle, as the CLI `list active` and
|
||||
dashboard agents pane render it.
|
||||
|
||||
Fields are deliberately backend-neutral. `services` is the set
|
||||
of sidecar daemons currently up for this bottle (`pipelock`,
|
||||
`egress`, `git-gate`, `supervise`); the dashboard uses it to
|
||||
gate edit verbs. `backend_name` is the matching key in
|
||||
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
|
||||
list rendering to disambiguate and by the dashboard's
|
||||
re-attach path."""
|
||||
|
||||
backend_name: str
|
||||
slug: str
|
||||
agent_name: str # from metadata.json; "?" if missing
|
||||
started_at: str # ISO 8601 from metadata.json; "" if missing
|
||||
services: tuple[str, ...] # alphabetical
|
||||
|
||||
|
||||
class Bottle(ABC):
|
||||
"""Handle to a running bottle. Yielded by a backend's launch step.
|
||||
|
||||
@@ -280,9 +302,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""Remove everything described by the cleanup plan."""
|
||||
|
||||
@abstractmethod
|
||||
def list_active(self) -> None:
|
||||
"""Print every currently-running bottle on this backend to
|
||||
stderr (name + status)."""
|
||||
def enumerate_active(self) -> Sequence[ActiveBottle]:
|
||||
"""Return every currently-running bottle on this backend.
|
||||
Empty when none. Backend-specific: docker queries `docker
|
||||
compose ls`; smolmachines queries `smolvm machine ls --json`
|
||||
+ cross-references its bundle container."""
|
||||
|
||||
|
||||
# Import concrete backend classes AFTER the base types are defined, so
|
||||
@@ -302,23 +326,52 @@ _BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def get_bottle_backend() -> BottleBackend[Any, Any]:
|
||||
"""Resolve the bottle backend for the active environment. Dies with
|
||||
a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an
|
||||
unimplemented one."""
|
||||
name = os.environ.get("CLAUDE_BOTTLE_BACKEND", "docker")
|
||||
if name not in _BACKENDS:
|
||||
def get_bottle_backend(
|
||||
name: str | None = None,
|
||||
) -> BottleBackend[Any, Any]:
|
||||
"""Resolve the bottle backend.
|
||||
|
||||
`name` precedence:
|
||||
1. explicit arg (CLI `--backend=<name>` passes through here)
|
||||
2. CLAUDE_BOTTLE_BACKEND env var
|
||||
3. default `docker`
|
||||
|
||||
Dies with a pointer at the known backends if the chosen name
|
||||
isn't implemented."""
|
||||
resolved = name or os.environ.get("CLAUDE_BOTTLE_BACKEND") or "docker"
|
||||
if resolved not in _BACKENDS:
|
||||
known = ", ".join(sorted(_BACKENDS))
|
||||
die(f"unknown CLAUDE_BOTTLE_BACKEND={name!r}; known backends: {known}")
|
||||
return _BACKENDS[name]
|
||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||
return _BACKENDS[resolved]
|
||||
|
||||
|
||||
def known_backend_names() -> tuple[str, ...]:
|
||||
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
||||
argparse (`--backend` choices) and the dashboard's backend
|
||||
picker."""
|
||||
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] = []
|
||||
for name in known_backend_names():
|
||||
out.extend(_BACKENDS[name].enumerate_active())
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActiveBottle",
|
||||
"Bottle",
|
||||
"BottleBackend",
|
||||
"BottleCleanupPlan",
|
||||
"BottlePlan",
|
||||
"BottleSpec",
|
||||
"ExecResult",
|
||||
"enumerate_active_bottles",
|
||||
"get_bottle_backend",
|
||||
"known_backend_names",
|
||||
]
|
||||
|
||||
@@ -14,9 +14,9 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from .. import BottleBackend, BottleSpec
|
||||
from .. import ActiveBottle, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import launch as _launch
|
||||
from . import prepare as _prepare
|
||||
@@ -65,5 +65,5 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
|
||||
_cleanup.cleanup(plan)
|
||||
|
||||
def list_active(self) -> None:
|
||||
_cleanup.list_active()
|
||||
def enumerate_active(self) -> Sequence[ActiveBottle]:
|
||||
return _cleanup.enumerate_active()
|
||||
|
||||
@@ -29,10 +29,16 @@ 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
|
||||
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _list_prefixed_containers() -> list[str]:
|
||||
@@ -161,24 +167,67 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
||||
warn(f"failed to remove {path}: {e}")
|
||||
|
||||
|
||||
def list_active() -> None:
|
||||
"""Print every active claude-bottle compose project + its
|
||||
services. Empty banner when there are none."""
|
||||
docker_mod.require_docker()
|
||||
active = list_compose_projects(include_stopped=False)
|
||||
if not active:
|
||||
info("no active claude-bottle compose projects")
|
||||
return
|
||||
print()
|
||||
for project in active:
|
||||
info(f"compose project: {project}")
|
||||
ps = subprocess.run(
|
||||
["docker", "compose", "-p", project, "ps", "--format",
|
||||
"{{.Service}}\t{{.Name}}\t{{.Status}}"],
|
||||
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
|
||||
`<project-label>\\t<service-label>` (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,
|
||||
)
|
||||
for line in (ps.stdout or "").splitlines():
|
||||
service, _, rest = line.partition("\t")
|
||||
name, _, status = rest.partition("\t")
|
||||
info(f" {service:12s} {name} ({status})")
|
||||
print()
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
if r.returncode != 0:
|
||||
return {}
|
||||
return _parse_services_by_project(r.stdout or "")
|
||||
|
||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from .. import BottleBackend, BottleSpec
|
||||
from .. import ActiveBottle, BottleBackend, BottleSpec
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
from . import prepare as _prepare
|
||||
from .bottle import SmolmachinesBottle
|
||||
@@ -73,9 +74,5 @@ class SmolmachinesBottleBackend(
|
||||
# Nothing to clean in chunks 1-3 — see
|
||||
# SmolmachinesBottleCleanupPlan docstring.
|
||||
|
||||
def list_active(self) -> None:
|
||||
from ...log import info
|
||||
info(
|
||||
"smolmachines list_active: not implemented (chunk 4 wires "
|
||||
"it to `smolvm machine ls --json`)"
|
||||
)
|
||||
def enumerate_active(self) -> Sequence[ActiveBottle]:
|
||||
return _enumerate.enumerate_active()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Active-bottle enumeration for the smolmachines backend (PRD
|
||||
0023 chunk 4 follow-up + issue #77).
|
||||
|
||||
Returns a list of `ActiveBottle` 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
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from .. import ActiveBottle
|
||||
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-<slug>`,
|
||||
# matching the bundle container name pattern. We use the prefix
|
||||
# both as a filter and to strip back to the slug.
|
||||
_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 []
|
||||
result = subprocess.run(
|
||||
["smolvm", "machine", "ls", "--json"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
machines = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
services_by_slug = _query_bundle_services()
|
||||
out: list[ActiveBottle] = []
|
||||
for m in machines:
|
||||
name = m.get("name") or ""
|
||||
state = m.get("state") or ""
|
||||
if state != "running" or not name.startswith(_VM_NAME_PREFIX):
|
||||
continue
|
||||
slug = name[len(_VM_NAME_PREFIX):]
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveBottle(
|
||||
backend_name="smolmachines",
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=services_by_slug.get(slug, ()),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
||||
"""`{slug: ('egress', 'pipelock', ...)}` from each running
|
||||
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:
|
||||
return {}
|
||||
ps = subprocess.run(
|
||||
["docker", "ps",
|
||||
"--filter", "name=" + _bundle.bundle_container_name(""),
|
||||
"--format", "{{.Names}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if ps.returncode != 0:
|
||||
return {}
|
||||
out: dict[str, tuple[str, ...]] = {}
|
||||
for line in (ps.stdout or "").splitlines():
|
||||
name = line.strip()
|
||||
if not name:
|
||||
continue
|
||||
slug = name.removeprefix(_bundle.bundle_container_name(""))
|
||||
if not slug:
|
||||
continue
|
||||
inspect = subprocess.run(
|
||||
["docker", "inspect", name, "--format", "{{json .Config.Env}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if inspect.returncode != 0:
|
||||
continue
|
||||
try:
|
||||
env_list = json.loads(inspect.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for entry in env_list:
|
||||
key, _, value = entry.partition("=")
|
||||
if key == "CLAUDE_BOTTLE_SIDECAR_DAEMONS":
|
||||
out[slug] = tuple(sorted(
|
||||
d for d in value.split(",") if d
|
||||
))
|
||||
break
|
||||
return out
|
||||
Reference in New Issue
Block a user