"""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-`, # 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