From 29249f93b70be3c695e81ede31e6db536e35f719 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 6 Jul 2026 21:01:35 +0000 Subject: [PATCH] refactor(bottle): move runner/state package --- bot_bottle/__init__.py | 2 +- bot_bottle/api.py | 250 +----------------- bot_bottle/backend/docker/cleanup.py | 2 +- bot_bottle/backend/docker/enumerate.py | 2 +- bot_bottle/backend/docker/launch.py | 2 +- bot_bottle/backend/egress_apply.py | 2 +- bot_bottle/backend/freeze.py | 4 +- .../backend/macos_container/enumerate.py | 2 +- bot_bottle/backend/macos_container/launch.py | 2 +- bot_bottle/backend/resolve_common.py | 2 +- bot_bottle/backend/smolmachines/enumerate.py | 2 +- bot_bottle/backend/smolmachines/freezer.py | 2 +- 12 files changed, 15 insertions(+), 259 deletions(-) diff --git a/bot_bottle/__init__.py b/bot_bottle/__init__.py index 52ccd80..9a29d2a 100644 --- a/bot_bottle/__init__.py +++ b/bot_bottle/__init__.py @@ -1,6 +1,6 @@ """bot-bottle: Python implementation of the agent container launcher.""" -from .api import BottleError, destroy, freeze, resume_headless, start_headless +from .bottle import BottleError, destroy, freeze, resume_headless, start_headless __all__ = [ "BottleError", diff --git a/bot_bottle/api.py b/bot_bottle/api.py index bda2840..21480bd 100644 --- a/bot_bottle/api.py +++ b/bot_bottle/api.py @@ -1,253 +1,9 @@ -"""Public Python API for programmatic bottle orchestration. +"""Compatibility wrapper for the public bottle runner API. -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. +The implementation lives in :mod:`bot_bottle.bottle.runner`. """ -from __future__ import annotations - -from typing import Sequence, cast - -from .backend import BottleSpec -from .backend.freeze import CommitCancelled, get_freezer -from .bottle_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 .bottle_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()}" - ) - +from .bottle.runner import BottleError, destroy, freeze, resume_headless, start_headless __all__ = [ "BottleError", diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 079de35..4133a8f 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -31,7 +31,7 @@ from ... import supervise as _supervise from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan -from ...bottle_state import bottle_state_dir, is_preserved +from ...bottle.state import bottle_state_dir, is_preserved from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index af12af3..f8d588c 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -15,7 +15,7 @@ from __future__ import annotations import subprocess from .. import ActiveAgent -from ...bottle_state import read_metadata +from ...bottle.state import read_metadata from .compose import compose_project_name, list_active_slugs diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index fe49903..9bc1255 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -46,7 +46,7 @@ from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan -from ...bottle_state import ( +from ...bottle.state import ( bottle_state_dir, egress_state_dir, git_gate_state_dir, diff --git a/bot_bottle/backend/egress_apply.py b/bot_bottle/backend/egress_apply.py index ed54cdf..3fb01a5 100644 --- a/bot_bottle/backend/egress_apply.py +++ b/bot_bottle/backend/egress_apply.py @@ -9,7 +9,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from pathlib import Path -from ..bottle_state import egress_state_dir +from ..bottle.state import egress_state_dir from ..egress import EGRESS_ROUTES_FILENAME from ..egress_addon_core import LOG_OFF, load_config diff --git a/bot_bottle/backend/freeze.py b/bot_bottle/backend/freeze.py index d9a8013..9466c92 100644 --- a/bot_bottle/backend/freeze.py +++ b/bot_bottle/backend/freeze.py @@ -16,7 +16,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from . import ActiveAgent -from ..bottle_state import mark_preserved, write_committed_image +from ..bottle.state import mark_preserved, write_committed_image from ..log import die, info @@ -66,7 +66,7 @@ class Freezer(ABC): def commit_slug(self, slug: str) -> None: """Convenience entry for cmd_commit when only a slug is available.""" - from ..bottle_state import read_metadata + from ..bottle.state import read_metadata metadata = read_metadata(slug) agent = ActiveAgent( backend_name=self.backend_name, diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index b7d261d..aff43d4 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -4,7 +4,7 @@ from __future__ import annotations import subprocess -from ...bottle_state import read_metadata +from ...bottle.state import read_metadata from .. import ActiveAgent _PREFIX = "bot-bottle-" diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 6e9034f..8d5bfbe 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -17,7 +17,7 @@ from contextlib import ExitStack, contextmanager from pathlib import Path from typing import Callable, Generator -from ...bottle_state import ( +from ...bottle.state import ( egress_state_dir, git_gate_state_dir, read_committed_image, diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index d1ad0dc..e284f95 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from ..agent_provider import AgentProvisionPlan -from ..bottle_state import ( +from ..bottle.state import ( BottleMetadata, agent_state_dir, bottle_identity, diff --git a/bot_bottle/backend/smolmachines/enumerate.py b/bot_bottle/backend/smolmachines/enumerate.py index cd24ec4..fb1c801 100644 --- a/bot_bottle/backend/smolmachines/enumerate.py +++ b/bot_bottle/backend/smolmachines/enumerate.py @@ -23,7 +23,7 @@ import json import subprocess from .. import ActiveAgent -from ...bottle_state import read_metadata +from ...bottle.state import read_metadata from . import sidecar_bundle as _bundle diff --git a/bot_bottle/backend/smolmachines/freezer.py b/bot_bottle/backend/smolmachines/freezer.py index 1315008..46642d9 100644 --- a/bot_bottle/backend/smolmachines/freezer.py +++ b/bot_bottle/backend/smolmachines/freezer.py @@ -18,7 +18,7 @@ from ..freeze import Freezer from ..docker import util as docker_mod from .local_registry import crane_push_tarball, ephemeral_registry from .smolvm import machine_cp, machine_exec, pack_create -from ...bottle_state import bottle_state_dir +from ...bottle.state import bottle_state_dir from ...log import die, info