From 44e2b5a897ef5c29f6905c1aaaa942f2c6313a67 Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 24 Jul 2026 14:15:04 -0400 Subject: [PATCH] refactor(supervise): split the supervise plane by tier; per-service store managers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each service its own store package + manager, and cut the supervise module along the control/data-plane boundary so nothing in the shared layer reaches up into the orchestrator. Stores, by owner: - bot_bottle/store/ keeps only the shared base (DbStore, migrations) and the concrete stores that aren't service-owned (audit_store, config_store). - bot_bottle/orchestrator/store/ now houses the orchestrator-owned stores — queue_store (supervise queue), secret_store, config_store — plus a new orchestrator store_manager that migrates them (composing audit/config downward from the base). The old shared store_manager is gone. Supervise plane, by tier: - bot_bottle/supervisor/ (NEUTRAL, importable by every tier including the gateway): types.py (the Proposal/Response/AuditEntry wire types + the tool/ status/poll constants + the shared daemon constants moved out of supervise.py) and plan.py (SupervisePlan, a pure DTO). - bot_bottle/orchestrator/supervisor/ (orchestrator-only): queue.py (the queue/audit I/O wrappers + render_diff + sha256_hex) and supervise.py (the Supervise lifecycle that stages the DB via the store manager). Its __init__ re-exports the neutral vocabulary so orchestrator-side callers import from one place. The gateway now imports only bot_bottle.supervisor.types (never bot_bottle.supervise), so the data plane holds no code dependency on the orchestrator — it reaches the queue over the control-plane RPC. This removes the circular import that moving queue_store under orchestrator introduced (supervise -> orchestrator -> service -> supervise). supervise_types.py -> supervisor/types.py; supervise.py deleted (split). Full unit suite green (2251). Co-Authored-By: Claude Opus 4.8 --- bot_bottle/backend/__init__.py | 2 +- bot_bottle/backend/consolidated_util.py | 4 +- bot_bottle/backend/docker/backend.py | 4 +- .../backend/docker/consolidated_compose.py | 2 +- .../backend/docker/consolidated_launch.py | 2 +- bot_bottle/backend/docker/launch.py | 4 +- bot_bottle/backend/docker/resolve_plan.py | 2 +- bot_bottle/backend/firecracker/backend.py | 2 +- .../firecracker/consolidated_launch.py | 2 +- bot_bottle/backend/firecracker/launch.py | 6 +- .../backend/firecracker/resolve_plan.py | 2 +- bot_bottle/backend/macos_container/backend.py | 2 +- .../macos_container/consolidated_launch.py | 2 +- bot_bottle/backend/macos_container/launch.py | 6 +- .../backend/macos_container/resolve_plan.py | 2 +- bot_bottle/backend/resolve_common.py | 3 +- bot_bottle/cli/__init__.py | 2 +- bot_bottle/cli/supervise.py | 2 +- bot_bottle/gateway/egress_addon.py | 2 +- bot_bottle/gateway/git_gate_render.py | 4 +- bot_bottle/gateway/supervise_server.py | 2 +- bot_bottle/orchestrator/__main__.py | 2 +- bot_bottle/orchestrator/control_plane.py | 2 +- bot_bottle/orchestrator/service.py | 6 +- bot_bottle/orchestrator/store/__init__.py | 10 + .../orchestrator/{ => store}/config_store.py | 6 +- .../{ => orchestrator}/store/queue_store.py | 10 +- .../orchestrator/{ => store}/secret_store.py | 0 .../{ => orchestrator}/store/store_manager.py | 32 +- .../orchestrator/supervisor/__init__.py | 34 ++ bot_bottle/orchestrator/supervisor/queue.py | 141 +++++++++ .../orchestrator/supervisor/supervise.py | 36 +++ bot_bottle/store/__init__.py | 14 +- bot_bottle/store/audit_store.py | 4 +- bot_bottle/supervise.py | 298 ------------------ bot_bottle/supervisor/__init__.py | 16 + bot_bottle/supervisor/plan.py | 26 ++ .../types.py} | 25 ++ tests/integration/test_gateway_image.py | 2 +- tests/unit/_docker_bottle_plan.py | 2 +- tests/unit/test_cli_dispatch.py | 2 +- tests/unit/test_config_store.py | 2 +- tests/unit/test_contrib_claude_provider.py | 2 +- tests/unit/test_contrib_codex_provider.py | 2 +- tests/unit/test_orchestrator_config_store.py | 2 +- tests/unit/test_orchestrator_control_plane.py | 4 +- tests/unit/test_orchestrator_secret_store.py | 2 +- tests/unit/test_orchestrator_service.py | 6 +- tests/unit/test_supervise.py | 6 +- tests/unit/test_supervise_cli.py | 2 +- tests/unit/test_supervise_edge.py | 6 +- tests/unit/test_supervise_server.py | 4 +- 52 files changed, 380 insertions(+), 385 deletions(-) create mode 100644 bot_bottle/orchestrator/store/__init__.py rename bot_bottle/orchestrator/{ => store}/config_store.py (96%) rename bot_bottle/{ => orchestrator}/store/queue_store.py (96%) rename bot_bottle/orchestrator/{ => store}/secret_store.py (100%) rename bot_bottle/{ => orchestrator}/store/store_manager.py (54%) create mode 100644 bot_bottle/orchestrator/supervisor/__init__.py create mode 100644 bot_bottle/orchestrator/supervisor/queue.py create mode 100644 bot_bottle/orchestrator/supervisor/supervise.py delete mode 100644 bot_bottle/supervise.py create mode 100644 bot_bottle/supervisor/__init__.py create mode 100644 bot_bottle/supervisor/plan.py rename bot_bottle/{supervise_types.py => supervisor/types.py} (84%) diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index ff5223e..79cd7af 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -48,7 +48,7 @@ from ..git_gate import GitGatePlan from ..log import die, info, warn from ..util import read_tty_line from ..manifest import Manifest, ManifestIndex -from ..supervise import SupervisePlan +from ..supervisor.plan import SupervisePlan from ..util import expand_tilde from ..env import resolve_env, ResolvedEnv from ..workspace import WorkspacePlan, workspace_plan diff --git a/bot_bottle/backend/consolidated_util.py b/bot_bottle/backend/consolidated_util.py index 520ca0a..3aa8590 100644 --- a/bot_bottle/backend/consolidated_util.py +++ b/bot_bottle/backend/consolidated_util.py @@ -13,7 +13,7 @@ from ..egress import EgressPlan from ..git_gate import GitGatePlan from ..orchestrator.client import OrchestratorClient, RegisteredBottle from ..orchestrator.registration import registration_inputs -from ..orchestrator.secret_store import new_env_var_secret +from ..orchestrator.store.secret_store import new_env_var_secret from .docker.gateway_provision import GatewayTransport, deprovision_git_gate, provision_git_gate @@ -58,7 +58,7 @@ def teardown_consolidated( ) -> None: """Deregister the bottle and remove its git-gate state. Both steps are idempotent so this is safe from a cleanup trap.""" - from ..orchestrator.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS + from ..orchestrator.store.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS OrchestratorClient( orchestrator_url, timeout=timeout if timeout is not None else DEFAULT_TEARDOWN_TIMEOUT_SECONDS, diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index b2ae45b..8cab6d7 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -24,12 +24,12 @@ from contextlib import contextmanager from pathlib import Path from typing import Generator, Sequence -from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT +from ...supervisor.types import SUPERVISE_HOSTNAME, SUPERVISE_PORT from ...agent_provider import AgentProvisionPlan from ...egress import EgressPlan from ...env import ResolvedEnv from ...git_gate import GitGatePlan -from ...supervise import SupervisePlan +from ...supervisor.plan import SupervisePlan from ...manifest import Manifest from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from . import cleanup as _cleanup diff --git a/bot_bottle/backend/docker/consolidated_compose.py b/bot_bottle/backend/docker/consolidated_compose.py index 0065224..45296b5 100644 --- a/bot_bottle/backend/docker/consolidated_compose.py +++ b/bot_bottle/backend/docker/consolidated_compose.py @@ -17,7 +17,7 @@ from __future__ import annotations from typing import Any from ...egress import egress_agent_env_entries -from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME +from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from .bottle_plan import DockerBottlePlan from .egress import EGRESS_PORT diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py index 482a30f..2f57efb 100644 --- a/bot_bottle/backend/docker/consolidated_launch.py +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -22,7 +22,7 @@ from ...git_gate import GitGatePlan from ...orchestrator.client import OrchestratorClient from ...gateway import GATEWAY_NETWORK from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService -from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME +from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.reprovision import reprovision_bottles from ..consolidated_util import provision_bottle from ..consolidated_util import teardown_consolidated as _teardown_util diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 47e2f64..78c8785 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -64,7 +64,7 @@ from .compose import ( write_compose_file, ) from .consolidated_compose import consolidated_agent_compose -from ...orchestrator.config_store import resolve_teardown_timeout +from ...orchestrator.store.config_store import resolve_teardown_timeout from .consolidated_launch import launch_consolidated, teardown_consolidated from ...orchestrator.lifecycle import INFRA_NAME from .gateway import DockerGateway @@ -207,7 +207,7 @@ def launch( # spec, value only in the subprocess env so it is never written to disk. compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env} if plan.env_var_secret: - from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME + from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME compose_env[ENV_VAR_SECRET_NAME] = plan.env_var_secret info( f"docker compose up -d (project {project}, agent on shared " diff --git a/bot_bottle/backend/docker/resolve_plan.py b/bot_bottle/backend/docker/resolve_plan.py index c48e3ac..eac5d2a 100644 --- a/bot_bottle/backend/docker/resolve_plan.py +++ b/bot_bottle/backend/docker/resolve_plan.py @@ -19,7 +19,7 @@ from ...env import ResolvedEnv from ...agent_provider import AgentProvisionPlan from ...egress import EgressPlan from ...manifest import Manifest -from ...supervise import SupervisePlan +from ...supervisor.plan import SupervisePlan from ...git_gate import GitGatePlan def preflight() -> None: diff --git a/bot_bottle/backend/firecracker/backend.py b/bot_bottle/backend/firecracker/backend.py index a8a9df9..a528182 100644 --- a/bot_bottle/backend/firecracker/backend.py +++ b/bot_bottle/backend/firecracker/backend.py @@ -17,7 +17,7 @@ from ...egress import EgressPlan from ...env import ResolvedEnv from ...git_gate import GitGatePlan from ...manifest import Manifest -from ...supervise import SupervisePlan +from ...supervisor.plan import SupervisePlan from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from . import cleanup as _cleanup from . import enumerate as _enumerate diff --git a/bot_bottle/backend/firecracker/consolidated_launch.py b/bot_bottle/backend/firecracker/consolidated_launch.py index cb72690..503d0af 100644 --- a/bot_bottle/backend/firecracker/consolidated_launch.py +++ b/bot_bottle/backend/firecracker/consolidated_launch.py @@ -38,7 +38,7 @@ from ...orchestrator.lifecycle import ( OrchestratorStartError, # re-exported so callers can catch it ) from ...orchestrator.reprovision import reprovision_bottles -from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME +from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ..consolidated_util import ( provision_bottle, teardown_consolidated as _teardown_util, diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index d72f40c..37bfb2d 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -48,14 +48,14 @@ from ...git_gate import ( ) from ...image_cache import check_stale_path from ...log import die, info, warn -from ...supervise import SUPERVISE_PORT +from ...supervisor.types import SUPERVISE_PORT from ..docker.egress import EGRESS_PORT from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from . import firecracker_vm, image_builder, isolation_probe, netpool, util from .bottle import FirecrackerBottle from .bottle_plan import FirecrackerBottlePlan -from ...orchestrator.config_store import resolve_teardown_timeout -from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME +from ...orchestrator.store.config_store import resolve_teardown_timeout +from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from .consolidated_launch import ( launch_consolidated, persist_env_var_secret, diff --git a/bot_bottle/backend/firecracker/resolve_plan.py b/bot_bottle/backend/firecracker/resolve_plan.py index 6292175..f5b0dd6 100644 --- a/bot_bottle/backend/firecracker/resolve_plan.py +++ b/bot_bottle/backend/firecracker/resolve_plan.py @@ -9,7 +9,7 @@ from ...egress import EgressPlan from ...env import ResolvedEnv from ...git_gate import GitGatePlan from ...manifest import Manifest -from ...supervise import SupervisePlan +from ...supervisor.plan import SupervisePlan from .. import BottleSpec from . import util from .bottle_plan import FirecrackerBottlePlan diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py index 2448c00..71b4d5e 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -10,7 +10,7 @@ from ...agent_provider import AgentProvisionPlan from ...egress import EgressPlan from ...env import ResolvedEnv from ...git_gate import GitGatePlan -from ...supervise import SupervisePlan +from ...supervisor.plan import SupervisePlan from ...manifest import Manifest from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from . import cleanup as _cleanup diff --git a/bot_bottle/backend/macos_container/consolidated_launch.py b/bot_bottle/backend/macos_container/consolidated_launch.py index 6c25f5e..94976fb 100644 --- a/bot_bottle/backend/macos_container/consolidated_launch.py +++ b/bot_bottle/backend/macos_container/consolidated_launch.py @@ -39,7 +39,7 @@ from ...git_gate import GitGatePlan from ...log import info from ...orchestrator.client import OrchestratorClient, OrchestratorClientError from ...orchestrator.reprovision import reprovision_bottles -from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME +from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ..consolidated_util import ( provision_bottle, teardown_consolidated as _teardown_util, diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index d54a20a..95f5d61 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -56,7 +56,7 @@ from ...gateway.git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT from ...image_cache import check_stale from ...log import die, info, warn from .. import BottleImages -from ...supervise import SUPERVISE_PORT +from ...supervisor.types import SUPERVISE_PORT from ..docker.egress import EGRESS_PORT from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from . import util as container_mod @@ -68,8 +68,8 @@ from .gateway_hosts import ( ) from . import nested_containers as nested_containers_mod from .bottle_plan import MacosContainerBottlePlan -from ...orchestrator.config_store import resolve_teardown_timeout -from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret +from ...orchestrator.store.config_store import resolve_teardown_timeout +from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret from .consolidated_launch import ( GatewayEndpoint, ensure_gateway, diff --git a/bot_bottle/backend/macos_container/resolve_plan.py b/bot_bottle/backend/macos_container/resolve_plan.py index 4c9883e..f8fb975 100644 --- a/bot_bottle/backend/macos_container/resolve_plan.py +++ b/bot_bottle/backend/macos_container/resolve_plan.py @@ -8,7 +8,7 @@ from ...agent_provider import AgentProvisionPlan from ...egress import EgressPlan from ...env import ResolvedEnv from ...git_gate import GitGatePlan -from ...supervise import SupervisePlan +from ...supervisor.plan import SupervisePlan from ...manifest import Manifest from .. import BottleSpec from . import util as container_mod diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index 88f94ff..612291b 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -28,7 +28,8 @@ from ..egress import Egress, EgressPlan from ..git_gate import GitGate, GitGatePlan from ..log import die from ..manifest import Manifest, ManifestBottle -from ..supervise import Supervise, SupervisePlan +from ..supervisor.plan import SupervisePlan +from ..orchestrator.supervisor.supervise import Supervise from . import BottleSpec diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index c1618b6..d1d550a 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -10,7 +10,7 @@ import sys from ..errors import MissingEnvVarError from ..log import Die, die, error from ..manifest import ManifestError -from ..store.store_manager import StoreManager +from ..orchestrator.store.store_manager import StoreManager from ._common import PROG from . import list as _list_mod from .backend import cmd_backend diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index d87726c..abf7d70 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -27,7 +27,7 @@ from ..orchestrator.client import ( discover_orchestrator_url, ) -from ..supervise import ( +from ..supervisor.types import ( Proposal, TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK, diff --git a/bot_bottle/gateway/egress_addon.py b/bot_bottle/gateway/egress_addon.py index 112de8b..eda9402 100644 --- a/bot_bottle/gateway/egress_addon.py +++ b/bot_bottle/gateway/egress_addon.py @@ -41,7 +41,7 @@ from bot_bottle.gateway.egress_addon_core import ( scan_outbound, ) from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver -from bot_bottle.supervise_types import ( +from bot_bottle.supervisor.types import ( STATUS_APPROVED, STATUS_MODIFIED, STATUSES, diff --git a/bot_bottle/gateway/git_gate_render.py b/bot_bottle/gateway/git_gate_render.py index 294d675..d6fe825 100644 --- a/bot_bottle/gateway/git_gate_render.py +++ b/bot_bottle/gateway/git_gate_render.py @@ -283,10 +283,10 @@ from pathlib import Path # calling bottle exactly as a direct write once did. try: from bot_bottle.gateway.policy_resolver import PolicyResolver, PolicyResolveError - from bot_bottle.supervise_types import TOOL_GITLEAKS_ALLOW + from bot_bottle.supervisor.types import TOOL_GITLEAKS_ALLOW except ImportError: from policy_resolver import PolicyResolver, PolicyResolveError - from supervise_types import TOOL_GITLEAKS_ALLOW + from supervisor.types import TOOL_GITLEAKS_ALLOW report_path = Path(sys.argv[1]) source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "") diff --git a/bot_bottle/gateway/supervise_server.py b/bot_bottle/gateway/supervise_server.py index 26495a3..520f1da 100644 --- a/bot_bottle/gateway/supervise_server.py +++ b/bot_bottle/gateway/supervise_server.py @@ -62,7 +62,7 @@ from bot_bottle.gateway.egress_addon_core import ( LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict, ) from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver -from bot_bottle import supervise as _sv +from bot_bottle.supervisor import types as _sv # --- JSON-RPC / MCP plumbing ---------------------------------------------- diff --git a/bot_bottle/orchestrator/__main__.py b/bot_bottle/orchestrator/__main__.py index 38d4e0c..cd532f9 100644 --- a/bot_bottle/orchestrator/__main__.py +++ b/bot_bottle/orchestrator/__main__.py @@ -16,7 +16,7 @@ import secrets from pathlib import Path from .. import log -from ..store.store_manager import StoreManager +from .store.store_manager import StoreManager from .broker import LaunchBroker, StubBroker from .control_plane import make_server from .docker_broker import DockerBroker diff --git a/bot_bottle/orchestrator/control_plane.py b/bot_bottle/orchestrator/control_plane.py index 5760923..4daed70 100644 --- a/bot_bottle/orchestrator/control_plane.py +++ b/bot_bottle/orchestrator/control_plane.py @@ -65,7 +65,7 @@ from urllib.parse import urlsplit from ..control_auth import ROLE_CLI, ROLES, verify from ..paths import CONTROL_PLANE_TOKEN_ENV -from ..supervise_types import TOOLS +from ..supervisor.types import TOOLS from .service import Orchestrator # JSON body payload type (parsed request / rendered response). diff --git a/bot_bottle/orchestrator/service.py b/bot_bottle/orchestrator/service.py index d521937..bf6a14d 100644 --- a/bot_bottle/orchestrator/service.py +++ b/bot_bottle/orchestrator/service.py @@ -27,7 +27,7 @@ from datetime import datetime, timezone from .broker import LaunchBroker, LaunchRequest, sign_request from .registry import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore -from ..supervise import ( +from .supervisor import ( AuditEntry, COMPONENT_FOR_TOOL, POLL_STATUS_PENDING, @@ -104,7 +104,7 @@ class Orchestrator: if tokens: self._tokens[rec.bottle_id] = dict(tokens) if env_var_secret: - from .secret_store import encrypt_value + from .store.secret_store import encrypt_value encrypted = {k: encrypt_value(env_var_secret, v) for k, v in tokens.items()} self.registry.store_agent_secrets(rec.bottle_id, encrypted) req = LaunchRequest( @@ -367,7 +367,7 @@ class Orchestrator: value with *env_var_secret*, and restores ``_tokens[bottle_id]``. Returns True on success, False when no stored secrets exist for this bottle or decryption fails (wrong key / corrupt data).""" - from .secret_store import decrypt_value + from .store.secret_store import decrypt_value encrypted = self.registry.get_agent_secrets(bottle_id) if not encrypted: return False diff --git a/bot_bottle/orchestrator/store/__init__.py b/bot_bottle/orchestrator/store/__init__.py new file mode 100644 index 0000000..1c4a81d --- /dev/null +++ b/bot_bottle/orchestrator/store/__init__.py @@ -0,0 +1,10 @@ +"""Orchestrator-owned SQLite stores. + +The stores whose tables only the orchestrator (control plane) opens: the +supervise proposal/response `queue_store`, the agent-secret `secret_store`, and +the orchestrator `config_store`. They build on the shared `DbStore` / +`migrations` base in `bot_bottle.store`. + +Callers import the concrete module directly, e.g. +`from bot_bottle.orchestrator.store.queue_store import QueueStore`. +""" diff --git a/bot_bottle/orchestrator/config_store.py b/bot_bottle/orchestrator/store/config_store.py similarity index 96% rename from bot_bottle/orchestrator/config_store.py rename to bot_bottle/orchestrator/store/config_store.py index 7779b68..251f0ef 100644 --- a/bot_bottle/orchestrator/config_store.py +++ b/bot_bottle/orchestrator/store/config_store.py @@ -11,9 +11,9 @@ import os import sqlite3 from pathlib import Path -from ..store.db_store import DbStore -from ..store.migrations import TableMigrations -from ..paths import host_db_path +from ...store.db_store import DbStore +from ...store.migrations import TableMigrations +from ...paths import host_db_path TEARDOWN_TIMEOUT_ENV = "BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS" DEFAULT_TEARDOWN_TIMEOUT_SECONDS = 30.0 diff --git a/bot_bottle/store/queue_store.py b/bot_bottle/orchestrator/store/queue_store.py similarity index 96% rename from bot_bottle/store/queue_store.py rename to bot_bottle/orchestrator/store/queue_store.py index 09ef1cf..16637f9 100644 --- a/bot_bottle/store/queue_store.py +++ b/bot_bottle/orchestrator/store/queue_store.py @@ -7,12 +7,12 @@ import sqlite3 from pathlib import Path try: - from ..supervise_types import Proposal, Response - from ..paths import host_db_path - from .db_store import DbStore - from .migrations import TableMigrations + from ...supervisor.types import Proposal, Response + from ...paths import host_db_path + from ...store.db_store import DbStore + from ...store.migrations import TableMigrations except ImportError: - from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervisor.types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/orchestrator/secret_store.py b/bot_bottle/orchestrator/store/secret_store.py similarity index 100% rename from bot_bottle/orchestrator/secret_store.py rename to bot_bottle/orchestrator/store/secret_store.py diff --git a/bot_bottle/store/store_manager.py b/bot_bottle/orchestrator/store/store_manager.py similarity index 54% rename from bot_bottle/store/store_manager.py rename to bot_bottle/orchestrator/store/store_manager.py index e7729cc..2e40e0f 100644 --- a/bot_bottle/store/store_manager.py +++ b/bot_bottle/orchestrator/store/store_manager.py @@ -1,34 +1,34 @@ -"""Singleton manager for all bot-bottle SQLite stores (PRD 0013).""" +"""The orchestrator's store manager (PRD 0013 / 0070). + +One store manager per service. Post-PRD-0070 the orchestrator is the sole +opener of `bot-bottle.db`, so it owns migrating every table in it: the supervise +`queue_store` (local to `orchestrator.store`) plus the `audit_store` and +`config_store` from the shared `bot_bottle.store` base. The data plane never +touches these — it reaches state over the control-plane RPC. +""" from __future__ import annotations from pathlib import Path -try: - from .audit_store import AuditStore - from .config_store import ConfigStore - from .queue_store import QueueStore -except ImportError: - from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module - from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module - from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module +from .queue_store import QueueStore +from ...store.audit_store import AuditStore +from ...store.config_store import ConfigStore _instance: StoreManager | None = None class StoreManager: - """Owns db_path and delegates migrate/is_migrated across all stores. + """Owns db_path and delegates migrate/is_migrated across the orchestrator's + stores. - Use instance() for normal access. Call reset(db_path) in tests to swap - the singleton to a temp path, then reset() with no args to restore the + Use instance() for normal access. Call reset(db_path) in tests to swap the + singleton to a temp path, then reset() with no args to restore the default.""" def __init__(self, db_path: Path | None = None) -> None: if db_path is None: - try: - from ..paths import host_db_path - except ImportError: - from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from ...paths import host_db_path db_path = host_db_path() self.db_path = db_path diff --git a/bot_bottle/orchestrator/supervisor/__init__.py b/bot_bottle/orchestrator/supervisor/__init__.py new file mode 100644 index 0000000..c6d903e --- /dev/null +++ b/bot_bottle/orchestrator/supervisor/__init__.py @@ -0,0 +1,34 @@ +"""The orchestrator's view of the supervise plane. + +Bundles the orchestrator-owned supervise surface: the queue/audit I/O and diff +rendering (`queue`) and the `Supervise` lifecycle (`supervise`). For +convenience it re-exports the neutral vocabulary (`bot_bottle.supervisor`'s +types + `SupervisePlan`) so orchestrator-side callers — service, CLI, tests — +import from one place. + +The data plane must NOT import this package; it imports `bot_bottle.supervisor` +(neutral) directly and reaches the queue over the control-plane RPC. +""" + +from __future__ import annotations + +from ...supervisor.types import * # noqa: F401,F403 — re-export the neutral vocabulary +from ...supervisor.plan import SupervisePlan +from .queue import ( + archive_all_proposals, + archive_proposal, + audit_dir, + audit_log_path, + list_all_pending_proposals, + list_pending_proposals, + read_audit_entries, + read_proposal, + read_response, + render_diff, + sha256_hex, + wait_for_response, + write_audit_entry, + write_proposal, + write_response, +) +from .supervise import Supervise diff --git a/bot_bottle/orchestrator/supervisor/queue.py b/bot_bottle/orchestrator/supervisor/queue.py new file mode 100644 index 0000000..fed4132 --- /dev/null +++ b/bot_bottle/orchestrator/supervisor/queue.py @@ -0,0 +1,141 @@ +"""Orchestrator-side supervise queue + audit I/O and diff rendering. + +The host-side library the orchestrator uses to drive the supervise flow: read / +write proposals and responses against the queue store, append audit entries, +and render operator diffs. Only the orchestrator opens `bot-bottle.db` (PRD +0070), so this lives under `orchestrator/` — the data plane reaches it over the +control-plane RPC, never by importing this module. +""" + +from __future__ import annotations + +import difflib +import hashlib +import time +from pathlib import Path + +from ...supervisor.types import ( + AuditEntry, + DEFAULT_POLL_INTERVAL_SEC, + Proposal, + Response, +) +from ...paths import bot_bottle_root +from ..store.queue_store import QueueStore +from ...store.audit_store import AuditStore + + +# --- Paths ----------------------------------------------------------------- + + +def audit_dir() -> Path: + return bot_bottle_root() / "audit" + + +def audit_log_path(component: str, slug: str) -> Path: + return audit_dir() / f"{component}-{slug}.log" + + +# --- Queue I/O ------------------------------------------------------------- + + +def write_proposal(proposal: Proposal) -> Path: + """Persist `proposal` in the queue database, mode 0o600. + Directory is created if missing.""" + return QueueStore(proposal.bottle_slug).write_proposal(proposal) + + +def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal: + return QueueStore(bottle_slug).read_proposal(proposal_id) + + +def list_pending_proposals(bottle_slug: str) -> list[Proposal]: + """All proposals for `bottle_slug` that do not yet have a matching + response. Sorted by `arrival_timestamp` so the operator sees the queue + FIFO.""" + return QueueStore(bottle_slug).list_pending_proposals() + + +def list_all_pending_proposals() -> list[Proposal]: + """All pending proposals across bottles, sorted FIFO.""" + return QueueStore("").list_all_pending_proposals() + + +def write_response(bottle_slug: str, response: Response) -> Path: + return QueueStore(bottle_slug).write_response(response) + + +def read_response(bottle_slug: str, proposal_id: str) -> Response: + return QueueStore(bottle_slug).read_response(proposal_id) + + +def wait_for_response( + bottle_slug: str, + proposal_id: str, + *, + poll_interval: float = DEFAULT_POLL_INTERVAL_SEC, + deadline: float | None = None, +) -> Response: + """Block until a response file appears for `proposal_id`, then return it. + `deadline` is an absolute time.monotonic() value after which the wait raises + TimeoutError. None waits forever — the natural shape, since the operator's + response time is unbounded. + + Polls SQLite so the implementation stays portable and stdlib-only.""" + store = QueueStore(bottle_slug) + while True: + try: + return store.read_response(proposal_id) + except FileNotFoundError: + pass + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError(f"no response for proposal {proposal_id!r}") + time.sleep(poll_interval) + + +def archive_proposal(bottle_slug: str, proposal_id: str) -> None: + """Mark both proposal and response rows processed. + Idempotent — missing rows are silently skipped.""" + QueueStore(bottle_slug).archive_proposal(proposal_id) + + +def archive_all_proposals(bottle_slug: str) -> None: + """Archive every proposal + response for `bottle_slug` (bottle teardown / + reconcile cleanup). Idempotent.""" + QueueStore(bottle_slug).archive_all() + + +# --- Audit log ------------------------------------------------------------- + + +def write_audit_entry(entry: AuditEntry) -> Path: + """Append `entry` to the host supervise audit table.""" + return AuditStore().write_audit_entry(entry) + + +def read_audit_entries(component: str, slug: str) -> list[AuditEntry]: + """Load all audit entries for the given component+slug.""" + return AuditStore().read_audit_entries(component, slug) + + +# --- Diff rendering -------------------------------------------------------- + + +def render_diff(before: str, after: str, *, label: str = "config") -> str: + """Unified diff suitable for the audit log + TUI. Empty diff (no changes) + renders as the empty string.""" + diff = difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"{label} (current)", + tofile=f"{label} (proposed)", + lineterm="", + ) + parts = list(diff) + if not parts: + return "" + return "".join(p if p.endswith("\n") else p + "\n" for p in parts).rstrip("\n") + + +def sha256_hex(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() diff --git a/bot_bottle/orchestrator/supervisor/supervise.py b/bot_bottle/orchestrator/supervisor/supervise.py new file mode 100644 index 0000000..f2e51fe --- /dev/null +++ b/bot_bottle/orchestrator/supervisor/supervise.py @@ -0,0 +1,36 @@ +"""The `Supervise` lifecycle — host-side database staging (PRD 0013 / 0070). + +`Supervise.prepare` migrates the orchestrator's `bot-bottle.db` and returns the +neutral `SupervisePlan`. It touches the orchestrator store manager, so it lives +here rather than in the neutral `bot_bottle.supervisor` package; the backend +(which drives launch) may import it — backend → orchestrator is an allowed +direction, unlike gateway → orchestrator. +""" + +from __future__ import annotations + +from abc import ABC +from pathlib import Path + +from ..store.store_manager import StoreManager +from ...supervisor.plan import SupervisePlan + + +class Supervise(ABC): + """Per-bottle supervise daemon. Encapsulates host-side database staging; + the gateway's start/stop lifecycle is backend-specific.""" + + def prepare( + self, + slug: str, + stage_dir: Path, + ) -> SupervisePlan: + """Stage the host database. Returns the plan; `internal_network` must be + set by the launch step before .start runs.""" + del stage_dir + mgr = StoreManager.instance() + mgr.migrate() + return SupervisePlan( + slug=slug, + db_path=mgr.db_path, + ) diff --git a/bot_bottle/store/__init__.py b/bot_bottle/store/__init__.py index ec5e39b..0a7f1f3 100644 --- a/bot_bottle/store/__init__.py +++ b/bot_bottle/store/__init__.py @@ -1,9 +1,13 @@ -"""SQLite-backed persistence for bot-bottle. +"""SQLite-backed persistence base for bot-bottle. -The store family: a shared `DbStore` base (versioned `migrations`) and the -concrete stores built on it — `ConfigStore`, `AuditStore`, `QueueStore` — plus -`StoreManager`, which migrates them together against the one per-host DB. +The shared store base: the `DbStore` class (versioned `migrations`) and the +concrete stores built on it that aren't owned by a single service — +`ConfigStore` and `AuditStore`. + +Service-owned stores live under their service: the supervise `queue_store`, the +`secret_store`, the orchestrator `config_store`, and the orchestrator +`store_manager` are under `bot_bottle.orchestrator.store`. Callers import the concrete module they need directly, e.g. -`from bot_bottle.store.store_manager import StoreManager`. +`from bot_bottle.store.db_store import DbStore`. """ diff --git a/bot_bottle/store/audit_store.py b/bot_bottle/store/audit_store.py index 13de470..3a6738b 100644 --- a/bot_bottle/store/audit_store.py +++ b/bot_bottle/store/audit_store.py @@ -6,12 +6,12 @@ import sqlite3 from pathlib import Path try: - from ..supervise_types import AuditEntry + from ..supervisor.types import AuditEntry from ..paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervisor.types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py deleted file mode 100644 index d71253c..0000000 --- a/bot_bottle/supervise.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Per-bottle supervise plane (PRD 0013). - -The supervise plane is the per-bottle MCP daemon plus its host-side -queue/audit support. The daemon (bot_bottle.gateway.supervise_server) -sits on the bottle's internal network and exposes MCP tools the agent -calls when it needs an operator-reviewed egress change: - - * egress-block / allow — agent proposes a new routes.yaml - -Each tool call: the agent passes the full proposed file plus a -justification text. The gateway validates the proposal syntactically, -writes it to the host SQLite queue table, and holds the tool-call -connection open. The operator's supervise TUI -(bot_bottle.cli.supervise) sees the proposal, accepts -approve / modify / reject, and writes a response row. The gateway sees -the response and returns `{status, notes}` to the agent. - -This module defines the host-side library: dataclasses for the queue -record shapes, queue read/write helpers, the audit log writer, and the -diff renderer. The in-gateway daemon lives in -bot_bottle/gateway/supervise_server.py; the supervise daemon's container -lifecycle is owned by the gateway (PRD 0024). - -For 0013 the supervisor's approval handlers are deliberately no-ops: -on approval the audit log is written and the response file is -delivered to the agent, but no host-side config change happens. The -remediation engines that wire real config changes land in PRDs 0014, -0015, and 0016. -""" - -from __future__ import annotations - -import difflib -import hashlib -import time -from abc import ABC -from dataclasses import dataclass -from pathlib import Path - -from .supervise_types import ( - ACTION_OPERATOR_EDIT, - AuditEntry, - POLL_STATUS_PENDING, - POLL_STATUS_UNKNOWN, - Proposal, - Response, - STATUSES, - STATUS_APPROVED, - STATUS_MODIFIED, - STATUS_REJECTED, - TOOLS, - TOOL_CHECK_PROPOSAL, - TOOL_EGRESS_ALLOW, - TOOL_EGRESS_BLOCK, - TOOL_EGRESS_TOKEN_ALLOW, - TOOL_GITLEAKS_ALLOW, - TOOL_LIST_EGRESS_ROUTES, -) - - -try: - from .paths import bot_bottle_root -except ImportError: # flat imports inside the gateway - from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module - - -SUPERVISE_HOSTNAME = "supervise" -SUPERVISE_PORT = 9100 - -# The supervise daemon uses these to query egress's -# introspection endpoint for the `list-egress-routes` MCP -# tool. The hostname + port match egress's docker network -# listen port (see backend.docker.egress.EGRESS_PORT). The supervise -# daemon runs inside the gateway alongside egress, so loopback -# is the stable address across docker, firecracker, and Apple -# Container backends. -EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099" -EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" - -COMPONENT_FOR_TOOL: dict[str, str] = { - TOOL_EGRESS_ALLOW: "egress", - TOOL_EGRESS_BLOCK: "egress", -} - -DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db" -DEFAULT_POLL_INTERVAL_SEC = 0.5 - - -# --- Paths ----------------------------------------------------------------- - - -def audit_dir() -> Path: - return bot_bottle_root() / "audit" - - -def audit_log_path(component: str, slug: str) -> Path: - return audit_dir() / f"{component}-{slug}.log" - - -try: - from .store.queue_store import QueueStore - from .store.audit_store import AuditStore - from .store.store_manager import StoreManager -except ImportError: - # Gateway: files are flat-copied under /app, not a package. - from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module - from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module - from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module - - -# --- Queue I/O ------------------------------------------------------------- - - -def write_proposal(proposal: Proposal) -> Path: - """Persist `proposal` in the queue database, mode 0o600. - Directory is created if missing.""" - return QueueStore(proposal.bottle_slug).write_proposal(proposal) - - -def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal: - return QueueStore(bottle_slug).read_proposal(proposal_id) - - -def list_pending_proposals(bottle_slug: str) -> list[Proposal]: - """All proposals for `bottle_slug` that do not yet have a matching - response. Sorted by `arrival_timestamp` so the operator - sees the queue FIFO.""" - return QueueStore(bottle_slug).list_pending_proposals() - - -def list_all_pending_proposals() -> list[Proposal]: - """All pending proposals across bottles, sorted FIFO.""" - return QueueStore("").list_all_pending_proposals() - - -def write_response(bottle_slug: str, response: Response) -> Path: - return QueueStore(bottle_slug).write_response(response) - - -def read_response(bottle_slug: str, proposal_id: str) -> Response: - return QueueStore(bottle_slug).read_response(proposal_id) - - -def wait_for_response( - bottle_slug: str, - proposal_id: str, - *, - poll_interval: float = DEFAULT_POLL_INTERVAL_SEC, - deadline: float | None = None, -) -> Response: - """Block until a response file appears for `proposal_id`, then - return it. `deadline` is an absolute time.monotonic() value after - which the wait raises TimeoutError. None waits forever — the - natural shape, since the operator's response time is unbounded. - - Polls SQLite so the implementation stays portable and stdlib-only.""" - store = QueueStore(bottle_slug) - while True: - try: - return store.read_response(proposal_id) - except FileNotFoundError: - pass - if deadline is not None and time.monotonic() >= deadline: - raise TimeoutError(f"no response for proposal {proposal_id!r}") - time.sleep(poll_interval) - - -def archive_proposal(bottle_slug: str, proposal_id: str) -> None: - """Mark both proposal and response rows processed. - Idempotent — missing rows are silently skipped.""" - QueueStore(bottle_slug).archive_proposal(proposal_id) - - -def archive_all_proposals(bottle_slug: str) -> None: - """Archive every proposal + response for `bottle_slug` (bottle teardown / - reconcile cleanup). Idempotent.""" - QueueStore(bottle_slug).archive_all() - - -# --- Audit log ------------------------------------------------------------- - - -def write_audit_entry(entry: AuditEntry) -> Path: - """Append `entry` to the host supervise audit table.""" - return AuditStore().write_audit_entry(entry) - - -def read_audit_entries(component: str, slug: str) -> list[AuditEntry]: - """Load all audit entries for the given component+slug.""" - return AuditStore().read_audit_entries(component, slug) - - -# --- Diff rendering -------------------------------------------------------- - - -def render_diff(before: str, after: str, *, label: str = "config") -> str: - """Unified diff suitable for the audit log + TUI. Empty diff (no - changes) renders as the empty string.""" - diff = difflib.unified_diff( - before.splitlines(keepends=True), - after.splitlines(keepends=True), - fromfile=f"{label} (current)", - tofile=f"{label} (proposed)", - lineterm="", - ) - parts = list(diff) - if not parts: - return "" - return "".join(p if p.endswith("\n") else p + "\n" for p in parts).rstrip("\n") - - -def sha256_hex(content: str) -> str: - return hashlib.sha256(content.encode("utf-8")).hexdigest() - - -# --- Gateway plan + abstract lifecycle ------------------------------------- - - -@dataclass(frozen=True) -class SupervisePlan: - """Output of Supervise.prepare; consumed by .start. - - `db_path` is the host database bind-mounted into the gateway at - /run/supervise/bot-bottle.db. `internal_network` is empty at - prepare time; the backend's launch step fills it via - dataclasses.replace before calling .start.""" - - slug: str - db_path: Path - internal_network: str = "" - - -class Supervise(ABC): - """Per-bottle supervise daemon. Encapsulates host-side database - staging; the gateway's start/stop lifecycle is backend-specific.""" - - def prepare( - self, - slug: str, - stage_dir: Path, - ) -> SupervisePlan: - """Stage the host database. Returns the plan; `internal_network` - must be set by the launch step before .start runs.""" - del stage_dir - mgr = StoreManager.instance() - mgr.migrate() - return SupervisePlan( - slug=slug, - db_path=mgr.db_path, - ) - - -__all__ = [ - "ACTION_OPERATOR_EDIT", - "AuditEntry", - "AuditStore", - "COMPONENT_FOR_TOOL", - "DEFAULT_POLL_INTERVAL_SEC", - "DB_PATH_IN_CONTAINER", - "Proposal", - "QueueStore", - "Response", - "StoreManager", - "STATUSES", - "STATUS_APPROVED", - "STATUS_MODIFIED", - "STATUS_REJECTED", - "POLL_STATUS_PENDING", - "POLL_STATUS_UNKNOWN", - "SUPERVISE_HOSTNAME", - "SUPERVISE_PORT", - "Supervise", - "SupervisePlan", - "TOOLS", - "EGRESS_FORWARD_PROXY", - "EGRESS_INTROSPECT_URL", - "TOOL_CHECK_PROPOSAL", - "TOOL_EGRESS_ALLOW", - "TOOL_EGRESS_BLOCK", - "TOOL_GITLEAKS_ALLOW", - "TOOL_EGRESS_TOKEN_ALLOW", - "TOOL_LIST_EGRESS_ROUTES", - "archive_proposal", - "archive_all_proposals", - "audit_dir", - "audit_log_path", - "list_pending_proposals", - "list_all_pending_proposals", - "read_audit_entries", - "read_proposal", - "read_response", - "render_diff", - "sha256_hex", - "wait_for_response", - "write_audit_entry", - "write_proposal", - "write_response", -] diff --git a/bot_bottle/supervisor/__init__.py b/bot_bottle/supervisor/__init__.py new file mode 100644 index 0000000..d76c1e9 --- /dev/null +++ b/bot_bottle/supervisor/__init__.py @@ -0,0 +1,16 @@ +"""Neutral supervise vocabulary — importable by every tier. + +The supervise plane spans the control plane (orchestrator) and the data plane +(gateway `supervise_server`): the gateway proposes/polls over RPC, the +orchestrator queues and applies operator decisions. Both sides must agree on +the wire types and constants, so those live here — a neutral package neither +tier's private code, importable by `bot_bottle.gateway` **and** +`bot_bottle.orchestrator` without either depending on the other. + + * `types` — the `Proposal`/`Response`/`AuditEntry` dataclasses, the tool / + status / poll-status constants, and the shared daemon constants. + * `plan` — `SupervisePlan`, the launch-time staging DTO. + +The orchestrator-only half (queue I/O, diff rendering, the `Supervise` +lifecycle) lives under `bot_bottle.orchestrator.supervisor`. +""" diff --git a/bot_bottle/supervisor/plan.py b/bot_bottle/supervisor/plan.py new file mode 100644 index 0000000..2962d82 --- /dev/null +++ b/bot_bottle/supervisor/plan.py @@ -0,0 +1,26 @@ +"""`SupervisePlan` — the launch-time supervise staging DTO. + +A pure value type (no store access), so it lives in the neutral package: the +backend builds it at launch and the orchestrator's `Supervise.prepare` returns +it. The behaviour that fills it in — staging the host database — is the +orchestrator's, in `bot_bottle.orchestrator.supervisor.supervise`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class SupervisePlan: + """Output of `Supervise.prepare`; consumed by `.start`. + + `db_path` is the host database bind-mounted into the gateway at + /run/supervise/bot-bottle.db. `internal_network` is empty at prepare time; + the backend's launch step fills it via `dataclasses.replace` before calling + `.start`.""" + + slug: str + db_path: Path + internal_network: str = "" diff --git a/bot_bottle/supervise_types.py b/bot_bottle/supervisor/types.py similarity index 84% rename from bot_bottle/supervise_types.py rename to bot_bottle/supervisor/types.py index 6bb65e5..e09fbde 100644 --- a/bot_bottle/supervise_types.py +++ b/bot_bottle/supervisor/types.py @@ -48,6 +48,24 @@ POLL_STATUS_UNKNOWN = "unknown" ACTION_OPERATOR_EDIT = "operator-edit" +# Shared supervise-daemon constants. The gateway `supervise_server` and the +# backend read these, so they live with the wire types in the neutral package +# (never in the orchestrator's private code the data plane can't import). +SUPERVISE_HOSTNAME = "supervise" +SUPERVISE_PORT = 9100 +# The supervise daemon queries egress's introspection endpoint for the +# `list-egress-routes` tool over loopback (egress runs alongside it in the +# gateway), stable across docker / firecracker / Apple Container backends. +EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099" +EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" +DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db" +DEFAULT_POLL_INTERVAL_SEC = 0.5 + +COMPONENT_FOR_TOOL: dict[str, str] = { + TOOL_EGRESS_ALLOW: "egress", + TOOL_EGRESS_BLOCK: "egress", +} + def _require_str(raw: dict[str, object], key: str) -> str: value = raw.get(key) @@ -175,4 +193,11 @@ __all__ = [ "TOOL_EGRESS_TOKEN_ALLOW", "TOOL_GITLEAKS_ALLOW", "TOOL_LIST_EGRESS_ROUTES", + "SUPERVISE_HOSTNAME", + "SUPERVISE_PORT", + "EGRESS_FORWARD_PROXY", + "EGRESS_INTROSPECT_URL", + "DB_PATH_IN_CONTAINER", + "DEFAULT_POLL_INTERVAL_SEC", + "COMPONENT_FOR_TOOL", ] diff --git a/tests/integration/test_gateway_image.py b/tests/integration/test_gateway_image.py index 624bda9..213c93b 100644 --- a/tests/integration/test_gateway_image.py +++ b/tests/integration/test_gateway_image.py @@ -91,7 +91,7 @@ class TestGatewayImage(unittest.TestCase): # Probe that the package imports resolve inside the image. rc, out = self._run_in_image( "python3", "-c", - "from bot_bottle import supervise; from bot_bottle.gateway import supervise_server; print('ok')", + "from bot_bottle.supervisor import types; from bot_bottle.gateway import supervise_server; print('ok')", ) self.assertEqual(0, rc, msg=out) self.assertIn("ok", out) diff --git a/tests/unit/_docker_bottle_plan.py b/tests/unit/_docker_bottle_plan.py index c3a16c2..78f4b19 100644 --- a/tests/unit/_docker_bottle_plan.py +++ b/tests/unit/_docker_bottle_plan.py @@ -16,7 +16,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import ManifestIndex -from bot_bottle.supervise import SupervisePlan +from bot_bottle.supervisor.plan import SupervisePlan SLUG = "demo-abc12" diff --git a/tests/unit/test_cli_dispatch.py b/tests/unit/test_cli_dispatch.py index e5e10d5..bdc37bf 100644 --- a/tests/unit/test_cli_dispatch.py +++ b/tests/unit/test_cli_dispatch.py @@ -15,7 +15,7 @@ from bot_bottle.cli import main from bot_bottle.store.db_store import DbStore from bot_bottle.log import Die from bot_bottle.manifest import ManifestError -from bot_bottle.store.store_manager import StoreManager +from bot_bottle.orchestrator.store.store_manager import StoreManager class TestMainDispatch(unittest.TestCase): diff --git a/tests/unit/test_config_store.py b/tests/unit/test_config_store.py index 0f40576..b47cb6d 100644 --- a/tests/unit/test_config_store.py +++ b/tests/unit/test_config_store.py @@ -11,7 +11,7 @@ from bot_bottle.store.config_store import ( DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS, ConfigStore, ) -from bot_bottle.store.store_manager import StoreManager +from bot_bottle.orchestrator.store.store_manager import StoreManager class TestConfigStore(unittest.TestCase): diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 0e8589b..1dc5db3 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -25,7 +25,7 @@ from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import ManifestIndex -from bot_bottle.supervise import SupervisePlan +from bot_bottle.supervisor.plan import SupervisePlan _URL = "http://supervise:9100/" diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index c53d2fb..4b2ca1f 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -25,7 +25,7 @@ from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import ManifestIndex -from bot_bottle.supervise import SupervisePlan +from bot_bottle.supervisor.plan import SupervisePlan _URL = "http://supervise:9100/" diff --git a/tests/unit/test_orchestrator_config_store.py b/tests/unit/test_orchestrator_config_store.py index ff43987..538a475 100644 --- a/tests/unit/test_orchestrator_config_store.py +++ b/tests/unit/test_orchestrator_config_store.py @@ -15,7 +15,7 @@ import unittest from pathlib import Path from types import ModuleType -from bot_bottle.orchestrator.config_store import ( +from bot_bottle.orchestrator.store.config_store import ( DEFAULT_TEARDOWN_TIMEOUT_SECONDS, TEARDOWN_TIMEOUT_ENV, OrchestratorConfigStore, diff --git a/tests/unit/test_orchestrator_control_plane.py b/tests/unit/test_orchestrator_control_plane.py index 1cd06b8..0bacd90 100644 --- a/tests/unit/test_orchestrator_control_plane.py +++ b/tests/unit/test_orchestrator_control_plane.py @@ -24,8 +24,8 @@ from bot_bottle.orchestrator.broker import StubBroker from bot_bottle.orchestrator.control_plane import dispatch, make_server from bot_bottle.orchestrator.registry import BottleRecord, RegistryStore from bot_bottle.orchestrator.service import Orchestrator -from bot_bottle.store.store_manager import StoreManager -from bot_bottle.supervise import ( +from bot_bottle.orchestrator.store.store_manager import StoreManager +from bot_bottle.orchestrator.supervisor import ( Proposal, TOOL_EGRESS_ALLOW, sha256_hex, diff --git a/tests/unit/test_orchestrator_secret_store.py b/tests/unit/test_orchestrator_secret_store.py index 64dd453..2b0e0d5 100644 --- a/tests/unit/test_orchestrator_secret_store.py +++ b/tests/unit/test_orchestrator_secret_store.py @@ -4,7 +4,7 @@ from __future__ import annotations import unittest -from bot_bottle.orchestrator.secret_store import ( +from bot_bottle.orchestrator.store.secret_store import ( ENV_VAR_SECRET_NAME, decrypt_value, encrypt_value, diff --git a/tests/unit/test_orchestrator_service.py b/tests/unit/test_orchestrator_service.py index 9441654..a83f8c1 100644 --- a/tests/unit/test_orchestrator_service.py +++ b/tests/unit/test_orchestrator_service.py @@ -14,9 +14,9 @@ from unittest.mock import patch from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker from bot_bottle.orchestrator.registry import RegistryStore from bot_bottle.orchestrator.service import Orchestrator -from bot_bottle.orchestrator.secret_store import new_env_var_secret -from bot_bottle.store.store_manager import StoreManager -from bot_bottle.supervise import ( +from bot_bottle.orchestrator.store.secret_store import new_env_var_secret +from bot_bottle.orchestrator.store.store_manager import StoreManager +from bot_bottle.orchestrator.supervisor import ( Proposal, STATUS_APPROVED, TOOL_EGRESS_ALLOW, diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index e3360a7..f0f41be 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -7,12 +7,12 @@ import unittest from datetime import datetime, timezone from pathlib import Path -from bot_bottle import supervise +from bot_bottle.orchestrator import supervisor as supervise from bot_bottle.paths import host_db_path from tests.unit import use_bottle_root from bot_bottle.store.audit_store import AuditStore -from bot_bottle.store.queue_store import QueueStore -from bot_bottle.supervise import ( +from bot_bottle.orchestrator.store.queue_store import QueueStore +from bot_bottle.orchestrator.supervisor import ( AuditEntry, Proposal, Response, diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index b785a43..f35105b 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -13,7 +13,7 @@ from datetime import datetime, timezone from unittest.mock import MagicMock, patch from bot_bottle.cli import supervise as supervise_cli -from bot_bottle.supervise import ( +from bot_bottle.orchestrator.supervisor import ( Proposal, TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK, diff --git a/tests/unit/test_supervise_edge.py b/tests/unit/test_supervise_edge.py index acbd420..c56d9c5 100644 --- a/tests/unit/test_supervise_edge.py +++ b/tests/unit/test_supervise_edge.py @@ -10,11 +10,11 @@ import unittest from pathlib import Path from unittest.mock import patch -from bot_bottle import supervise +from bot_bottle.orchestrator import supervisor as supervise from bot_bottle.store.audit_store import AuditStore from bot_bottle.paths import bot_bottle_root -from bot_bottle.store.queue_store import QueueStore -from bot_bottle.supervise import ( +from bot_bottle.orchestrator.store.queue_store import QueueStore +from bot_bottle.orchestrator.supervisor import ( AuditEntry, Proposal, STATUS_APPROVED, diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 7f0f21d..a082998 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -18,8 +18,8 @@ from pathlib import Path from tests.unit import use_bottle_root -from bot_bottle import supervise as _sv -from bot_bottle.store import queue_store as _qs +from bot_bottle.orchestrator import supervisor as _sv +from bot_bottle.orchestrator.store import queue_store as _qs from bot_bottle.store import audit_store as _as from bot_bottle.gateway import supervise_server # noqa: E402