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:
@@ -0,0 +1,93 @@
|
||||
"""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`
|
||||
listings — the CLI and dashboard both go through this so adding
|
||||
a backend lights it up in both places."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from claude_bottle import backend as backend_mod
|
||||
from claude_bottle.backend import (
|
||||
ActiveBottle,
|
||||
enumerate_active_bottles,
|
||||
get_bottle_backend,
|
||||
known_backend_names,
|
||||
)
|
||||
|
||||
|
||||
class TestGetBottleBackend(unittest.TestCase):
|
||||
def test_explicit_name_wins_over_env(self):
|
||||
with patch.dict(os.environ, {"CLAUDE_BOTTLE_BACKEND": "smolmachines"}):
|
||||
b = get_bottle_backend("docker")
|
||||
self.assertEqual("docker", b.name)
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {"CLAUDE_BOTTLE_BACKEND": "smolmachines"}):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("smolmachines", b.name)
|
||||
|
||||
def test_default_docker(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
b = get_bottle_backend()
|
||||
self.assertEqual("docker", b.name)
|
||||
|
||||
def test_unknown_dies(self):
|
||||
with patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_bottle_backend("nonexistent")
|
||||
|
||||
|
||||
class TestKnownBackendNames(unittest.TestCase):
|
||||
def test_returns_both_backends_sorted(self):
|
||||
self.assertEqual(("docker", "smolmachines"), known_backend_names())
|
||||
|
||||
|
||||
class TestEnumerateActiveBottles(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(
|
||||
backend_name="docker", slug="a-1", agent_name="impl",
|
||||
started_at="", services=("pipelock",),
|
||||
)
|
||||
b = ActiveBottle(
|
||||
backend_name="smolmachines", slug="b-2", agent_name="research",
|
||||
started_at="", services=(),
|
||||
)
|
||||
|
||||
class _FakeBackend:
|
||||
def __init__(self, items):
|
||||
self._items = items
|
||||
|
||||
def enumerate_active(self):
|
||||
return self._items
|
||||
|
||||
with patch.object(
|
||||
backend_mod, "_BACKENDS",
|
||||
{"docker": _FakeBackend([a]), "smolmachines": _FakeBackend([b])},
|
||||
):
|
||||
self.assertEqual([a, b], enumerate_active_bottles())
|
||||
|
||||
def test_empty_when_no_backends_have_active(self):
|
||||
class _FakeBackend:
|
||||
def enumerate_active(self):
|
||||
return []
|
||||
|
||||
with patch.object(
|
||||
backend_mod, "_BACKENDS",
|
||||
{"docker": _FakeBackend(), "smolmachines": _FakeBackend()},
|
||||
):
|
||||
self.assertEqual([], enumerate_active_bottles())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Unit: `cli.py start --backend=<name>` flag (issue #77).
|
||||
|
||||
Asserts that the flag wins over the env var, that the env var is
|
||||
the fallback, and that the choices are pulled from the backend
|
||||
registry (so adding a backend lights up in argparse without code
|
||||
edits)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
from claude_bottle.backend import known_backend_names
|
||||
|
||||
|
||||
class TestStartBackendFlag(unittest.TestCase):
|
||||
"""The flag is wired by `cmd_start`'s argparse and threaded
|
||||
through `prepare_with_preflight(backend_name=...)`. Rather than
|
||||
drive the whole start flow (which builds containers), we test
|
||||
the argparse shape and the resolution function separately."""
|
||||
|
||||
def _build_parser(self):
|
||||
# Mirror the parser definition from `cmd_start` so this
|
||||
# test doesn't have to invoke the full command.
|
||||
parser = argparse.ArgumentParser(prog="cb start")
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("name")
|
||||
return parser
|
||||
|
||||
def test_flag_recognized(self):
|
||||
args = self._build_parser().parse_args(["--backend=smolmachines", "researcher"])
|
||||
self.assertEqual("smolmachines", args.backend)
|
||||
self.assertEqual("researcher", args.name)
|
||||
|
||||
def test_flag_default_none_means_env_or_docker(self):
|
||||
args = self._build_parser().parse_args(["researcher"])
|
||||
self.assertIsNone(args.backend)
|
||||
|
||||
def test_invalid_backend_rejected_by_argparse(self):
|
||||
parser = self._build_parser()
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["--backend=garbage", "researcher"])
|
||||
|
||||
def test_resolution_priority_explicit_over_env(self):
|
||||
# Independent assertion that get_bottle_backend (where
|
||||
# `--backend` ultimately threads to) prefers the explicit
|
||||
# name over CLAUDE_BOTTLE_BACKEND.
|
||||
from claude_bottle.backend import get_bottle_backend
|
||||
with patch.dict(os.environ, {"CLAUDE_BOTTLE_BACKEND": "smolmachines"}):
|
||||
self.assertEqual("docker", get_bottle_backend("docker").name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,18 +1,10 @@
|
||||
"""Unit: dashboard.discover_active_agents (PRD 0019 chunk 1).
|
||||
"""Unit: dashboard's row-formatting + selection helpers (PRD 0019).
|
||||
|
||||
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.
|
||||
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
|
||||
and assembly tests live next to the docker backend's
|
||||
implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,54 +14,9 @@ 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.")
|
||||
@@ -86,97 +33,12 @@ class _FakeHomeMixin:
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
class TestFormatAgentRow(unittest.TestCase):
|
||||
"""One-line row formatting for the agents pane (PRD 0019 chunk 2)."""
|
||||
|
||||
def _agent(self, **overrides) -> dashboard.ActiveAgent:
|
||||
defaults = dict(
|
||||
backend_name="docker",
|
||||
slug="dev-abc12",
|
||||
agent_name="implementer",
|
||||
started_at="2026-05-26T02:55:01+00:00",
|
||||
@@ -232,6 +94,7 @@ class TestSelectionStatus(unittest.TestCase):
|
||||
|
||||
def _agent(self, slug: str) -> dashboard.ActiveAgent:
|
||||
return dashboard.ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=slug, agent_name="x", started_at="", services=(),
|
||||
)
|
||||
|
||||
@@ -295,6 +158,7 @@ class TestRunningCounts(unittest.TestCase):
|
||||
|
||||
def _agent(self, agent_name: str) -> dashboard.ActiveAgent:
|
||||
return dashboard.ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=f"{agent_name}-abc",
|
||||
agent_name=agent_name,
|
||||
started_at="",
|
||||
@@ -329,6 +193,7 @@ class TestSelectedAgent(unittest.TestCase):
|
||||
|
||||
def _agent(self, slug: str, services: tuple[str, ...] = ()) -> dashboard.ActiveAgent:
|
||||
return dashboard.ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=slug, agent_name="x", started_at="", services=services,
|
||||
)
|
||||
|
||||
@@ -387,6 +252,7 @@ class TestPickNextAfterStop(unittest.TestCase):
|
||||
|
||||
def _agent(self, slug: str) -> dashboard.ActiveAgent:
|
||||
return dashboard.ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=slug, agent_name=slug, started_at="", services=(),
|
||||
)
|
||||
|
||||
@@ -579,6 +445,7 @@ class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
|
||||
|
||||
def _agent(self, services: tuple[str, ...]) -> dashboard.ActiveAgent:
|
||||
return dashboard.ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug="dev-abc12",
|
||||
agent_name="impl",
|
||||
started_at="",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Unit: docker backend's `enumerate_active` (issue #77).
|
||||
|
||||
The full enumerate 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 `ActiveBottle` 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. Tests moved out of `test_dashboard_active_agents.py` as part
|
||||
of issue #77 — the dashboard now delegates to this layer.
|
||||
"""
|
||||
|
||||
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, cleanup
|
||||
|
||||
|
||||
class TestParseServicesByProject(unittest.TestCase):
|
||||
def test_empty_input(self):
|
||||
self.assertEqual({}, cleanup._parse_services_by_project(""))
|
||||
|
||||
def test_one_container(self):
|
||||
out = cleanup._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(
|
||||
"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 = cleanup._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 = cleanup._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="enum-active.")
|
||||
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 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
def test_no_active_slugs_returns_empty(self):
|
||||
self._stub([], {})
|
||||
self.assertEqual([], cleanup.enumerate_active())
|
||||
|
||||
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"}},
|
||||
)
|
||||
active = cleanup.enumerate_active()
|
||||
self.assertEqual(1, len(active))
|
||||
a = active[0]
|
||||
self.assertEqual("docker", a.backend_name)
|
||||
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"}})
|
||||
active = cleanup.enumerate_active()
|
||||
self.assertEqual(1, len(active))
|
||||
self.assertEqual("?", active[0].agent_name)
|
||||
self.assertEqual("", active[0].started_at)
|
||||
self.assertEqual(("pipelock",), active[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"], {})
|
||||
active = cleanup.enumerate_active()
|
||||
self.assertEqual((), active[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"], {})
|
||||
active = cleanup.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())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user