diff --git a/bot_bottle/backend/docker/resolve_plan.py b/bot_bottle/backend/docker/resolve_plan.py index c723cfd..3eff0ef 100644 --- a/bot_bottle/backend/docker/resolve_plan.py +++ b/bot_bottle/backend/docker/resolve_plan.py @@ -11,32 +11,30 @@ via the base class's `prepare` template before this is called. from __future__ import annotations import os -from datetime import datetime, timezone -from dataclasses import replace from pathlib import Path from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider -from ...egress import Egress from ...env import ResolvedEnv, resolve_env -from ...git_gate import GitGate from ...log import die -from ...supervise import Supervise from ...workspace import workspace_plan as resolve_workspace_plan from .. import BottleSpec +from ..resolve_common import ( + merge_provision_env_vars, + mint_slug, + prepare_agent_state_dir, + prepare_egress, + prepare_git_gate, + prepare_supervise, + resolve_manifest_dockerfile, + write_launch_metadata, +) from . import util as docker_mod from .bottle_plan import DockerBottlePlan from ...bottle_state import ( - BottleMetadata, - agent_state_dir, - bottle_identity, clear_preserve_marker, - egress_state_dir, - git_gate_state_dir, per_bottle_dockerfile, per_bottle_dockerfile_path, per_bottle_image_tag, - supervise_state_dir, - write_metadata, ) from .sidecar_bundle import sidecar_bundle_container_name @@ -51,12 +49,7 @@ def resolve_plan( validation already ran in the base class.""" docker_mod.require_docker() - git_gate = GitGate() - egress = Egress() - supervise = Supervise() - manifest = spec.manifest - agent = manifest.agents[spec.agent_name] bottle = manifest.bottle_for(spec.agent_name) provider = bottle.agent_provider provider_obj = get_provider(provider.template) @@ -64,26 +57,8 @@ def resolve_plan( guest_home = "/home/node" workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) - # PRD 0016 follow-up: identity, not bare slug. A fresh `start` - # mints a random-suffixed identity (so parallel runs of the same - # agent in the same cwd don't collide on container/network - # names); a `resume` passes the recorded identity in via - # spec.identity to continue an existing bottle's state. - slug = spec.identity or bottle_identity(spec.agent_name) - # Record the launch metadata so `cli.py resume ` can - # reconstruct the spec. Idempotent — re-writes on resume with a - # refreshed started_at. - write_metadata(BottleMetadata( - identity=slug, - agent_name=spec.agent_name, - cwd=spec.user_cwd if spec.copy_cwd else "", - copy_cwd=spec.copy_cwd, - started_at=datetime.now(timezone.utc).isoformat(), - compose_project=f"bot-bottle-{slug}", - backend="docker", - label=spec.label, - color=spec.color, - )) + slug = mint_slug(spec) + write_launch_metadata(slug, spec, compose_project=f"bot-bottle-{slug}", backend="docker") # Clear any leftover preserve marker from a prior capability-block # so this fresh launch can be cleaned up at session-end unless # the agent triggers another capability-block. @@ -103,7 +78,7 @@ def resolve_plan( dockerfile_path = str(per_bottle_dockerfile_path(slug)) elif provider.dockerfile: image = f"bot-bottle-{provider.template}:{slug}" - dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec) + dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec) derived_image = "" runtime_image = image if spec.copy_cwd: @@ -149,29 +124,14 @@ def resolve_plan( f"retry." ) - # PRD 0018 chunk 2: prepare-time scratch files live under - # ~/.bot-bottle/state/// so chunk 3's compose - # bind-mounts can point at stable paths. The state subdirs are - # cleaned up by start.py's session-end teardown unless something - # explicitly preserves the state dir (capability-block, crash). - agent_dir = agent_state_dir(slug) - agent_dir.mkdir(parents=True, exist_ok=True) + agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) env_file = agent_dir / "agent.env" - prompt_file = agent_dir / "prompt.txt" - prompt_file.write_text("") - prompt_file.chmod(0o600) - git_gate_dir = git_gate_state_dir(slug) - git_gate_dir.mkdir(parents=True, exist_ok=True) - git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir) + git_gate_plan = prepare_git_gate(bottle, slug) resolved = resolve_env(manifest, spec.agent_name) - # Everything that should reach the bottle by-name (so its value - # never lands on argv or in env_file) goes into one dict. Nothing - # mutates the host os.environ. forwarded_env: dict[str, str] = dict(resolved.forwarded) _write_env_file(resolved, env_file) - prompt_file.write_text(agent.prompt) use_runsc = docker_mod.runsc_available() agent_provision = agent_provision_plan( @@ -186,39 +146,25 @@ def resolve_plan( label=spec.label, color=spec.color, ) - guest_env = dict(agent_provision.guest_env) - for key, val in agent_provision.env_vars.items(): - guest_env.setdefault(key, val) - agent_provision = replace(agent_provision, guest_env=guest_env) + agent_provision = merge_provision_env_vars(agent_provision) - egress_dir = egress_state_dir(slug) - egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = egress.prepare( - bottle, slug, egress_dir, agent_provision.egress_routes, + egress_plan = prepare_egress(bottle, slug, agent_provision) + + # Current Dockerfile for the agent image. For `--cwd` derived + # images the base Dockerfile is what the agent should propose + # changes against (the derived layer is just a workspace copy). + # (routes.yaml used to land here too but PRD 0017 chunk 3 + # moved it behind the `list-egress-routes` MCP tool so the + # agent gets live state rather than a launch-time snapshot.) + supervise_dockerfile_path = ( + Path(dockerfile_path) if dockerfile_path else provider_obj.dockerfile ) - - supervise_plan = None - if bottle.supervise: - # Current Dockerfile for the agent image. For `--cwd` derived - # images the base Dockerfile is what the agent should propose - # changes against (the derived layer is just a workspace copy). - # (routes.yaml used to land here too but PRD 0017 chunk 3 - # moved it behind the `list-egress-routes` MCP tool so the - # agent gets live state rather than a launch-time snapshot.) - supervise_dockerfile_path = ( - Path(dockerfile_path) if dockerfile_path else provider_obj.dockerfile - ) - dockerfile_content = ( - supervise_dockerfile_path.read_text(encoding="utf-8") - if supervise_dockerfile_path.is_file() - else "" - ) - supervise_dir = supervise_state_dir(slug) - supervise_dir.mkdir(parents=True, exist_ok=True) - supervise_plan = supervise.prepare( - slug, supervise_dir, - dockerfile_content=dockerfile_content, - ) + dockerfile_content = ( + supervise_dockerfile_path.read_text(encoding="utf-8") + if supervise_dockerfile_path.is_file() + else "" + ) + supervise_plan = prepare_supervise(bottle, slug, dockerfile_content=dockerfile_content) return DockerBottlePlan( spec=spec, @@ -260,8 +206,3 @@ def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None: env_file.chmod(0o600) -def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str: - path = Path(os.path.expanduser(path_value)) - if not path.is_absolute(): - path = Path(spec.user_cwd) / path - return str(path) diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py new file mode 100644 index 0000000..55d6c47 --- /dev/null +++ b/bot_bottle/backend/resolve_common.py @@ -0,0 +1,124 @@ +"""Shared helpers used by both backends' resolve_plan steps. + +Each helper owns one well-defined step of the per-bottle plan +resolution so docker and smolmachines don't repeat the same logic. +Backend-specific steps (container names, env-file, per-bottle +Dockerfile overrides, subnet allocation) stay in the backend's own +resolve_plan.py. +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path + +from ..agent_provider import AgentProvisionPlan +from ..bottle_state import ( + BottleMetadata, + agent_state_dir, + bottle_identity, + egress_state_dir, + git_gate_state_dir, + supervise_state_dir, + write_metadata, +) +from ..egress import Egress, EgressPlan +from ..git_gate import GitGate, GitGatePlan +from ..manifest import ManifestBottle +from ..supervise import Supervise, SupervisePlan +from . import BottleSpec + + +def mint_slug(spec: BottleSpec) -> str: + """Return the bottle identity: the recorded identity for a resume, + or a freshly minted one for a new start.""" + return spec.identity or bottle_identity(spec.agent_name) + + +def write_launch_metadata( + slug: str, spec: BottleSpec, *, compose_project: str, backend: str, +) -> None: + """Persist launch metadata so `cli.py resume ` can + reconstruct the spec. Idempotent — re-writes on resume with a + refreshed started_at.""" + write_metadata(BottleMetadata( + identity=slug, + agent_name=spec.agent_name, + cwd=spec.user_cwd if spec.copy_cwd else "", + copy_cwd=spec.copy_cwd, + started_at=datetime.now(timezone.utc).isoformat(), + compose_project=compose_project, + backend=backend, + label=spec.label, + color=spec.color, + )) + + +def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]: + """Create the agent state subdir, write the prompt file. + Returns (agent_dir, prompt_file).""" + manifest = spec.manifest + agent = manifest.agents[spec.agent_name] + agent_dir = agent_state_dir(slug) + agent_dir.mkdir(parents=True, exist_ok=True) + prompt_file = agent_dir / "prompt.txt" + prompt_file.write_text(agent.prompt or "") + prompt_file.chmod(0o600) + return agent_dir, prompt_file + + +def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan: + git_gate_dir = git_gate_state_dir(slug) + git_gate_dir.mkdir(parents=True, exist_ok=True) + return GitGate().prepare(bottle, slug, git_gate_dir) + + +def prepare_egress( + bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan, +) -> EgressPlan: + egress_dir = egress_state_dir(slug) + egress_dir.mkdir(parents=True, exist_ok=True) + return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes) + + +def prepare_supervise( + bottle: ManifestBottle, slug: str, *, dockerfile_content: str = "", +) -> SupervisePlan | None: + """Prepare the supervise sidecar state dir. Returns None when + bottle.supervise is falsy.""" + if not bottle.supervise: + return None + supervise_dir = supervise_state_dir(slug) + supervise_dir.mkdir(parents=True, exist_ok=True) + return Supervise().prepare(slug, supervise_dir, dockerfile_content=dockerfile_content) + + +def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan: + """Fold provision.env_vars into guest_env (setdefault semantics) + and return a new plan with the merged guest_env.""" + merged = dict(provision.guest_env) + for key, val in provision.env_vars.items(): + merged.setdefault(key, val) + return replace(provision, guest_env=merged) + + +def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str: + """Resolve a manifest-supplied dockerfile path relative to user_cwd.""" + path = Path(os.path.expanduser(path_value)) + if not path.is_absolute(): + path = Path(spec.user_cwd) / path + return str(path) + + +__all__ = [ + "merge_provision_env_vars", + "mint_slug", + "prepare_agent_state_dir", + "prepare_egress", + "prepare_git_gate", + "prepare_supervise", + "resolve_manifest_dockerfile", + "write_launch_metadata", +] diff --git a/bot_bottle/backend/smolmachines/resolve_plan.py b/bot_bottle/backend/smolmachines/resolve_plan.py index c569e30..2db756f 100644 --- a/bot_bottle/backend/smolmachines/resolve_plan.py +++ b/bot_bottle/backend/smolmachines/resolve_plan.py @@ -11,26 +11,22 @@ No VM bringup — that's `launch.launch`'s job.""" from __future__ import annotations import os -from datetime import datetime, timezone -from dataclasses import replace from pathlib import Path from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider from ...backend import BottleSpec -from ...bottle_state import ( - BottleMetadata, - agent_state_dir, - bottle_identity, - egress_state_dir, - git_gate_state_dir, - supervise_state_dir, - write_metadata, -) -from ...egress import Egress from ...env import resolve_env -from ...git_gate import GitGate -from ...supervise import Supervise from ...workspace import workspace_plan as resolve_workspace_plan +from ..resolve_common import ( + merge_provision_env_vars, + mint_slug, + prepare_agent_state_dir, + prepare_egress, + prepare_git_gate, + prepare_supervise, + resolve_manifest_dockerfile, + write_launch_metadata, +) from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight @@ -62,21 +58,8 @@ def resolve_plan( guest_home = "/home/node" workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) - slug = spec.identity or bottle_identity(spec.agent_name) - - # Record minimal metadata so `cli.py resume` can recover the - # slug. Same schema as the docker backend. - write_metadata(BottleMetadata( - identity=slug, - agent_name=spec.agent_name, - cwd=spec.user_cwd if spec.copy_cwd else "", - copy_cwd=spec.copy_cwd, - started_at=datetime.now(timezone.utc).isoformat(), - compose_project="", - backend="smolmachines", - label=spec.label, - color=spec.color, - )) + slug = mint_slug(spec) + write_launch_metadata(slug, spec, compose_project="", backend="smolmachines") subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) @@ -98,22 +81,8 @@ def resolve_plan( "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", } - git_gate_dir = git_gate_state_dir(slug) - git_gate_dir.mkdir(parents=True, exist_ok=True) - git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir) - - # Prompt file is always written (mode 0o600) so the in-VM - # path always exists. Content is the agent's `prompt` - # field (markdown body) — empty for agents with no prompt. - # claude-code reads it via --append-system-prompt-file only - # when non-empty, but the file must exist either way to - # match the docker backend's contract. - agent_dir = agent_state_dir(slug) - agent_dir.mkdir(parents=True, exist_ok=True) - prompt_file = agent_dir / "prompt.txt" - agent = manifest.agents[spec.agent_name] - prompt_file.write_text(agent.prompt or "") - prompt_file.chmod(0o600) + git_gate_plan = prepare_git_gate(bottle, slug) + agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) machine_name = f"bot-bottle-{slug}" if provider.template in PROVIDER_TEMPLATES: @@ -123,7 +92,7 @@ def resolve_plan( agent_dockerfile_path = str(provider_obj.dockerfile) if provider.dockerfile: agent_image_ref = f"bot-bottle-{provider.template}:{slug}" - agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec) + agent_dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec) agent_provision = agent_provision_plan( template=provider.template, dockerfile=agent_dockerfile_path, @@ -137,22 +106,10 @@ def resolve_plan( label=spec.label, color=spec.color, ) - merged_guest_env = dict(agent_provision.guest_env) - for key, val in agent_provision.env_vars.items(): - merged_guest_env.setdefault(key, val) - agent_provision = replace(agent_provision, guest_env=merged_guest_env) + agent_provision = merge_provision_env_vars(agent_provision) - egress_dir = egress_state_dir(slug) - egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = Egress().prepare( - bottle, slug, egress_dir, agent_provision.egress_routes, - ) - - supervise_plan = None - if bottle.supervise: - supervise_dir = supervise_state_dir(slug) - supervise_dir.mkdir(parents=True, exist_ok=True) - supervise_plan = Supervise().prepare(slug, supervise_dir) + egress_plan = prepare_egress(bottle, slug, agent_provision) + supervise_plan = prepare_supervise(bottle, slug) return SmolmachinesBottlePlan( spec=spec, @@ -172,10 +129,3 @@ def resolve_plan( agent_provision=agent_provision, workspace_plan=workspace_plan, ) - - -def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str: - path = Path(os.path.expanduser(path_value)) - if not path.is_absolute(): - path = Path(spec.user_cwd) / path - return str(path) diff --git a/tests/unit/test_smolmachines_prepare.py b/tests/unit/test_smolmachines_prepare.py index 82ee261..a7e6b5c 100644 --- a/tests/unit/test_smolmachines_prepare.py +++ b/tests/unit/test_smolmachines_prepare.py @@ -55,9 +55,9 @@ class TestSmolmachinesResolveEnv(unittest.TestCase): patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight"), patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_bundle_subnet", return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")), - patch("bot_bottle.backend.smolmachines.resolve_plan.GitGate") as mock_gg, - patch("bot_bottle.backend.smolmachines.resolve_plan.Egress") as mock_eg, - patch("bot_bottle.backend.smolmachines.resolve_plan.Supervise"), + patch("bot_bottle.backend.resolve_common.GitGate") as mock_gg, + patch("bot_bottle.backend.resolve_common.Egress") as mock_eg, + patch("bot_bottle.backend.resolve_common.Supervise"), patch( "bot_bottle.backend.smolmachines.resolve_plan.agent_provision_plan" ) as mock_app,