feat: expose stable Python API for programmatic bottle orchestration
Add bot_bottle/api.py with four public functions the orchestrator uses: start_headless, resume_headless, freeze, and destroy. These let a ProgrammaticBottleRunner call directly into bot_bottle instead of shelling out to the CLI; call sites in lifecycle.py stay unchanged. Key changes: - BottleSpec gains forge_env field for forge sidecar credentials - _launch_bottle returns (slug, exit_code) instead of int so start_headless can return the slug to callers - All four API functions convert Die and non-zero exits to BottleError - 27 new unit tests; existing tests updated for the new return type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,11 @@
|
|||||||
"""bot-bottle: Python implementation of the agent container launcher."""
|
"""bot-bottle: Python implementation of the agent container launcher."""
|
||||||
|
|
||||||
|
from .api import BottleError, destroy, freeze, resume_headless, start_headless
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BottleError",
|
||||||
|
"destroy",
|
||||||
|
"freeze",
|
||||||
|
"resume_headless",
|
||||||
|
"start_headless",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
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=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=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=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=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()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BottleError",
|
||||||
|
"destroy",
|
||||||
|
"freeze",
|
||||||
|
"resume_headless",
|
||||||
|
"start_headless",
|
||||||
|
]
|
||||||
@@ -37,7 +37,7 @@ import shlex
|
|||||||
import sys
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
@@ -75,6 +75,11 @@ class BottleSpec:
|
|||||||
# Ordered bottle names selected at launch (issue #269). When non-empty
|
# Ordered bottle names selected at launch (issue #269). When non-empty
|
||||||
# they are merged in order and replace the agent's `bottle:` field.
|
# they are merged in order and replace the agent's `bottle:` field.
|
||||||
bottle_names: tuple[str, ...] = ()
|
bottle_names: tuple[str, ...] = ()
|
||||||
|
# Forge sidecar env vars (PRD forge-native-integration, chunk 1).
|
||||||
|
# Passed by the orchestrator at launch time; the forge sidecar reads
|
||||||
|
# them to connect to Gitea. Empty for non-forge runs. The agent
|
||||||
|
# process itself does not receive these.
|
||||||
|
forge_env: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -74,10 +74,11 @@ def cmd_resume(argv: list[str]) -> int:
|
|||||||
bottle_names=tuple(metadata.bottle_names),
|
bottle_names=tuple(metadata.bottle_names),
|
||||||
)
|
)
|
||||||
backend_name = metadata.backend or None
|
backend_name = metadata.backend or None
|
||||||
return _launch_bottle(
|
_, rc = _launch_bottle(
|
||||||
spec,
|
spec,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
assume_yes=args.headless,
|
assume_yes=args.headless,
|
||||||
headless_prompt_text=args.prompt or "",
|
headless_prompt_text=args.prompt or "",
|
||||||
)
|
)
|
||||||
|
return rc
|
||||||
|
|||||||
+13
-5
@@ -144,11 +144,12 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
color=color,
|
color=color,
|
||||||
bottle_names=bottle_names,
|
bottle_names=bottle_names,
|
||||||
)
|
)
|
||||||
return _launch_bottle(
|
_, rc = _launch_bottle(
|
||||||
spec,
|
spec,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
# --- Headless launch -----------------------------------------------------
|
# --- Headless launch -----------------------------------------------------
|
||||||
@@ -203,13 +204,14 @@ def _start_headless(
|
|||||||
color=args.color or "",
|
color=args.color or "",
|
||||||
bottle_names=bottle_names,
|
bottle_names=bottle_names,
|
||||||
)
|
)
|
||||||
return _launch_bottle(
|
_, rc = _launch_bottle(
|
||||||
spec,
|
spec,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
assume_yes=True,
|
assume_yes=True,
|
||||||
headless_prompt_text=prompt,
|
headless_prompt_text=prompt,
|
||||||
)
|
)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
def _uniquify_label_headless(label: str) -> str:
|
def _uniquify_label_headless(label: str) -> str:
|
||||||
@@ -497,11 +499,16 @@ def _launch_bottle(
|
|||||||
backend_name: str | None = None,
|
backend_name: str | None = None,
|
||||||
assume_yes: bool = False,
|
assume_yes: bool = False,
|
||||||
headless_prompt_text: str = "",
|
headless_prompt_text: str = "",
|
||||||
) -> int:
|
) -> tuple[str, int]:
|
||||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||||
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
||||||
attaches claude, and prints the resume hint on session end.
|
attaches claude, and prints the resume hint on session end.
|
||||||
|
|
||||||
|
Returns ``(slug, exit_code)`` where ``slug`` is the bottle identity
|
||||||
|
(empty string when the launch was aborted before a slug was minted)
|
||||||
|
and ``exit_code`` is the agent process's exit code (0 on clean exit
|
||||||
|
or when launch was aborted before the agent ran).
|
||||||
|
|
||||||
`assume_yes` skips the interactive y/N confirmation (headless /
|
`assume_yes` skips the interactive y/N confirmation (headless /
|
||||||
orchestrator launches), where there is no human at the prompt.
|
orchestrator launches), where there is no human at the prompt.
|
||||||
|
|
||||||
@@ -510,6 +517,7 @@ def _launch_bottle(
|
|||||||
agent receives the initial task without interactive input."""
|
agent receives the initial task without interactive input."""
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
||||||
identity = ""
|
identity = ""
|
||||||
|
exit_code = 0
|
||||||
try:
|
try:
|
||||||
plan, identity = prepare_with_preflight(
|
plan, identity = prepare_with_preflight(
|
||||||
spec,
|
spec,
|
||||||
@@ -520,7 +528,7 @@ def _launch_bottle(
|
|||||||
backend_name=backend_name,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
if plan is None:
|
if plan is None:
|
||||||
return 0
|
return identity, 0
|
||||||
|
|
||||||
backend = get_bottle_backend(backend_name)
|
backend = get_bottle_backend(backend_name)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
@@ -547,7 +555,7 @@ def _launch_bottle(
|
|||||||
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
||||||
if agent_provider_template == "claude":
|
if agent_provider_template == "claude":
|
||||||
capture_claude_session_state(identity, exit_code)
|
capture_claude_session_state(identity, exit_code)
|
||||||
return 0
|
return identity, exit_code
|
||||||
finally:
|
finally:
|
||||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||||
# sources under state/<slug>/. If we never reached the
|
# sources under state/<slug>/. If we never reached the
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Unit: bot_bottle public Python API (bot_bottle/__init__.py surface).
|
||||||
|
|
||||||
|
Covers start_headless, resume_headless, freeze, and destroy — the four
|
||||||
|
operations the bot-bottle-orchestrator's ProgrammaticBottleRunner uses.
|
||||||
|
All I/O is stubbed so no container is created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manifest(agent_name: str = "implementer", bottle_name: str = "claude"):
|
||||||
|
manifest = MagicMock()
|
||||||
|
manifest.agents = {agent_name: MagicMock(bottle=bottle_name)}
|
||||||
|
manifest.all_agent_names = [agent_name]
|
||||||
|
manifest.all_bottle_names = [bottle_name]
|
||||||
|
manifest.home_md = None # eager mode — _peek_agent_bottle uses agents dict
|
||||||
|
manifest.require_agent = MagicMock(return_value=None)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(
|
||||||
|
slug: str = "implementer-abc12",
|
||||||
|
agent_name: str = "implementer",
|
||||||
|
backend: str = "docker",
|
||||||
|
):
|
||||||
|
md = MagicMock()
|
||||||
|
md.identity = slug
|
||||||
|
md.agent_name = agent_name
|
||||||
|
md.cwd = "/repo"
|
||||||
|
md.copy_cwd = False
|
||||||
|
md.bottle_names = ["claude"]
|
||||||
|
md.backend = backend
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
self._launch = patch(
|
||||||
|
"bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0)
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"bot_bottle.api._uniquify_label_headless", side_effect=lambda lbl: lbl
|
||||||
|
).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def _spec(self):
|
||||||
|
self._launch.assert_called_once()
|
||||||
|
return self._launch.call_args[0][0]
|
||||||
|
|
||||||
|
def test_returns_slug_on_success(self):
|
||||||
|
slug = start_headless("implementer", prompt="Do it")
|
||||||
|
self.assertEqual("implementer-abc12", slug)
|
||||||
|
|
||||||
|
def test_passes_assume_yes_and_prompt(self):
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
kwargs = self._launch.call_args[1]
|
||||||
|
self.assertTrue(kwargs["assume_yes"])
|
||||||
|
self.assertEqual("Do it", kwargs["headless_prompt_text"])
|
||||||
|
|
||||||
|
def test_explicit_bottles_forwarded(self):
|
||||||
|
start_headless("implementer", prompt="Do it", bottles=["dev", "claude"])
|
||||||
|
self.assertEqual(("dev", "claude"), self._spec().bottle_names)
|
||||||
|
|
||||||
|
def test_default_bottle_resolved_from_manifest(self):
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
self.assertEqual(("claude",), self._spec().bottle_names)
|
||||||
|
|
||||||
|
def test_forge_env_on_spec(self):
|
||||||
|
env = {"FORGE_GITEA_API": "https://gitea.example.com/api/v1", "FORGE_OWNER": "acme"}
|
||||||
|
start_headless("implementer", prompt="Do it", forge_env=env)
|
||||||
|
self.assertEqual(env, self._spec().forge_env)
|
||||||
|
|
||||||
|
def test_no_forge_env_defaults_to_empty_dict(self):
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
self.assertEqual({}, self._spec().forge_env)
|
||||||
|
|
||||||
|
def test_nonzero_exit_raises_bottle_error(self):
|
||||||
|
self._launch.return_value = ("implementer-abc12", 1)
|
||||||
|
with self.assertRaises(BottleError) as ctx:
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
self.assertEqual(1, ctx.exception.exit_code)
|
||||||
|
|
||||||
|
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 self.assertRaises(BottleError):
|
||||||
|
start_headless("implementer", prompt="Do it")
|
||||||
|
self._launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_backend_name_forwarded(self):
|
||||||
|
start_headless("implementer", prompt="Do it", backend_name="docker")
|
||||||
|
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
||||||
|
|
||||||
|
def test_label_forwarded_to_spec(self):
|
||||||
|
start_headless("implementer", prompt="Do it", label="nightly")
|
||||||
|
self.assertEqual("nightly", self._spec().label)
|
||||||
|
|
||||||
|
def test_color_forwarded_to_spec(self):
|
||||||
|
start_headless("implementer", prompt="Do it", color="green")
|
||||||
|
self.assertEqual("green", self._spec().color)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resume_headless
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResumeHeadless(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._md = _metadata()
|
||||||
|
patch("bot_bottle.api.read_metadata", return_value=self._md).start()
|
||||||
|
manifest = _make_manifest()
|
||||||
|
patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest).start()
|
||||||
|
self._launch = patch(
|
||||||
|
"bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0)
|
||||||
|
).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def _spec(self):
|
||||||
|
self._launch.assert_called_once()
|
||||||
|
return self._launch.call_args[0][0]
|
||||||
|
|
||||||
|
def test_passes_assume_yes_and_prompt(self):
|
||||||
|
resume_headless("implementer-abc12", prompt="Address review")
|
||||||
|
kwargs = self._launch.call_args[1]
|
||||||
|
self.assertTrue(kwargs["assume_yes"])
|
||||||
|
self.assertEqual("Address review", kwargs["headless_prompt_text"])
|
||||||
|
|
||||||
|
def test_identity_set_on_spec(self):
|
||||||
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
|
self.assertEqual("implementer-abc12", self._spec().identity)
|
||||||
|
|
||||||
|
def test_forge_env_on_spec(self):
|
||||||
|
env = {"FORGE_ISSUE_NUMBER": "42"}
|
||||||
|
resume_headless("implementer-abc12", prompt="Prompt", forge_env=env)
|
||||||
|
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 self.assertRaises(BottleError):
|
||||||
|
resume_headless("no-such-abc12", prompt="Prompt")
|
||||||
|
self._launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_nonzero_exit_raises_bottle_error(self):
|
||||||
|
self._launch.return_value = ("implementer-abc12", 2)
|
||||||
|
with self.assertRaises(BottleError) as ctx:
|
||||||
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
|
self.assertEqual(2, ctx.exception.exit_code)
|
||||||
|
|
||||||
|
def test_backend_from_metadata_when_not_supplied(self):
|
||||||
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
|
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
||||||
|
|
||||||
|
def test_explicit_backend_overrides_metadata(self):
|
||||||
|
resume_headless(
|
||||||
|
"implementer-abc12", prompt="Prompt", backend_name="smolmachines"
|
||||||
|
)
|
||||||
|
self.assertEqual("smolmachines", self._launch.call_args[1]["backend_name"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# freeze
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreeze(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
patch("bot_bottle.api.read_metadata", return_value=_metadata()).start()
|
||||||
|
self._freezer = MagicMock()
|
||||||
|
self._get_freezer = patch(
|
||||||
|
"bot_bottle.api.get_freezer", return_value=self._freezer
|
||||||
|
).start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def test_calls_commit_slug(self):
|
||||||
|
freeze("implementer-abc12")
|
||||||
|
self._freezer.commit_slug.assert_called_once_with("implementer-abc12")
|
||||||
|
|
||||||
|
def test_backend_from_metadata_when_not_supplied(self):
|
||||||
|
freeze("implementer-abc12")
|
||||||
|
self._get_freezer.assert_called_once_with("docker")
|
||||||
|
|
||||||
|
def test_explicit_backend_used(self):
|
||||||
|
freeze("implementer-abc12", backend_name="smolmachines")
|
||||||
|
self._get_freezer.assert_called_once_with("smolmachines")
|
||||||
|
|
||||||
|
def test_commit_cancelled_raises_bottle_error(self):
|
||||||
|
from bot_bottle.backend.freeze import CommitCancelled
|
||||||
|
self._freezer.commit_slug.side_effect = CommitCancelled("declined")
|
||||||
|
with self.assertRaises(BottleError):
|
||||||
|
freeze("implementer-abc12")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# destroy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def test_docker_backend_calls_destroy_docker(self):
|
||||||
|
destroy("implementer-abc12")
|
||||||
|
self._dd.assert_called_once_with("implementer-abc12")
|
||||||
|
|
||||||
|
def test_state_dir_always_cleaned(self):
|
||||||
|
destroy("implementer-abc12")
|
||||||
|
self._cleanup.assert_called_once_with("implementer-abc12")
|
||||||
|
|
||||||
|
def test_smolmachines_backend_calls_destroy_smolmachines(self):
|
||||||
|
patch(
|
||||||
|
"bot_bottle.api.read_metadata",
|
||||||
|
return_value=_metadata(backend="smolmachines"),
|
||||||
|
).start()
|
||||||
|
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
||||||
|
destroy("implementer-abc12")
|
||||||
|
ds.assert_called_once_with("implementer-abc12")
|
||||||
|
self._dd.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_metadata_defaults_to_docker(self):
|
||||||
|
patch("bot_bottle.api.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()
|
||||||
|
destroy("implementer-abc12", backend_name="smolmachines")
|
||||||
|
ds.assert_called_once_with("implementer-abc12")
|
||||||
|
self._dd.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# public surface exported from bot_bottle.__init__
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicSurface(unittest.TestCase):
|
||||||
|
def test_importable_from_package(self):
|
||||||
|
import bot_bottle
|
||||||
|
for name in ("BottleError", "start_headless", "resume_headless", "freeze", "destroy"):
|
||||||
|
self.assertTrue(hasattr(bot_bottle, name), f"missing: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -29,7 +29,7 @@ def _metadata():
|
|||||||
class ResumeHeadlessTest(unittest.TestCase):
|
class ResumeHeadlessTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._launch = patch.object(
|
self._launch = patch.object(
|
||||||
resume_mod, "_launch_bottle", return_value=0
|
resume_mod, "_launch_bottle", return_value=("implementer-abc12", 0)
|
||||||
).start()
|
).start()
|
||||||
patch.object(
|
patch.object(
|
||||||
resume_mod, "read_metadata", return_value=_metadata()
|
resume_mod, "read_metadata", return_value=_metadata()
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TestCmdStartHeadless(unittest.TestCase):
|
|||||||
return_value=self._manifest,
|
return_value=self._manifest,
|
||||||
).start()
|
).start()
|
||||||
self._launch_mock = patch(
|
self._launch_mock = patch(
|
||||||
"bot_bottle.cli.start._launch_bottle", return_value=0
|
"bot_bottle.cli.start._launch_bottle", return_value=("", 0)
|
||||||
).start()
|
).start()
|
||||||
# No bottles running by default → no label collision.
|
# No bottles running by default → no label collision.
|
||||||
patch(
|
patch(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class TestCmdStartSelector(unittest.TestCase):
|
|||||||
|
|
||||||
self._launch_patch = patch(
|
self._launch_patch = patch(
|
||||||
"bot_bottle.cli.start._launch_bottle",
|
"bot_bottle.cli.start._launch_bottle",
|
||||||
return_value=0,
|
return_value=("", 0),
|
||||||
)
|
)
|
||||||
self._launch_mock = self._launch_patch.start()
|
self._launch_mock = self._launch_patch.start()
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
|
|||||||
self._manifest = _make_manifest(["researcher"], ["claude"])
|
self._manifest = _make_manifest(["researcher"], ["claude"])
|
||||||
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
|
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
|
||||||
self._launch_mock = patch(
|
self._launch_mock = patch(
|
||||||
"bot_bottle.cli.start._launch_bottle", return_value=0,
|
"bot_bottle.cli.start._launch_bottle", return_value=("", 0),
|
||||||
).start()
|
).start()
|
||||||
# Stub the bottle picker to always return a selection.
|
# Stub the bottle picker to always return a selection.
|
||||||
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
|
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
|
||||||
|
|||||||
Reference in New Issue
Block a user