af82f2ba20
Both docker and smolmachines backends use bottle state helpers. Moving to bot_bottle/ makes the sharing explicit and removes the cross-backend dependency (smolmachines importing from ..docker). All callers updated: docker backend, smolmachines backend, cli modules, and tests.
124 lines
4.5 KiB
Python
124 lines
4.5 KiB
Python
"""Active-agent enumeration for the smolmachines backend (PRD
|
|
0023 chunk 4 follow-up + issue #77).
|
|
|
|
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 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.
|
|
|
|
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 subprocess
|
|
|
|
from .. import ActiveAgent
|
|
from ...bottle_state import read_metadata
|
|
from . import sidecar_bundle as _bundle
|
|
|
|
|
|
# Smolvm VM names produced by prepare are `bot-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 = "bot-bottle-"
|
|
|
|
|
|
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,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
try:
|
|
machines = json.loads(result.stdout or "[]")
|
|
except json.JSONDecodeError:
|
|
return []
|
|
services_by_slug = _query_bundle_services()
|
|
out: list[ActiveAgent] = []
|
|
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(ActiveAgent(
|
|
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, ()),
|
|
label=metadata.label if metadata else "",
|
|
color=metadata.color if metadata else "",
|
|
))
|
|
return out
|
|
|
|
|
|
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
|
|
"""`{slug: ('egress', ...)}` from each running bundle container's
|
|
`BOT_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.
|
|
|
|
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",
|
|
"--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 == "BOT_BOTTLE_SIDECAR_DAEMONS":
|
|
out[slug] = tuple(sorted(
|
|
d for d in value.split(",") if d
|
|
))
|
|
break
|
|
return out
|