refactor(bottle): finish bottle package move
This commit is contained in:
@@ -43,7 +43,7 @@ from ..docker.git_gate import (
|
|||||||
)
|
)
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
from ...bottle_state import (
|
from ...bottle.state import (
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
git_gate_state_dir,
|
git_gate_state_dir,
|
||||||
read_committed_image,
|
read_committed_image,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 <identity>` reconstruct a bottle's spec. State
|
||||||
|
lives at:
|
||||||
|
|
||||||
|
~/.bot-bottle/state/<identity>/
|
||||||
|
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-<id>)
|
||||||
|
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 <agent>` 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 <identity>` 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/<identity>/...
|
||||||
|
_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 <identity>`. 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 <agent>` 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 <identity>`,
|
||||||
|
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/<identity>/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/<identity>/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 <identity>` 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/<slug>/ 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",
|
||||||
|
]
|
||||||
+4
-361
@@ -1,364 +1,7 @@
|
|||||||
"""Per-bottle persistent state.
|
"""Compatibility wrapper for per-bottle persistent state.
|
||||||
|
|
||||||
Holds optional per-bottle Dockerfile overrides, the transcript snapshot
|
The implementation lives in :mod:`bot_bottle.bottle.state`.
|
||||||
the state-preservation helper saves before teardown, and the launch metadata that lets
|
|
||||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
|
||||||
lives at:
|
|
||||||
|
|
||||||
~/.bot-bottle/state/<identity>/
|
|
||||||
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-<id>)
|
|
||||||
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 <agent>` 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 <identity>` 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
|
from .bottle.state import * # noqa: F403
|
||||||
|
from .bottle.state import __all__
|
||||||
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/<identity>/...
|
|
||||||
_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 <identity>`. 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 <agent>` 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 <identity>`,
|
|
||||||
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/<identity>/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/<identity>/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 <identity>` 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/<slug>/ 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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import argparse
|
|||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
from ..backend import enumerate_active_agents
|
||||||
from ..backend.freeze import CommitCancelled, get_freezer
|
from ..backend.freeze import CommitCancelled, get_freezer
|
||||||
from ..bottle_state import read_metadata
|
from ..bottle.state import read_metadata
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import tui
|
from . import tui
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from ..backend import BottleSpec
|
from ..backend import BottleSpec
|
||||||
from ..bottle_state import read_metadata
|
from ..bottle.state import read_metadata
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import ManifestIndex
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..backend import (
|
|||||||
)
|
)
|
||||||
from ..backend.docker import util as docker_mod
|
from ..backend.docker import util as docker_mod
|
||||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from ..bottle_state import (
|
from ..bottle.state import (
|
||||||
cleanup_state,
|
cleanup_state,
|
||||||
is_preserved,
|
is_preserved,
|
||||||
mark_preserved,
|
mark_preserved,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import supervise as _supervise
|
from .. import supervise as _supervise
|
||||||
from ..bottle_state import read_metadata
|
from ..bottle.state import read_metadata
|
||||||
from ..backend.docker.egress_apply import (
|
from ..backend.docker.egress_apply import (
|
||||||
EgressApplyError,
|
EgressApplyError,
|
||||||
applicator as _docker_applicator,
|
applicator as _docker_applicator,
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ bot_bottle/git_gate_provision.py
|
|||||||
bot_bottle/git_http_backend.py
|
bot_bottle/git_http_backend.py
|
||||||
bot_bottle/supervise.py
|
bot_bottle/supervise.py
|
||||||
bot_bottle/yaml_subset.py
|
bot_bottle/yaml_subset.py
|
||||||
bot_bottle/bottle_state.py
|
bot_bottle/bottle/state.py
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
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 bot_bottle.manifest import ManifestIndex
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|||||||
+34
-25
@@ -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):
|
class TestStartHeadless(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._manifest = _make_manifest()
|
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(
|
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()
|
).start()
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.api._uniquify_label_headless", side_effect=str
|
"bot_bottle.bottle.runner._uniquify_label_headless", side_effect=str
|
||||||
).start()
|
).start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
@@ -101,7 +110,7 @@ class TestStartHeadless(unittest.TestCase):
|
|||||||
|
|
||||||
def test_no_default_bottle_raises_bottle_error(self):
|
def test_no_default_bottle_raises_bottle_error(self):
|
||||||
manifest = _make_manifest(bottle_name="")
|
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):
|
with self.assertRaises(BottleError):
|
||||||
start_headless("implementer", prompt="Do it")
|
start_headless("implementer", prompt="Do it")
|
||||||
self._launch.assert_not_called()
|
self._launch.assert_not_called()
|
||||||
@@ -109,7 +118,7 @@ class TestStartHeadless(unittest.TestCase):
|
|||||||
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
||||||
from bot_bottle.manifest import ManifestError
|
from bot_bottle.manifest import ManifestError
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
"bot_bottle.bottle.runner.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
||||||
).start()
|
).start()
|
||||||
with self.assertRaises(BottleError):
|
with self.assertRaises(BottleError):
|
||||||
start_headless("implementer", prompt="Do it")
|
start_headless("implementer", prompt="Do it")
|
||||||
@@ -142,11 +151,11 @@ class TestStartHeadless(unittest.TestCase):
|
|||||||
class TestResumeHeadless(unittest.TestCase):
|
class TestResumeHeadless(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._md = _metadata()
|
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()
|
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(
|
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()
|
).start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
@@ -170,7 +179,7 @@ class TestResumeHeadless(unittest.TestCase):
|
|||||||
self.assertEqual(env, self._spec().forge_env)
|
self.assertEqual(env, self._spec().forge_env)
|
||||||
|
|
||||||
def test_missing_state_raises_bottle_error(self):
|
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):
|
with self.assertRaises(BottleError):
|
||||||
resume_headless("no-such-abc12", prompt="Prompt")
|
resume_headless("no-such-abc12", prompt="Prompt")
|
||||||
self._launch.assert_not_called()
|
self._launch.assert_not_called()
|
||||||
@@ -184,7 +193,7 @@ class TestResumeHeadless(unittest.TestCase):
|
|||||||
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
||||||
from bot_bottle.manifest import ManifestError
|
from bot_bottle.manifest import ManifestError
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
"bot_bottle.bottle.runner.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
||||||
).start()
|
).start()
|
||||||
with self.assertRaises(BottleError):
|
with self.assertRaises(BottleError):
|
||||||
resume_headless("implementer-abc12", prompt="Prompt")
|
resume_headless("implementer-abc12", prompt="Prompt")
|
||||||
@@ -214,10 +223,10 @@ class TestResumeHeadless(unittest.TestCase):
|
|||||||
|
|
||||||
class TestFreeze(unittest.TestCase):
|
class TestFreeze(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
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._freezer = MagicMock()
|
||||||
self._get_freezer = patch(
|
self._get_freezer = patch(
|
||||||
"bot_bottle.api.get_freezer", return_value=self._freezer
|
"bot_bottle.bottle.runner.get_freezer", return_value=self._freezer
|
||||||
).start()
|
).start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
@@ -253,10 +262,10 @@ class TestFreeze(unittest.TestCase):
|
|||||||
|
|
||||||
class TestDestroy(unittest.TestCase):
|
class TestDestroy(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
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._dd = patch("bot_bottle.api._destroy_docker").start()
|
self._dd = patch("bot_bottle.bottle.runner._destroy_docker").start()
|
||||||
patch("bot_bottle.api.clear_preserve_marker").start()
|
patch("bot_bottle.bottle.runner.clear_preserve_marker").start()
|
||||||
self._cleanup = patch("bot_bottle.api.cleanup_state").start()
|
self._cleanup = patch("bot_bottle.bottle.runner.cleanup_state").start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
def test_docker_backend_calls_destroy_docker(self):
|
def test_docker_backend_calls_destroy_docker(self):
|
||||||
@@ -269,32 +278,32 @@ class TestDestroy(unittest.TestCase):
|
|||||||
|
|
||||||
def test_smolmachines_backend_calls_destroy_smolmachines(self):
|
def test_smolmachines_backend_calls_destroy_smolmachines(self):
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.api.read_metadata",
|
"bot_bottle.bottle.runner.read_metadata",
|
||||||
return_value=_metadata(backend="smolmachines"),
|
return_value=_metadata(backend="smolmachines"),
|
||||||
).start()
|
).start()
|
||||||
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start()
|
||||||
destroy("implementer-abc12")
|
destroy("implementer-abc12")
|
||||||
ds.assert_called_once_with("implementer-abc12")
|
ds.assert_called_once_with("implementer-abc12")
|
||||||
self._dd.assert_not_called()
|
self._dd.assert_not_called()
|
||||||
|
|
||||||
def test_other_backend_skips_docker_and_smolmachines(self):
|
def test_other_backend_skips_docker_and_smolmachines(self):
|
||||||
patch(
|
patch(
|
||||||
"bot_bottle.api.read_metadata",
|
"bot_bottle.bottle.runner.read_metadata",
|
||||||
return_value=_metadata(backend="macos-container"),
|
return_value=_metadata(backend="macos-container"),
|
||||||
).start()
|
).start()
|
||||||
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
ds = patch("bot_bottle.bottle.runner._destroy_smolmachines").start()
|
||||||
destroy("implementer-abc12")
|
destroy("implementer-abc12")
|
||||||
self._dd.assert_not_called()
|
self._dd.assert_not_called()
|
||||||
ds.assert_not_called()
|
ds.assert_not_called()
|
||||||
self._cleanup.assert_called_once_with("implementer-abc12")
|
self._cleanup.assert_called_once_with("implementer-abc12")
|
||||||
|
|
||||||
def test_missing_metadata_defaults_to_docker(self):
|
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")
|
destroy("no-state-abc12")
|
||||||
self._dd.assert_called_once_with("no-state-abc12")
|
self._dd.assert_called_once_with("no-state-abc12")
|
||||||
|
|
||||||
def test_explicit_backend_overrides_metadata(self):
|
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")
|
destroy("implementer-abc12", backend_name="smolmachines")
|
||||||
ds.assert_called_once_with("implementer-abc12")
|
ds.assert_called_once_with("implementer-abc12")
|
||||||
self._dd.assert_not_called()
|
self._dd.assert_not_called()
|
||||||
@@ -320,7 +329,7 @@ class TestDestroyDocker(unittest.TestCase):
|
|||||||
return_value="bb-proj",
|
return_value="bb-proj",
|
||||||
).start()
|
).start()
|
||||||
self._state_dir = patch(
|
self._state_dir = patch(
|
||||||
"bot_bottle.bottle_state.bottle_state_dir",
|
"bot_bottle.bottle.state.bottle_state_dir",
|
||||||
return_value=Path("/fake/state"),
|
return_value=Path("/fake/state"),
|
||||||
).start()
|
).start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
@@ -332,7 +341,7 @@ class TestDestroyDocker(unittest.TestCase):
|
|||||||
"bot_bottle.backend.docker.compose.compose_file_path",
|
"bot_bottle.backend.docker.compose.compose_file_path",
|
||||||
return_value=fake_file,
|
return_value=fake_file,
|
||||||
):
|
):
|
||||||
from bot_bottle.api import _destroy_docker
|
from bot_bottle.bottle.runner import _destroy_docker
|
||||||
_destroy_docker("slug-1")
|
_destroy_docker("slug-1")
|
||||||
|
|
||||||
def test_calls_compose_down_when_file_exists(self) -> None:
|
def test_calls_compose_down_when_file_exists(self) -> None:
|
||||||
@@ -356,7 +365,7 @@ class TestDestroySmolmachines(unittest.TestCase):
|
|||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
def _call(self) -> None:
|
def _call(self) -> None:
|
||||||
from bot_bottle.api import _destroy_smolmachines
|
from bot_bottle.bottle.runner import _destroy_smolmachines
|
||||||
_destroy_smolmachines("slug-7")
|
_destroy_smolmachines("slug-7")
|
||||||
|
|
||||||
def test_issues_stop_then_delete(self) -> None:
|
def test_issues_stop_then_delete(self) -> None:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle import bottle_state
|
from bot_bottle import bottle_state
|
||||||
from bot_bottle.bottle_state import (
|
from bot_bottle.bottle.state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
read_metadata,
|
read_metadata,
|
||||||
write_metadata,
|
write_metadata,
|
||||||
@@ -31,6 +31,15 @@ class _FakeHomeMixin:
|
|||||||
self._tmp.cleanup()
|
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):
|
class TestPerBottleDockerfile(_FakeHomeMixin, unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
self._setup_fake_home()
|
||||||
|
|||||||
Reference in New Issue
Block a user