diff --git a/bot_bottle/backend/base.py b/bot_bottle/backend/base.py index 02e821f2..65478387 100644 --- a/bot_bottle/backend/base.py +++ b/bot_bottle/backend/base.py @@ -23,14 +23,14 @@ from dataclasses import dataclass from pathlib import Path from typing import Generator, Generic, Sequence, TypeVar -from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan +from ..agent_provider import AgentProvisionPlan, get_provider from ..egress import EgressPlan from ..git_gate import GitGatePlan from ..log import die, info from ..util import expand_tilde from ..manifest import Manifest, ManifestIndex from ..supervisor.plan import SupervisePlan -from ..env import resolve_env, ResolvedEnv +from ..env import ResolvedEnv from ..workspace import WorkspacePlan, workspace_plan from .print_util import print_multi, visible_agent_env_names from .util import host_skill_dir @@ -296,82 +296,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): backend-specific resolution (names, scratch files, etc.). The validation step is enforced here so a future backend cannot accidentally skip it. No remote/runtime resources are created.""" - from .resolve_common import ( - merge_provision_env_vars, - mint_slug, - prepare_agent_state_dir, - prepare_egress, - prepare_git_gate, - prepare_supervise, - reject_nested_containers, - resolve_manifest_dockerfile, - write_launch_metadata, - ) - - manifest = self._validate(spec) - - if not self.supports_nested_containers: - reject_nested_containers(self.name, manifest) - - self._preflight() - - from ..git_gate import GitGate - manifest = GitGate().preflight_host_keys( - manifest, - headless=spec.headless, - home_md=spec.manifest.home_md, - ) - - manifest_bottle = manifest.bottle - manifest_agent_provider = manifest_bottle.agent_provider - agent_provider = get_provider(manifest_agent_provider.template) - resolved_env = resolve_env(manifest) - workspace = workspace_plan(spec, guest_home=agent_provider.guest_home) - - slug = mint_slug(spec) - write_launch_metadata(slug, spec, compose_project="", backend=self.name) - - # Manifest may override the Dockerfile per-bottle; otherwise fall - # back to the provider plugin's bundled Dockerfile (next to its - # agent_provider.py module). - if manifest_agent_provider.dockerfile: - agent_dockerfile_path = resolve_manifest_dockerfile( - manifest_agent_provider.dockerfile, spec, - ) - else: - agent_dockerfile_path = str(agent_provider.dockerfile) - - agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest) - - agent_provision_plan = build_agent_provision_plan( - template=manifest_agent_provider.template, - dockerfile=agent_dockerfile_path, - state_dir=agent_dir, - instance_name=f"bot-bottle-{slug}", - prompt_file=prompt_file, - guest_env=self._build_guest_env(resolved_env), - forward_host_credentials=manifest_agent_provider.forward_host_credentials, - auth_token=manifest_agent_provider.auth_token, - host_env=dict(os.environ), - trusted_project_path=workspace.workdir, - label=spec.label, - color=spec.color, - provider_settings=manifest_agent_provider.settings, - ) - agent_provision_plan = merge_provision_env_vars(agent_provision_plan) - egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan) - supervise_plan = prepare_supervise(manifest_bottle, slug) - git_gate_plan = prepare_git_gate(manifest_bottle, slug) + from .preparation import BottlePreparationPlanner + prepared = BottlePreparationPlanner(self).prepare(spec) return self._resolve_plan( spec, - manifest=manifest, - slug=slug, - resolved_env=resolved_env, - agent_provision_plan=agent_provision_plan, - egress_plan=egress_plan, - supervise_plan=supervise_plan, - git_gate_plan=git_gate_plan, + manifest=prepared.manifest, + slug=prepared.slug, + resolved_env=prepared.resolved_env, + agent_provision_plan=prepared.agent_provision_plan, + egress_plan=prepared.egress_plan, + supervise_plan=prepared.supervise_plan, + git_gate_plan=prepared.git_gate_plan, stage_dir=stage_dir, ) diff --git a/bot_bottle/backend/preparation.py b/bot_bottle/backend/preparation.py new file mode 100644 index 00000000..6b445964 --- /dev/null +++ b/bot_bottle/backend/preparation.py @@ -0,0 +1,119 @@ +"""Backend-neutral preparation planner. + +This module owns the shared transformation from a CLI ``BottleSpec`` to the +typed inputs consumed by a concrete backend's ``_resolve_plan``. Backend +classes retain only their validation/preflight/env hooks and their +backend-specific final resolution. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider +from ..egress import EgressPlan +from ..env import ResolvedEnv, resolve_env +from ..git_gate import GitGate, GitGatePlan +from ..manifest import Manifest +from ..supervisor.plan import SupervisePlan +from ..workspace import workspace_plan +from .resolve_common import ( + merge_provision_env_vars, + mint_slug, + prepare_agent_state_dir, + prepare_egress, + prepare_git_gate, + prepare_supervise, + reject_nested_containers, + resolve_manifest_dockerfile, + write_launch_metadata, +) + +if TYPE_CHECKING: + from .base import BottleSpec + + +class PreparationBackend(Protocol): + """Backend hooks needed by the shared planner.""" + + name: str + supports_nested_containers: bool + + def _validate(self, spec: BottleSpec) -> Manifest: ... + def _preflight(self) -> None: ... + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: ... + + +@dataclass(frozen=True) +class PreparedBottle: + """Typed, backend-neutral result of shared launch preparation.""" + + manifest: Manifest + slug: str + resolved_env: ResolvedEnv + agent_provision_plan: AgentProvisionPlan + egress_plan: EgressPlan + git_gate_plan: GitGatePlan + supervise_plan: SupervisePlan | None + + +class BottlePreparationPlanner: + """Run the common, side-effect-limited part of bottle preparation.""" + + def __init__(self, backend: PreparationBackend) -> None: + self._backend = backend + + def prepare(self, spec: BottleSpec) -> PreparedBottle: + backend = self._backend + manifest = backend._validate(spec) + if not backend.supports_nested_containers: + reject_nested_containers(backend.name, manifest) + + backend._preflight() + manifest = GitGate().preflight_host_keys( + manifest, + headless=spec.headless, + home_md=spec.manifest.home_md, + ) + + bottle = manifest.bottle + provider_config = bottle.agent_provider + provider = get_provider(provider_config.template) + resolved_env = resolve_env(manifest) + workspace = workspace_plan(spec, guest_home=provider.guest_home) + slug = mint_slug(spec) + write_launch_metadata(slug, spec, compose_project="", backend=backend.name) + + dockerfile = ( + resolve_manifest_dockerfile(provider_config.dockerfile, spec) + if provider_config.dockerfile + else str(provider.dockerfile) + ) + agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest) + provision = build_agent_provision_plan( + template=provider_config.template, + dockerfile=dockerfile, + state_dir=agent_dir, + instance_name=f"bot-bottle-{slug}", + prompt_file=prompt_file, + guest_env=backend._build_guest_env(resolved_env), + forward_host_credentials=provider_config.forward_host_credentials, + auth_token=provider_config.auth_token, + host_env=dict(os.environ), + trusted_project_path=workspace.workdir, + label=spec.label, + color=spec.color, + provider_settings=provider_config.settings, + ) + provision = merge_provision_env_vars(provision) + return PreparedBottle( + manifest=manifest, + slug=slug, + resolved_env=resolved_env, + agent_provision_plan=provision, + egress_plan=prepare_egress(bottle, slug, provision), + git_gate_plan=prepare_git_gate(bottle, slug), + supervise_plan=prepare_supervise(bottle, slug), + ) diff --git a/tests/unit/test_architecture_guardrails.py b/tests/unit/test_architecture_guardrails.py index 47ed6513..6efb0397 100644 --- a/tests/unit/test_architecture_guardrails.py +++ b/tests/unit/test_architecture_guardrails.py @@ -64,3 +64,16 @@ class TestRuntimeModuleSizes(unittest.TestCase): ".addon_core import" in text: violations.append(str(path.relative_to(ROOT))) self.assertEqual([], violations) + + def test_backend_contract_does_not_absorb_preparation_logic(self) -> None: + caps = { + ROOT / "bot_bottle" / "backend" / "base.py": 580, + ROOT / "bot_bottle" / "backend" / "preparation.py": 160, + } + oversized = [ + f"{path.relative_to(ROOT)} " + f"({len(path.read_text().splitlines())}>{cap})" + for path, cap in caps.items() + if len(path.read_text().splitlines()) > cap + ] + self.assertEqual([], oversized)