adff1263d8
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>
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""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
|