PRD 0003: Bottle Backend abstraction #5
Reference in New Issue
Block a user
Delete Branch "add-bottle-factory-abstraction"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements PRD 0003.
BottleBackendis the abstraction;DockerBottleBackendis the only concrete impl. Selected viaCLAUDE_BOTTLE_BACKEND(defaultdocker).All Docker-specific behavior — container/network lifecycle, pipelock sidecar, env-file / argv serialization, runsc detection — lives under
claude_bottle/backend/docker/. The top level (manifest.py,env.py,pipelock.pyyaml + allowlist,util.py,cli.py) is backend-agnostic.bottles[].runtimeis removed from the manifest; gVisor (runsc) is auto-detected by the backend.docs(prd): add 0003 bottle factory abstractionto PRD 0003: Bottle factory abstractionThe Docker factory had absorbed live container ops but left the host-side prep (image-name resolution, container-name collision retry, pipelock yaml generation, env_resolve writes, host validation) in cli/start.py. That kept ~half the Docker-specific logic outside the abstraction. Split the factory into two phases: prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan Resolves names, validates skills/SSH, writes scratch files. No Docker resources created yet. launch_docker_bottle(plan) -> ContextManager[Bottle] Builds image, creates networks, boots pipelock, runs the agent container, provisions files. Teardown on exit. DockerBottleSpec shrinks to intent-only inputs (manifest, agent name, --cwd flag, user_cwd, forward_oauth_token). The CLI no longer references docker_mod, pipelock, skills, ssh, or env_resolve. get_bottle_factory becomes get_bottle_platform returning a BottlePlatform with .prepare and .launch — one selectable thing per platform. The Bottle handle now remembers the in-container prompt path and adds --append-system-prompt-file to claude's argv when present, so the CLI no longer needs to know the path. cmd_start: ~148 lines down from 229. Tests pass; dry-run output byte-identical.cmd_cleanup used to only sweep running containers via `docker ps`, missing stopped pipelock sidecars and orphaned networks entirely. On my host the new version surfaced ~10 stranded networks left behind by SIGKILLed sessions — the kind of thing the old command implied it was handling. New shape, symmetric with start: - BottleCleanupPlan (abstract, in bottles/__init__.py) with `print` + `empty` abstract members. - DockerBottleCleanupPlan (concrete, in bottles/docker.py) carrying the resolved tuples of containers and networks. - BottlePlatform gains abstract prepare_cleanup() + cleanup(plan). DockerBottlePlatform implements both: - prepare_cleanup: docker ps -a + docker network ls, both filtered to ^claude-bottle-, sorted for stable output. - cleanup: docker rm -f containers first (they hold the network attachment), then docker network rm. - cmd_cleanup is now ~25 lines: prepare → print → y/N → cleanup.The Docker bridge / internal network primitives are Docker-specific; they belong inside the Docker platform package alongside util.py and the rest. Same logic the earlier top-level docker.py move followed. Imports: - platform.py: `from ... import network as network_mod` -> `from . import network as network_mod` - network.py: `from .log import ...` -> `from ...log import ...` - tests/test_orphan_cleanup.py: from claude_bottle.network -> from claude_bottle.platform.docker.networkLift the file-copying-into-the-running-container step out of DockerBottleBackend._provision_container into its own class. The backend now holds a DockerBottleProvisioner instance and delegates the post-launch provisioning to it. - BottleProvisioner (abstract) in backend/__init__.py with a `provision(plan, target) -> str | None` method. - DockerBottleProvisioner (concrete) in backend/docker/provisioner.py inheriting from the base, narrowing plan to DockerBottlePlan via isinstance, and carrying the prompt/skills/SSH/.git copy logic unchanged. - DockerBottleBackend keeps a class-level DockerBottleProvisioner() and calls self._provisioner.provision(plan, container) from launch. _provision_container method removed. No behavior change.BottleProvisioner had no independent identity — no state, only one caller, never selected, never crossed a method boundary as data. It was a method dressed up as a class. Reverting that turn: - BottleBackend gains an abstract provision(plan, target). - DockerBottleBackend.provision absorbs the body that lived on DockerBottleProvisioner. - backend/docker/provisioner.py deleted. - BottleProvisioner ABC removed from backend/__init__.py. - launch now calls self.provision(plan, container) directly. Net: -1 file, -1 class, -1 ABC. Same behavior; tests pass.The whole module folds into two methods on the backend: validate_skills(skills) — called from prepare; fails loudly when a named skill is missing on the host so the user doesn't get a y/N for a plan that's already known to break. _host_skill_dir(name) — private helper used by both validate_skills and provision_skills. skills.py is deleted; the four prior functions (host_skill_dir, host_skill_exists, require_host_skill, skills_validate_all) collapse into the two above without losing the pre-y/N validation.Add a frozen ProxyPlan dataclass to pipelock.py (currently one field: yaml_path; kept as a class so future proxy-level state has a home). - prepare_proxy(spec, stage_dir) now returns pipelock.ProxyPlan instead of a raw Path. - DockerBottlePlan replaces pipelock_yaml_path + pipelock_yaml_filename with a single proxy: ProxyPlan field. - launch reads plan.proxy.yaml_path.parent / .name when calling pipelock_start. Eventually pipelock_start should just take a Path but that's a separate change.1f3169e453toff962d2893PipelockProxy becomes an ABC with the platform-agnostic prepare/_build_pipelock_yaml as concrete methods and start/stop as abstract. Docker-specific sidecar lifecycle moves to a new sibling file: claude_bottle/backend/docker/pipelock.py DockerPipelockProxy(PipelockProxy) — implements start (docker create/cp/network connect/start) and stop (docker inspect/rm -f). DockerBottleBackend._proxy is now a DockerPipelockProxy instance. Tests that previously instantiated PipelockProxy() directly switch to DockerPipelockProxy() (the base is no longer constructable).New file claude_bottle/backend/util.py for cross-backend host-side helpers: host_skill_dir(name) — resolves $HOME/.claude/skills/<name> docker/util.py gains: docker_exec_root(container, argv) — `docker exec -u 0` wrapper used by SSH provisioning DockerBottleBackend drops the two methods that wrapped these (`_host_skill_dir`, `_docker_exec_root`) — they had no instance state and just lived on the class for organizational reasons. Call sites now use the imported functions directly.PRD 0003: Bottle factory abstractionto PRD 0003: Bottle Backend abstraction