diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 8736567..4177e88 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -46,7 +46,7 @@ from ...git_gate import ( revoke_git_gate_provisioned_keys, ) from ...log import info, warn -from ...bottle_state import ( +from ...bottle.state import ( egress_state_dir, git_gate_state_dir, read_committed_image, diff --git a/bot_bottle/bottle/__init__.py b/bot_bottle/bottle/__init__.py new file mode 100644 index 0000000..52026bc --- /dev/null +++ b/bot_bottle/bottle/__init__.py @@ -0,0 +1,11 @@ +"""Bottle lifecycle primitives.""" + +from .runner import BottleError, destroy, freeze, resume_headless, start_headless + +__all__ = [ + "BottleError", + "destroy", + "freeze", + "resume_headless", + "start_headless", +] diff --git a/bot_bottle/bottle/runner.py b/bot_bottle/bottle/runner.py new file mode 100644 index 0000000..201d228 --- /dev/null +++ b/bot_bottle/bottle/runner.py @@ -0,0 +1,258 @@ +"""Public Python API for programmatic bottle orchestration. + +Stable surface for bot-bottle-orchestrator (and other Python callers) to +drive bottles without invoking the CLI as a subprocess. Every function +converts ``Die`` and non-zero agent exit codes to ``BottleError`` so +callers use exception handling rather than inspecting return values. + +The Protocol the orchestrator's ``BottleRunner`` targets looks like:: + + class BottleRunner(Protocol): + def start(self, agent: str, *, prompt: str, ...) -> str: ... + def resume(self, slug: str, *, prompt: str) -> None: ... + def freeze(self, slug: str) -> None: ... + def destroy(self, slug: str) -> None: ... + +A ``SubprocessBottleRunner`` calls ``./cli.py`` for each operation. A +``ProgrammaticBottleRunner`` calls these functions directly; the Protocol +call sites in ``lifecycle.py`` are unchanged. +""" + +from __future__ import annotations + +from typing import Sequence, cast + +from ..backend import BottleSpec +from ..backend.freeze import CommitCancelled, get_freezer +from .state import cleanup_state, clear_preserve_marker, read_metadata +from ..cli._common import USER_CWD +from ..cli.start import _launch_bottle, _peek_agent_bottle, _uniquify_label_headless +from ..log import Die +from ..manifest import ManifestError, ManifestIndex + + +class BottleError(Exception): + """Raised when a bottle operation fails. + + ``exit_code`` carries the agent process's exit code when the failure is + a non-zero agent exit; 1 for all other failure modes (missing state, + backend errors, etc.).""" + + def __init__(self, message: str, *, exit_code: int = 1) -> None: + super().__init__(message) + self.exit_code = exit_code + + +def start_headless( + agent_name: str, + *, + prompt: str, + bottles: Sequence[str] | None = None, + label: str | None = None, + color: str | None = None, + backend_name: str | None = None, + copy_cwd: bool = False, + forge_env: dict[str, str] | None = None, + user_cwd: str | None = None, +) -> str: + """Launch a new bottle headlessly. Returns the bottle slug. + + ``forge_env`` is passed through to the forge sidecar (not the agent) + when the bottle is forge-targeted; it carries the credentials and + context the sidecar needs to call the forge API. + + Raises ``BottleError`` on configuration errors or if the agent exits + non-zero. The returned slug can be passed to ``freeze()``, + ``resume_headless()``, or ``destroy()`` for subsequent lifecycle + operations.""" + cwd = user_cwd or USER_CWD + try: + manifest = ManifestIndex.resolve(cwd) + manifest.require_agent(agent_name) + except (Die, ManifestError) as exc: + raise BottleError(str(exc)) from exc + + if bottles: + bottle_names: tuple[str, ...] = tuple(bottles) + else: + default_bottle = _peek_agent_bottle(manifest, agent_name) + if not default_bottle: + raise BottleError( + f"agent '{agent_name}' has no default bottle; " + f"pass bottles=[...]" + ) + bottle_names = (default_bottle,) + + spec = BottleSpec( + manifest=manifest, + agent_name=agent_name, + copy_cwd=copy_cwd, + user_cwd=cwd, + label=_uniquify_label_headless(label or agent_name), + color=color or "", + bottle_names=bottle_names, + forge_env=dict(forge_env) if forge_env else {}, + ) + try: + slug, exit_code = _launch_bottle( + spec, + dry_run=False, + backend_name=backend_name, + assume_yes=True, + headless_prompt_text=prompt, + ) + except Die as exc: + raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc + if exit_code != 0: + raise BottleError( + f"agent exited {exit_code} (slug={slug!r})", exit_code=exit_code + ) + return slug + + +def resume_headless( + slug: str, + *, + prompt: str, + backend_name: str | None = None, + forge_env: dict[str, str] | None = None, +) -> None: + """Resume a frozen bottle headlessly with ``prompt``. + + ``forge_env`` re-supplies forge context for the new session (the + sidecar is relaunched alongside the agent on resume). + + Raises ``BottleError`` on missing state, backend errors, or non-zero + agent exit.""" + metadata = read_metadata(slug) + if metadata is None: + raise BottleError( + f"no state recorded for slug {slug!r}; " + f"check ~/.bot-bottle/state/ or call start_headless() to create a new bottle" + ) + + try: + manifest = ManifestIndex.resolve(metadata.cwd or USER_CWD) + manifest.require_agent(metadata.agent_name) + except (Die, ManifestError) as exc: + raise BottleError(str(exc)) from exc + + spec = BottleSpec( + manifest=manifest, + agent_name=metadata.agent_name, + copy_cwd=metadata.copy_cwd, + user_cwd=metadata.cwd or USER_CWD, + identity=metadata.identity, + bottle_names=tuple(metadata.bottle_names), + forge_env=dict(forge_env) if forge_env else {}, + ) + try: + _, exit_code = _launch_bottle( + spec, + dry_run=False, + backend_name=backend_name or metadata.backend or None, + assume_yes=True, + headless_prompt_text=prompt, + ) + except Die as exc: + raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc + if exit_code != 0: + raise BottleError( + f"agent exited {exit_code} resuming {slug!r}", exit_code=exit_code + ) + + +def freeze(slug: str, *, backend_name: str | None = None) -> None: + """Freeze the named bottle to a resumable artifact. + + Reads the bottle's backend from its metadata when ``backend_name`` is + not supplied. Raises ``BottleError`` if the freeze fails.""" + metadata = read_metadata(slug) + resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker" + try: + get_freezer(resolved_backend).commit_slug(slug) + except CommitCancelled as exc: + raise BottleError(f"freeze cancelled for {slug!r}") from exc + except Die as exc: + raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc + + +def destroy(slug: str, *, backend_name: str | None = None) -> None: + """Destroy the named bottle, removing all resources and state. + + Brings down any running resources for ``slug``, then removes the + per-bottle state directory. Idempotent: a slug with no running + resources or no state directory is not an error.""" + metadata = read_metadata(slug) + resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker" + try: + if resolved_backend == "docker": + _destroy_docker(slug) + elif resolved_backend == "smolmachines": + _destroy_smolmachines(slug) + # macos-container: the container is torn down inside the launch + # context manager; no persistent VM survives, so nothing extra is + # needed at destroy time beyond the state-dir removal below. + except Die as exc: + raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc + clear_preserve_marker(slug) + cleanup_state(slug) + + +# --- backend-specific helpers ----------------------------------------------- + + +def _destroy_docker(slug: str) -> None: + """Best-effort ``docker compose down`` for a Docker bottle. + + No-op when the compose file is absent — the project was already + brought down (normal for a frozen bottle) or was never created.""" + from ..backend.docker.compose import ( + compose_down, + compose_file_path, + compose_project_name, + ) + from .state import bottle_state_dir + + state_dir = bottle_state_dir(slug) + compose_file = compose_file_path(state_dir) + if compose_file.exists(): + compose_down(compose_project_name(slug), compose_file) + + +def _destroy_smolmachines(slug: str) -> None: + """Best-effort stop + delete for a smolmachines bottle. + + Both steps are best-effort: a machine that is already gone does not + cause an error; partial failures are logged as warnings.""" + import subprocess + + from ..log import warn + + machine = f"bot-bottle-{slug}" + subprocess.run( + ["smolvm", "machine", "stop", "--name", machine], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + r = subprocess.run( + ["smolvm", "machine", "delete", "-f", machine], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + warn( + f"smolvm machine delete -f {machine!r} failed " + f"(may already be gone): {(r.stderr or '').strip()}" + ) + + +__all__ = [ + "BottleError", + "destroy", + "freeze", + "resume_headless", + "start_headless", +] diff --git a/bot_bottle/bottle/state.py b/bot_bottle/bottle/state.py new file mode 100644 index 0000000..527ec02 --- /dev/null +++ b/bot_bottle/bottle/state.py @@ -0,0 +1,364 @@ +"""Per-bottle persistent state. + +Holds optional per-bottle Dockerfile overrides, the transcript snapshot +the state-preservation helper saves before teardown, and the launch metadata that lets +`cli.py resume ` reconstruct a bottle's spec. State +lives at: + + ~/.bot-bottle/state// + metadata.json — agent_name + cwd + started_at (for resume) + Dockerfile — per-bottle override (absent → use repo's) + transcript/ — last snapshotted agent state (best-effort) + +When the per-bottle Dockerfile is present, the launch step builds +the agent image with a per-bottle tag (bot-bottle-rebuilt-) +from this file rather than the repo's. The build context is still +the repo root so the Dockerfile can COPY bot_bottle source files +the same way the original does. + +Identity model: +- Every `cli.py start ` mints a fresh identity via + `bottle_identity(agent_name)`: slug-prefix for readability plus a + 5-char random suffix for parallel-safe uniqueness. The metadata + written at launch time pins (agent_name, cwd) to that identity. +- `cli.py resume ` reads the metadata and re-launches a + bottle pinned to the same identity, picking up any per-bottle + Dockerfile and transcript snapshot. +""" + +from __future__ import annotations + +import dataclasses +import json +import secrets +import string +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from .. import supervise as _supervise + + +# Directory layout: ~/.bot-bottle/state//... +_STATE_SUBDIR = "state" +_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" +_COMMITTED_IMAGE_NAME = "committed-image" +_TRANSCRIPT_SUBDIR = "transcript" +# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources +# live here so chunk 3's `docker compose up` can find them at stable +# paths. Each sidecar's `prepare()` writes config + CAs into its own +# subdir; the launch step is unchanged today (still `docker cp`). +_EGRESS_SUBDIR = "egress" +_GIT_GATE_SUBDIR = "git-gate" +_SUPERVISE_SUBDIR = "supervise" +_AGENT_SUBDIR = "agent" +_METADATA_NAME = "metadata.json" +# Live-config dir bind-mounted into the supervise sidecar (read-only). +# Host's apply paths keep these files fresh so supervise's +# `list-egress-routes` MCP tool returns the current state — +# not a snapshot from launch time. +_LIVE_CONFIG_SUBDIR = "live-config" +LIVE_CONFIG_ROUTES_NAME = "routes.yaml" +LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" +# Empty marker file. Session preservation writes it before teardown so +# cli.py's session-end cleanup knows to preserve the state dir for +# `cli.py resume `. Absent = clean up. +_PRESERVE_MARKER = ".preserve" + +# 5 chars of base36 alphabet ≈ 60M combinations. Plenty for human +# operators starting bottles by hand; collision-free in practice. +_RANDOM_SUFFIX_LEN = 5 +_SUFFIX_ALPHABET = string.ascii_lowercase + string.digits + + +def bottle_identity(agent_name: str) -> str: + """Mint a fresh per-launch bottle identity. The slug-prefix is + `slugify(agent_name)` for readability; the suffix is 5 random + base36 chars so two simultaneous `start ` invocations + don't collide on container/network names. + + Every call produces a different identity (non-deterministic). + To continue an existing bottle's state, use the recorded + identity from BottleMetadata via `cli.py resume `, + not this function.""" + from ..backend.docker import util as docker_mod + slug = docker_mod.slugify(agent_name) + suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN)) + return f"{slug}-{suffix}" + + +@dataclass(frozen=True) +class BottleMetadata: + """Persistent record of how a bottle was launched, written at + start time and read by `cli.py resume`. Lives at + ~/.bot-bottle/state//metadata.json.""" + + identity: str + agent_name: str + cwd: str # empty string when --cwd was not passed + copy_cwd: bool + started_at: str # ISO 8601 UTC + # PRD 0018 chunk 3: derivable from identity via + # `compose_project_name(identity)`, but persisted explicitly so + # dashboard / cleanup / resume tooling can read it without + # importing the compose module. Empty string for state dirs + # written before chunk 3 (resume / inspect should fall back to + # deriving from identity in that case). + compose_project: str = "" + # PRD 0040: backend name ("docker" or "smolmachines"). Empty string + # for state dirs written before PRD 0040; callers default to "docker" + # for backward compatibility. + backend: str = "" + label: str = "" + color: str = "" + # Ordered bottle names selected at launch (issue #269). Empty tuple + # for state dirs written before this change; resume falls back to + # the agent's `bottle:` field in that case. + bottle_names: tuple[str, ...] = () + + +def metadata_path(identity: str) -> Path: + return bottle_state_dir(identity) / _METADATA_NAME + + +def write_metadata(metadata: BottleMetadata) -> Path: + """Persist `metadata` to ~/.bot-bottle/state//metadata.json. + Mode 0o644 — no secrets, just (agent_name, cwd, timestamp).""" + path = metadata_path(metadata.identity) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(dataclasses.asdict(metadata), indent=2) + "\n") + path.chmod(0o644) + return path + + +def read_metadata(identity: str) -> BottleMetadata | None: + """Return the metadata for `identity`, or None if no state has + been recorded for it. Used by `cli.py resume` to reconstruct + the launch spec.""" + path = metadata_path(identity) + if not path.is_file(): + return None + raw = json.loads(path.read_text()) + if not isinstance(raw, dict): + return None + raw_typed = cast(dict[str, object], raw) + raw_bottle_names = raw_typed.get("bottle_names", []) + bottle_names: tuple[str, ...] = () + if isinstance(raw_bottle_names, list): + bottle_names = tuple(str(n) for n in raw_bottle_names if isinstance(n, str)) + return BottleMetadata( + identity=str(raw_typed.get("identity", identity)), + agent_name=str(raw_typed.get("agent_name", "")), + cwd=str(raw_typed.get("cwd", "")), + copy_cwd=bool(raw_typed.get("copy_cwd", False)), + started_at=str(raw_typed.get("started_at", "")), + compose_project=str(raw_typed.get("compose_project", "")), + backend=str(raw_typed.get("backend", "")), + label=str(raw_typed.get("label", "")), + color=str(raw_typed.get("color", "")), + bottle_names=bottle_names, + ) + + +def bottle_state_dir(identity: str) -> Path: + """Per-bottle state directory on the host. Created lazily by the + write helpers; readers tolerate its absence.""" + return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity + + +def per_bottle_dockerfile_path(identity: str) -> Path: + return bottle_state_dir(identity) / _PER_BOTTLE_DOCKERFILE_NAME + + +def per_bottle_dockerfile(identity: str) -> str | None: + """Return the per-bottle Dockerfile content if present, else + None. None means: use the provider or manifest Dockerfile.""" + p = per_bottle_dockerfile_path(identity) + if p.is_file(): + return p.read_text() + return None + + +def write_per_bottle_dockerfile(identity: str, content: str) -> Path: + p = per_bottle_dockerfile_path(identity) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + p.chmod(0o644) + return p + + +def committed_image_path(identity: str) -> Path: + return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME + + +def write_committed_image(identity: str, image_tag: str) -> Path: + """Persist the committed image tag for `identity`. The next + `cli.py resume ` will boot from this image instead of + rebuilding from the Dockerfile.""" + path = committed_image_path(identity) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(image_tag.strip() + "\n") + path.chmod(0o644) + return path + + +def read_committed_image(identity: str) -> str | None: + """Return the committed image tag for `identity`, or None if no + commit has been recorded. Used by the Docker launch step to skip + the Dockerfile build when a committed snapshot exists.""" + path = committed_image_path(identity) + if not path.is_file(): + return None + tag = path.read_text().strip() + return tag or None + + +def per_bottle_image_tag(identity: str) -> str: + """Image tag for a rebuilt bottle. Distinct from the base + bot-bottle-claude:latest so per-bottle rebuilds don't collide in + the docker image cache.""" + return f"bot-bottle-rebuilt-{identity}:latest" + + +def live_config_dir(identity: str) -> Path: + """Per-bottle live-config dir. Bind-mounted read-only into the + supervise sidecar; the host's apply paths refresh the files on + every operator approval so the agent's `list-*` MCP tools always + return current state.""" + return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR + + +def live_routes_path(identity: str) -> Path: + return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME + + +def live_allowlist_path(identity: str) -> Path: + return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME + + +def write_live_config( + identity: str, *, routes: str = "", allowlist: str = "", +) -> Path: + """Initialise (or refresh) the live-config dir. Empty-string args + leave the existing file alone (caller passes only what it knows). + Returns the live-config dir path.""" + d = live_config_dir(identity) + d.mkdir(parents=True, exist_ok=True) + if routes: + p = live_routes_path(identity) + p.write_text(routes) + p.chmod(0o644) + if allowlist: + p = live_allowlist_path(identity) + p.write_text(allowlist) + p.chmod(0o644) + return d + + +def transcript_snapshot_dir(identity: str) -> Path: + """Where agent session snapshots are kept for resume flows.""" + return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR + + +# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------ +# +# Each sidecar gets its own subdir under the bottle's state dir for +# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes +# land here; the state dir's normal cleanup (`cleanup_state`) reaps +# them along with everything else when the bottle session ends and +# nothing requested preservation. + + +def egress_state_dir(identity: str) -> Path: + """State subdir for the egress sidecar: routes.yaml + the + per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" + return bottle_state_dir(identity) / _EGRESS_SUBDIR + + +def git_gate_state_dir(identity: str) -> Path: + """State subdir for the git-gate sidecar: entrypoint + hooks + + per-upstream known_hosts. Bind-mount source from chunk 3 + onward.""" + return bottle_state_dir(identity) / _GIT_GATE_SUBDIR + + +def supervise_state_dir(identity: str) -> Path: + """State subdir reserved for supervise sidecar bind-mount sources. + The queue dir is intentionally NOT under here — it lives at + ~/.bot-bottle/queue// alongside the audit logs, so it + survives state-dir cleanup.""" + return bottle_state_dir(identity) / _SUPERVISE_SUBDIR + + +def agent_state_dir(identity: str) -> Path: + """State subdir for the agent's prepare-time scratch files: the + env file (docker --env-file source) and the prompt file.""" + return bottle_state_dir(identity) / _AGENT_SUBDIR + + +# --- Preserve-on-close marker ---------------------------------------------- + + +def preserve_marker_path(identity: str) -> Path: + return bottle_state_dir(identity) / _PRESERVE_MARKER + + +def mark_preserved(identity: str) -> Path: + """Mark this bottle's state for preservation across session + teardown so cli.py's session-end cleanup leaves the state dir + intact for a subsequent `cli.py resume`.""" + path = preserve_marker_path(identity) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +def is_preserved(identity: str) -> bool: + return preserve_marker_path(identity).exists() + + +def clear_preserve_marker(identity: str) -> None: + """Idempotent removal. Called at fresh launch (start or resume) + so a marker left from a prior preserved session doesn't keep + state alive past the next normal session-end.""" + try: + preserve_marker_path(identity).unlink() + except FileNotFoundError: + pass + + +def cleanup_state(identity: str) -> None: + """Remove the per-bottle state dir entirely. Called by cli.py + when a bottle session ends and is_preserved(identity) is False. + Idempotent — missing dir is success.""" + import shutil + state_dir = bottle_state_dir(identity) + if state_dir.is_dir(): + shutil.rmtree(state_dir, ignore_errors=True) + + +__all__ = [ + "BottleMetadata", + "agent_state_dir", + "bottle_identity", + "bottle_state_dir", + "cleanup_state", + "clear_preserve_marker", + "committed_image_path", + "egress_state_dir", + "git_gate_state_dir", + "is_preserved", + "mark_preserved", + "metadata_path", + "per_bottle_dockerfile", + "per_bottle_dockerfile_path", + "per_bottle_image_tag", + "preserve_marker_path", + "read_committed_image", + "read_metadata", + "supervise_state_dir", + "transcript_snapshot_dir", + "write_committed_image", + "write_metadata", + "write_per_bottle_dockerfile", +] diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index 7eb3316..58540c0 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -1,363 +1,6 @@ -"""Per-bottle persistent state. +"""Compatibility wrapper for per-bottle persistent state. -Holds optional per-bottle Dockerfile overrides, the transcript snapshot -the state-preservation helper saves before teardown, and the launch metadata that lets -`cli.py resume ` reconstruct a bottle's spec. State -lives at: - - ~/.bot-bottle/state// - metadata.json — agent_name + cwd + started_at (for resume) - Dockerfile — per-bottle override (absent → use repo's) - transcript/ — last snapshotted agent state (best-effort) - -When the per-bottle Dockerfile is present, the launch step builds -the agent image with a per-bottle tag (bot-bottle-rebuilt-) -from this file rather than the repo's. The build context is still -the repo root so the Dockerfile can COPY bot_bottle source files -the same way the original does. - -Identity model: -- Every `cli.py start ` mints a fresh identity via - `bottle_identity(agent_name)`: slug-prefix for readability plus a - 5-char random suffix for parallel-safe uniqueness. The metadata - written at launch time pins (agent_name, cwd) to that identity. -- `cli.py resume ` reads the metadata and re-launches a - bottle pinned to the same identity, picking up any per-bottle - Dockerfile and transcript snapshot. +The implementation lives in :mod:`bot_bottle.bottle.state`. """ -from __future__ import annotations - -import dataclasses -import json -import secrets -import string -from dataclasses import dataclass -from pathlib import Path -from typing import cast - -from . import supervise as _supervise - - -# Directory layout: ~/.bot-bottle/state//... -_STATE_SUBDIR = "state" -_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" -_COMMITTED_IMAGE_NAME = "committed-image" -_TRANSCRIPT_SUBDIR = "transcript" -# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources -# live here so chunk 3's `docker compose up` can find them at stable -# paths. Each sidecar's `prepare()` writes config + CAs into its own -# subdir; the launch step is unchanged today (still `docker cp`). -_EGRESS_SUBDIR = "egress" -_GIT_GATE_SUBDIR = "git-gate" -_SUPERVISE_SUBDIR = "supervise" -_AGENT_SUBDIR = "agent" -_METADATA_NAME = "metadata.json" -# Live-config dir bind-mounted into the supervise sidecar (read-only). -# Host's apply paths keep these files fresh so supervise's -# `list-egress-routes` MCP tool returns the current state — -# not a snapshot from launch time. -_LIVE_CONFIG_SUBDIR = "live-config" -LIVE_CONFIG_ROUTES_NAME = "routes.yaml" -LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" -# Empty marker file. Session preservation writes it before teardown so -# cli.py's session-end cleanup knows to preserve the state dir for -# `cli.py resume `. Absent = clean up. -_PRESERVE_MARKER = ".preserve" - -# 5 chars of base36 alphabet ≈ 60M combinations. Plenty for human -# operators starting bottles by hand; collision-free in practice. -_RANDOM_SUFFIX_LEN = 5 -_SUFFIX_ALPHABET = string.ascii_lowercase + string.digits - - -def bottle_identity(agent_name: str) -> str: - """Mint a fresh per-launch bottle identity. The slug-prefix is - `slugify(agent_name)` for readability; the suffix is 5 random - base36 chars so two simultaneous `start ` invocations - don't collide on container/network names. - - Every call produces a different identity (non-deterministic). - To continue an existing bottle's state, use the recorded - identity from BottleMetadata via `cli.py resume `, - not this function.""" - from .backend.docker import util as docker_mod - slug = docker_mod.slugify(agent_name) - suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN)) - return f"{slug}-{suffix}" - - -@dataclass(frozen=True) -class BottleMetadata: - """Persistent record of how a bottle was launched, written at - start time and read by `cli.py resume`. Lives at - ~/.bot-bottle/state//metadata.json.""" - - identity: str - agent_name: str - cwd: str # empty string when --cwd was not passed - copy_cwd: bool - started_at: str # ISO 8601 UTC - # PRD 0018 chunk 3: derivable from identity via - # `compose_project_name(identity)`, but persisted explicitly so - # dashboard / cleanup / resume tooling can read it without - # importing the compose module. Empty string for state dirs - # written before chunk 3 (resume / inspect should fall back to - # deriving from identity in that case). - compose_project: str = "" - # PRD 0040: backend name ("docker" or "smolmachines"). Empty string - # for state dirs written before PRD 0040; callers default to "docker" - # for backward compatibility. - backend: str = "" - label: str = "" - color: str = "" - # Ordered bottle names selected at launch (issue #269). Empty tuple - # for state dirs written before this change; resume falls back to - # the agent's `bottle:` field in that case. - bottle_names: tuple[str, ...] = () - - -def metadata_path(identity: str) -> Path: - return bottle_state_dir(identity) / _METADATA_NAME - - -def write_metadata(metadata: BottleMetadata) -> Path: - """Persist `metadata` to ~/.bot-bottle/state//metadata.json. - Mode 0o644 — no secrets, just (agent_name, cwd, timestamp).""" - path = metadata_path(metadata.identity) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(dataclasses.asdict(metadata), indent=2) + "\n") - path.chmod(0o644) - return path - - -def read_metadata(identity: str) -> BottleMetadata | None: - """Return the metadata for `identity`, or None if no state has - been recorded for it. Used by `cli.py resume` to reconstruct - the launch spec.""" - path = metadata_path(identity) - if not path.is_file(): - return None - raw = json.loads(path.read_text()) - if not isinstance(raw, dict): - return None - raw_typed = cast(dict[str, object], raw) - raw_bottle_names = raw_typed.get("bottle_names", []) - bottle_names: tuple[str, ...] = () - if isinstance(raw_bottle_names, list): - bottle_names = tuple(str(n) for n in raw_bottle_names if isinstance(n, str)) - return BottleMetadata( - identity=str(raw_typed.get("identity", identity)), - agent_name=str(raw_typed.get("agent_name", "")), - cwd=str(raw_typed.get("cwd", "")), - copy_cwd=bool(raw_typed.get("copy_cwd", False)), - started_at=str(raw_typed.get("started_at", "")), - compose_project=str(raw_typed.get("compose_project", "")), - backend=str(raw_typed.get("backend", "")), - label=str(raw_typed.get("label", "")), - color=str(raw_typed.get("color", "")), - bottle_names=bottle_names, - ) - - -def bottle_state_dir(identity: str) -> Path: - """Per-bottle state directory on the host. Created lazily by the - write helpers; readers tolerate its absence.""" - return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity - - -def per_bottle_dockerfile_path(identity: str) -> Path: - return bottle_state_dir(identity) / _PER_BOTTLE_DOCKERFILE_NAME - - -def per_bottle_dockerfile(identity: str) -> str | None: - """Return the per-bottle Dockerfile content if present, else - None. None means: use the provider or manifest Dockerfile.""" - p = per_bottle_dockerfile_path(identity) - if p.is_file(): - return p.read_text() - return None - - -def write_per_bottle_dockerfile(identity: str, content: str) -> Path: - p = per_bottle_dockerfile_path(identity) - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(content) - p.chmod(0o644) - return p - - -def committed_image_path(identity: str) -> Path: - return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME - - -def write_committed_image(identity: str, image_tag: str) -> Path: - """Persist the committed image tag for `identity`. The next - `cli.py resume ` will boot from this image instead of - rebuilding from the Dockerfile.""" - path = committed_image_path(identity) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(image_tag.strip() + "\n") - path.chmod(0o644) - return path - - -def read_committed_image(identity: str) -> str | None: - """Return the committed image tag for `identity`, or None if no - commit has been recorded. Used by the Docker launch step to skip - the Dockerfile build when a committed snapshot exists.""" - path = committed_image_path(identity) - if not path.is_file(): - return None - tag = path.read_text().strip() - return tag or None - - -def per_bottle_image_tag(identity: str) -> str: - """Image tag for a rebuilt bottle. Distinct from the base - bot-bottle-claude:latest so per-bottle rebuilds don't collide in - the docker image cache.""" - return f"bot-bottle-rebuilt-{identity}:latest" - - -def live_config_dir(identity: str) -> Path: - """Per-bottle live-config dir. Bind-mounted read-only into the - supervise sidecar; the host's apply paths refresh the files on - every operator approval so the agent's `list-*` MCP tools always - return current state.""" - return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR - - -def live_routes_path(identity: str) -> Path: - return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME - - -def live_allowlist_path(identity: str) -> Path: - return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME - - -def write_live_config( - identity: str, *, routes: str = "", allowlist: str = "", -) -> Path: - """Initialise (or refresh) the live-config dir. Empty-string args - leave the existing file alone (caller passes only what it knows). - Returns the live-config dir path.""" - d = live_config_dir(identity) - d.mkdir(parents=True, exist_ok=True) - if routes: - p = live_routes_path(identity) - p.write_text(routes) - p.chmod(0o644) - if allowlist: - p = live_allowlist_path(identity) - p.write_text(allowlist) - p.chmod(0o644) - return d - - -def transcript_snapshot_dir(identity: str) -> Path: - """Where agent session snapshots are kept for resume flows.""" - return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR - - -# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------ -# -# Each sidecar gets its own subdir under the bottle's state dir for -# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes -# land here; the state dir's normal cleanup (`cleanup_state`) reaps -# them along with everything else when the bottle session ends and -# nothing requested preservation. - - -def egress_state_dir(identity: str) -> Path: - """State subdir for the egress sidecar: routes.yaml + the - per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" - return bottle_state_dir(identity) / _EGRESS_SUBDIR - - -def git_gate_state_dir(identity: str) -> Path: - """State subdir for the git-gate sidecar: entrypoint + hooks + - per-upstream known_hosts. Bind-mount source from chunk 3 - onward.""" - return bottle_state_dir(identity) / _GIT_GATE_SUBDIR - - -def supervise_state_dir(identity: str) -> Path: - """State subdir reserved for supervise sidecar bind-mount sources. - Runtime queue/audit rows live in the host-level bot-bottle SQLite - database, so they survive state-dir cleanup.""" - return bottle_state_dir(identity) / _SUPERVISE_SUBDIR - - -def agent_state_dir(identity: str) -> Path: - """State subdir for the agent's prepare-time scratch files: the - env file (docker --env-file source) and the prompt file.""" - return bottle_state_dir(identity) / _AGENT_SUBDIR - - -# --- Preserve-on-close marker ---------------------------------------------- - - -def preserve_marker_path(identity: str) -> Path: - return bottle_state_dir(identity) / _PRESERVE_MARKER - - -def mark_preserved(identity: str) -> Path: - """Mark this bottle's state for preservation across session - teardown so cli.py's session-end cleanup leaves the state dir - intact for a subsequent `cli.py resume`.""" - path = preserve_marker_path(identity) - path.parent.mkdir(parents=True, exist_ok=True) - path.touch() - return path - - -def is_preserved(identity: str) -> bool: - return preserve_marker_path(identity).exists() - - -def clear_preserve_marker(identity: str) -> None: - """Idempotent removal. Called at fresh launch (start or resume) - so a marker left from a prior preserved session doesn't keep - state alive past the next normal session-end.""" - try: - preserve_marker_path(identity).unlink() - except FileNotFoundError: - pass - - -def cleanup_state(identity: str) -> None: - """Remove the per-bottle state dir entirely. Called by cli.py - when a bottle session ends and is_preserved(identity) is False. - Idempotent — missing dir is success.""" - import shutil - state_dir = bottle_state_dir(identity) - if state_dir.is_dir(): - shutil.rmtree(state_dir, ignore_errors=True) - - -__all__ = [ - "BottleMetadata", - "agent_state_dir", - "bottle_identity", - "bottle_state_dir", - "cleanup_state", - "clear_preserve_marker", - "committed_image_path", - "egress_state_dir", - "git_gate_state_dir", - "is_preserved", - "mark_preserved", - "metadata_path", - "per_bottle_dockerfile", - "per_bottle_dockerfile_path", - "per_bottle_image_tag", - "preserve_marker_path", - "read_committed_image", - "read_metadata", - "supervise_state_dir", - "transcript_snapshot_dir", - "write_committed_image", - "write_metadata", - "write_per_bottle_dockerfile", -] +from .bottle.state import * # noqa: F403 diff --git a/bot_bottle/cli/commit.py b/bot_bottle/cli/commit.py index d724bcc..7e692d9 100644 --- a/bot_bottle/cli/commit.py +++ b/bot_bottle/cli/commit.py @@ -14,7 +14,7 @@ import argparse from ..backend import enumerate_active_agents from ..backend.freeze import CommitCancelled, get_freezer -from ..bottle_state import read_metadata +from ..bottle.state import read_metadata from ..log import die from ._common import PROG from . import tui diff --git a/bot_bottle/cli/resume.py b/bot_bottle/cli/resume.py index 6aaae28..c4a3adb 100644 --- a/bot_bottle/cli/resume.py +++ b/bot_bottle/cli/resume.py @@ -17,7 +17,7 @@ from __future__ import annotations import argparse from ..backend import BottleSpec -from ..bottle_state import read_metadata +from ..bottle.state import read_metadata from ..log import die from ..manifest import ManifestIndex from ._common import PROG, USER_CWD diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 2b95929..d707e1a 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -31,7 +31,7 @@ from ..backend import ( ) from ..backend.docker import util as docker_mod from ..backend.docker.bottle_plan import DockerBottlePlan -from ..bottle_state import ( +from ..bottle.state import ( cleanup_state, is_preserved, mark_preserved, diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index 6f341bd..5e78289 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -20,7 +20,7 @@ from datetime import datetime, timezone from pathlib import Path from .. import supervise as _supervise -from ..bottle_state import read_metadata +from ..bottle.state import read_metadata from ..backend.docker.egress_apply import ( EgressApplyError, applicator as _docker_applicator, diff --git a/scripts/critical-modules.txt b/scripts/critical-modules.txt index fa542f2..6518ec2 100644 --- a/scripts/critical-modules.txt +++ b/scripts/critical-modules.txt @@ -22,4 +22,4 @@ bot_bottle/git_gate_provision.py bot_bottle/git_http_backend.py bot_bottle/supervise.py bot_bottle/yaml_subset.py -bot_bottle/bottle_state.py +bot_bottle/bottle/state.py diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index dcb3329..63d9272 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -30,7 +30,7 @@ import unittest from pathlib import Path from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.bottle_state import cleanup_state +from bot_bottle.bottle.state import cleanup_state from bot_bottle.manifest import ManifestIndex from tests._docker import skip_unless_docker diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index d4d2233..633d9f6 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -50,15 +50,24 @@ def _metadata( # --------------------------------------------------------------------------- +class TestCompatibilityImports(unittest.TestCase): + def test_legacy_api_module_reexports_public_runner_api(self): + from bot_bottle import api + from bot_bottle.bottle import runner + + self.assertIs(api.BottleError, runner.BottleError) + self.assertIs(api.start_headless, runner.start_headless) + + class TestStartHeadless(unittest.TestCase): def setUp(self) -> None: self._manifest = _make_manifest() - patch("bot_bottle.api.ManifestIndex.resolve", return_value=self._manifest).start() + patch("bot_bottle.bottle.runner.ManifestIndex.resolve", return_value=self._manifest).start() self._launch = patch( - "bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0) + "bot_bottle.bottle.runner._launch_bottle", return_value=("implementer-abc12", 0) ).start() patch( - "bot_bottle.api._uniquify_label_headless", side_effect=str + "bot_bottle.bottle.runner._uniquify_label_headless", side_effect=str ).start() self.addCleanup(patch.stopall) @@ -101,7 +110,7 @@ class TestStartHeadless(unittest.TestCase): def test_no_default_bottle_raises_bottle_error(self): manifest = _make_manifest(bottle_name="") - with patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest): + with patch("bot_bottle.bottle.runner.ManifestIndex.resolve", return_value=manifest): with self.assertRaises(BottleError): start_headless("implementer", prompt="Do it") self._launch.assert_not_called() @@ -109,7 +118,7 @@ class TestStartHeadless(unittest.TestCase): def test_manifest_error_in_resolve_raises_bottle_error(self): from bot_bottle.manifest import ManifestError patch( - "bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad") + "bot_bottle.bottle.runner.ManifestIndex.resolve", side_effect=ManifestError("bad") ).start() with self.assertRaises(BottleError): start_headless("implementer", prompt="Do it") @@ -142,11 +151,11 @@ class TestStartHeadless(unittest.TestCase): class TestResumeHeadless(unittest.TestCase): def setUp(self) -> None: self._md = _metadata() - patch("bot_bottle.api.read_metadata", return_value=self._md).start() + patch("bot_bottle.bottle.runner.read_metadata", return_value=self._md).start() manifest = _make_manifest() - patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest).start() + patch("bot_bottle.bottle.runner.ManifestIndex.resolve", return_value=manifest).start() self._launch = patch( - "bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0) + "bot_bottle.bottle.runner._launch_bottle", return_value=("implementer-abc12", 0) ).start() self.addCleanup(patch.stopall) @@ -170,7 +179,7 @@ class TestResumeHeadless(unittest.TestCase): self.assertEqual(env, self._spec().forge_env) def test_missing_state_raises_bottle_error(self): - with patch("bot_bottle.api.read_metadata", return_value=None): + with patch("bot_bottle.bottle.runner.read_metadata", return_value=None): with self.assertRaises(BottleError): resume_headless("no-such-abc12", prompt="Prompt") self._launch.assert_not_called() @@ -184,7 +193,7 @@ class TestResumeHeadless(unittest.TestCase): def test_manifest_error_in_resolve_raises_bottle_error(self): from bot_bottle.manifest import ManifestError patch( - "bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad") + "bot_bottle.bottle.runner.ManifestIndex.resolve", side_effect=ManifestError("bad") ).start() with self.assertRaises(BottleError): resume_headless("implementer-abc12", prompt="Prompt") @@ -214,10 +223,10 @@ class TestResumeHeadless(unittest.TestCase): class TestFreeze(unittest.TestCase): def setUp(self) -> None: - patch("bot_bottle.api.read_metadata", return_value=_metadata()).start() + patch("bot_bottle.bottle.runner.read_metadata", return_value=_metadata()).start() self._freezer = MagicMock() self._get_freezer = patch( - "bot_bottle.api.get_freezer", return_value=self._freezer + "bot_bottle.bottle.runner.get_freezer", return_value=self._freezer ).start() self.addCleanup(patch.stopall) @@ -253,10 +262,10 @@ class TestFreeze(unittest.TestCase): class TestDestroy(unittest.TestCase): def setUp(self) -> None: - patch("bot_bottle.api.read_metadata", return_value=_metadata()).start() - self._dd = patch("bot_bottle.api._destroy_docker").start() - patch("bot_bottle.api.clear_preserve_marker").start() - self._cleanup = patch("bot_bottle.api.cleanup_state").start() + patch("bot_bottle.bottle.runner.read_metadata", return_value=_metadata()).start() + self._dd = patch("bot_bottle.bottle.runner._destroy_docker").start() + patch("bot_bottle.bottle.runner.clear_preserve_marker").start() + self._cleanup = patch("bot_bottle.bottle.runner.cleanup_state").start() self.addCleanup(patch.stopall) def test_docker_backend_calls_destroy_docker(self): @@ -269,32 +278,32 @@ class TestDestroy(unittest.TestCase): def test_smolmachines_backend_calls_destroy_smolmachines(self): patch( - "bot_bottle.api.read_metadata", + "bot_bottle.bottle.runner.read_metadata", return_value=_metadata(backend="smolmachines"), ).start() - ds = patch("bot_bottle.api._destroy_smolmachines").start() + ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start() destroy("implementer-abc12") ds.assert_called_once_with("implementer-abc12") self._dd.assert_not_called() def test_other_backend_skips_docker_and_smolmachines(self): patch( - "bot_bottle.api.read_metadata", + "bot_bottle.bottle.runner.read_metadata", return_value=_metadata(backend="macos-container"), ).start() - ds = patch("bot_bottle.api._destroy_smolmachines").start() + ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start() destroy("implementer-abc12") self._dd.assert_not_called() ds.assert_not_called() self._cleanup.assert_called_once_with("implementer-abc12") def test_missing_metadata_defaults_to_docker(self): - patch("bot_bottle.api.read_metadata", return_value=None).start() + patch("bot_bottle.bottle.runner.read_metadata", return_value=None).start() destroy("no-state-abc12") self._dd.assert_called_once_with("no-state-abc12") def test_explicit_backend_overrides_metadata(self): - ds = patch("bot_bottle.api._destroy_smolmachines").start() + ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start() destroy("implementer-abc12", backend_name="smolmachines") ds.assert_called_once_with("implementer-abc12") self._dd.assert_not_called() @@ -320,7 +329,7 @@ class TestDestroyDocker(unittest.TestCase): return_value="bb-proj", ).start() self._state_dir = patch( - "bot_bottle.bottle_state.bottle_state_dir", + "bot_bottle.bottle.state.bottle_state_dir", return_value=Path("/fake/state"), ).start() self.addCleanup(patch.stopall) @@ -332,7 +341,7 @@ class TestDestroyDocker(unittest.TestCase): "bot_bottle.backend.docker.compose.compose_file_path", return_value=fake_file, ): - from bot_bottle.api import _destroy_docker + from bot_bottle.bottle.runner import _destroy_docker _destroy_docker("slug-1") def test_calls_compose_down_when_file_exists(self) -> None: @@ -356,7 +365,7 @@ class TestDestroySmolmachines(unittest.TestCase): self.addCleanup(patch.stopall) def _call(self) -> None: - from bot_bottle.api import _destroy_smolmachines + from bot_bottle.bottle.runner import _destroy_smolmachines _destroy_smolmachines("slug-7") def test_issues_stop_then_delete(self) -> None: diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index d170af7..227b0cf 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -8,7 +8,7 @@ from pathlib import Path from bot_bottle import supervise from bot_bottle import bottle_state -from bot_bottle.bottle_state import ( +from bot_bottle.bottle.state import ( BottleMetadata, read_metadata, write_metadata, @@ -31,6 +31,15 @@ class _FakeHomeMixin: self._tmp.cleanup() +class TestCompatibilityImports(unittest.TestCase): + def test_legacy_bottle_state_module_reexports_state_api(self): + from bot_bottle import bottle_state as legacy + from bot_bottle.bottle import state + + self.assertIs(legacy.BottleMetadata, state.BottleMetadata) + self.assertIs(legacy.read_metadata, state.read_metadata) + + class TestPerBottleDockerfile(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home()