refactor(backend): extract shared bottle preparation planner
This commit is contained in:
+11
-75
@@ -23,14 +23,14 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Generic, Sequence, TypeVar
|
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 ..egress import EgressPlan
|
||||||
from ..git_gate import GitGatePlan
|
from ..git_gate import GitGatePlan
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from ..manifest import Manifest, ManifestIndex
|
from ..manifest import Manifest, ManifestIndex
|
||||||
from ..supervisor.plan import SupervisePlan
|
from ..supervisor.plan import SupervisePlan
|
||||||
from ..env import resolve_env, ResolvedEnv
|
from ..env import ResolvedEnv
|
||||||
from ..workspace import WorkspacePlan, workspace_plan
|
from ..workspace import WorkspacePlan, workspace_plan
|
||||||
from .print_util import print_multi, visible_agent_env_names
|
from .print_util import print_multi, visible_agent_env_names
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
@@ -296,82 +296,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
backend-specific resolution (names, scratch files, etc.). The
|
backend-specific resolution (names, scratch files, etc.). The
|
||||||
validation step is enforced here so a future backend cannot
|
validation step is enforced here so a future backend cannot
|
||||||
accidentally skip it. No remote/runtime resources are created."""
|
accidentally skip it. No remote/runtime resources are created."""
|
||||||
from .resolve_common import (
|
from .preparation import BottlePreparationPlanner
|
||||||
merge_provision_env_vars,
|
prepared = BottlePreparationPlanner(self).prepare(spec)
|
||||||
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)
|
|
||||||
|
|
||||||
return self._resolve_plan(
|
return self._resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
manifest=manifest,
|
manifest=prepared.manifest,
|
||||||
slug=slug,
|
slug=prepared.slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=prepared.resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=prepared.agent_provision_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=prepared.egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=prepared.supervise_plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=prepared.git_gate_plan,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -64,3 +64,16 @@ class TestRuntimeModuleSizes(unittest.TestCase):
|
|||||||
".addon_core import" in text:
|
".addon_core import" in text:
|
||||||
violations.append(str(path.relative_to(ROOT)))
|
violations.append(str(path.relative_to(ROOT)))
|
||||||
self.assertEqual([], violations)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user