diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index a94960e..ba13551 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -18,7 +18,7 @@ # /git-gate-entrypoint.sh docker-cp'd at start time # /git-gate/creds/* docker-cp'd at start time # /git/* bare repos, populated at runtime -# /run/supervise/queue/ bind-mounted at run time +# /run/supervise/bot-bottle.db bind-mounted at run time # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir # # Exposed ports inside the container: @@ -81,7 +81,7 @@ RUN mkdir -p \ /etc/git-gate \ /git-gate/creds \ /git \ - /run/supervise/queue \ + /run/supervise \ /home/mitmproxy/.mitmproxy # Documentation only — the compose renderer publishes whichever diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 78112fc..9929610 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -35,7 +35,6 @@ from ...git_gate import GIT_GATE_HOSTNAME from ...log import die, warn from ...supervise import ( DB_PATH_IN_CONTAINER, - QUEUE_DIR_IN_CONTAINER, SUPERVISE_HOSTNAME, SUPERVISE_PORT, ) @@ -165,7 +164,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: env += [ f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", f"SUPERVISE_PORT={SUPERVISE_PORT}", ] volumes.append({ @@ -174,13 +172,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: "target": DB_PATH_IN_CONTAINER, "read_only": False, }) - volumes.append({ - "type": "bind", - "source": str(sp.queue_dir), - "target": QUEUE_DIR_IN_CONTAINER, - "read_only": False, - }) - internal_aliases = [EGRESS_HOSTNAME] if gp.upstreams: internal_aliases.append(GIT_GATE_HOSTNAME) diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 518644a..10976d9 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -33,7 +33,7 @@ from ...git_gate import ( revoke_git_gate_provisioned_keys, ) from ...log import die, info, warn -from ...supervise import DB_PATH_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT +from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT from ...util import expand_tilde from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT from ..docker.git_gate import ( @@ -380,7 +380,6 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]: env += [ f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", f"SUPERVISE_PORT={SUPERVISE_PORT}", ] return tuple(env) @@ -407,7 +406,6 @@ def _sidecar_mounts( sp = plan.supervise_plan if sp is not None: mounts.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False)) - mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False)) return tuple(mounts) diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 57de70b..8736567 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -27,7 +27,7 @@ from ...egress import ( egress_resolve_token_values, egress_sidecar_env_entries, ) -from ...supervise import DB_PATH_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT +from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT from ...util import expand_tilde from ..docker import util as docker_mod from ..docker.egress import ( @@ -370,11 +370,9 @@ def _bundle_launch_spec( env += [ f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", f"SUPERVISE_PORT={SUPERVISE_PORT}", ] volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False)) - volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False)) # Container ports the agent reaches from the smolvm guest — # published on host loopback so the guest can dial via TSI + diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index 02fe3b1..7eb3316 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -284,9 +284,8 @@ def git_gate_state_dir(identity: str) -> Path: def supervise_state_dir(identity: str) -> Path: """State subdir reserved for supervise sidecar bind-mount sources. - The queue dir is intentionally NOT under here — it lives at - ~/.bot-bottle/queue// alongside the audit logs, so it - survives state-dir cleanup.""" + Runtime queue/audit rows live in the host-level bot-bottle SQLite + database, so they survive state-dir cleanup.""" return bottle_state_dir(identity) / _SUPERVISE_SUBDIR diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index 3eeaeb6..6f341bd 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -45,7 +45,7 @@ from ..supervise import ( TOOL_EGRESS_BLOCK, TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW, - list_pending_proposals, + list_all_pending_proposals, render_diff, write_audit_entry, write_response, @@ -63,10 +63,9 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL @dataclass(frozen=True) class QueuedProposal: - """A pending proposal plus the queue dir it was found in.""" + """A pending proposal from the supervise queue.""" proposal: Proposal - queue_dir: Path # Errors any remediation engine may raise. Caught by the TUI key @@ -86,16 +85,11 @@ def apply_routes_change(slug: str, content: str) -> tuple[str, str]: def discover_pending() -> list[QueuedProposal]: - """Walk ~/.bot-bottle/queue/* and collect pending proposals.""" - queue_root = _supervise.bot_bottle_root() / "queue" - if not queue_root.is_dir(): - return [] - out: list[QueuedProposal] = [] - for slug_dir in sorted(queue_root.iterdir()): - if not slug_dir.is_dir(): - continue - for proposal in list_pending_proposals(slug_dir): - out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir)) + """Collect pending proposals across bottles.""" + out = [ + QueuedProposal(proposal=proposal) + for proposal in list_all_pending_proposals() + ] out.sort(key=lambda q: q.proposal.arrival_timestamp) return out @@ -118,7 +112,6 @@ def _detail_lines( (f"tool: {p.tool}", 0), (f"id: {p.id}", 0), (f"arrived: {p.arrival_timestamp}", 0), - (f"queue: {qp.queue_dir}", 0), ("", 0), ("justification:", 0), ] @@ -165,7 +158,7 @@ def approve( notes=notes, final_file=final_file, ) - write_response(qp.queue_dir, response) + write_response(qp.proposal.bottle_slug, response) _write_audit( qp, action=status, notes=notes, diff_before=diff_before, diff_after=diff_after, @@ -179,7 +172,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None: notes=reason, final_file=None, ) - write_response(qp.queue_dir, response) + write_response(qp.proposal.bottle_slug, response) _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") diff --git a/bot_bottle/egress_addon.py b/bot_bottle/egress_addon.py index a28094e..d0cf0c7 100644 --- a/bot_bottle/egress_addon.py +++ b/bot_bottle/egress_addon.py @@ -79,14 +79,13 @@ class EgressAddon: # only — a restart re-prompts. Mutated only from the asyncio loop that # runs the addon hooks, so no lock is needed. self.safe_tokens: set[str] = set() - self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip() self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip() self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._reload(initial=True) self._install_sighup() def _supervise_available(self) -> bool: - return bool(self._supervise_queue_dir and self._supervise_slug) + return bool(self._supervise_slug) def _reload(self, *, initial: bool = False) -> None: try: @@ -393,9 +392,8 @@ class EgressAddon: justification=_TOKEN_ALLOW_JUSTIFICATION, current_file_hash=_sv.sha256_hex(payload), ) - queue_dir = Path(self._supervise_queue_dir) try: - _sv.write_proposal(queue_dir, proposal) + _sv.write_proposal(proposal) except OSError as e: sys.stderr.write( f"egress: could not queue token-allow proposal: {e}; " @@ -411,8 +409,8 @@ class EgressAddon: **self._req_ctx(flow), }) + "\n") - response = await self._await_token_response(queue_dir, proposal.id) - _sv.archive_proposal(queue_dir, proposal.id) + response = await self._await_token_response(proposal.id) + _sv.archive_proposal(self._supervise_slug, proposal.id) if response is not None and response.status in ( _sv.STATUS_APPROVED, _sv.STATUS_MODIFIED, @@ -439,16 +437,15 @@ class EgressAddon: async def _await_token_response( self, - queue_dir: Path, proposal_id: str, ) -> "_sv.Response | None": - """Poll the queue dir for the operator's response without blocking the + """Poll the DB for the operator's response without blocking the proxy event loop. Returns the Response, or None on timeout.""" loop = asyncio.get_running_loop() deadline = loop.time() + self._token_allow_timeout while True: try: - return _sv.read_response(queue_dir, proposal_id) + return _sv.read_response(self._supervise_slug, proposal_id) except (OSError, ValueError, KeyError): # Not written yet, or a partial/malformed write — retry until # the deadline, then fail closed. diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index 656f0e9..ba11926 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -239,9 +239,8 @@ from pathlib import Path from bot_bottle import supervise as _sv report_path = Path(sys.argv[1]) -queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "") slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") -if not queue_dir or not slug: +if not slug: sys.exit(2) try: @@ -289,7 +288,7 @@ proposal = _sv.Proposal.new( current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(), now=datetime.datetime.now(datetime.timezone.utc), ) -_sv.write_proposal(Path(queue_dir), proposal) +_sv.write_proposal(proposal) print(proposal.id) PY ) @@ -303,7 +302,7 @@ PY return 1 fi - queue_dir=${SUPERVISE_QUEUE_DIR:-} + slug=${SUPERVISE_BOTTLE_SLUG:-} timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300} case "$timeout" in ''|*[!0-9]*) @@ -315,14 +314,14 @@ PY echo "git-gate: approve with './cli.py supervise' to continue this push" >&2 waited=0 while [ "$waited" -lt "$timeout" ]; do - status=$(python3 - "$queue_dir" "$proposal_id" <<'PY' + status=$(python3 - "$slug" "$proposal_id" <<'PY' import sys -from pathlib import Path from bot_bottle import supervise as _sv +slug = sys.argv[1] try: - response = _sv.read_response(Path(sys.argv[1]), sys.argv[2]) + response = _sv.read_response(slug, sys.argv[2]) except FileNotFoundError: sys.exit(2) print(response.status) @@ -337,13 +336,12 @@ PY if [ -n "$status" ]; then case "$status" in approved|modified) - python3 - "$queue_dir" "$proposal_id" <<'PY' || true + python3 - "$slug" "$proposal_id" <<'PY' || true import sys -from pathlib import Path from bot_bottle import supervise as _sv -_sv.archive_proposal(Path(sys.argv[1]), sys.argv[2]) +_sv.archive_proposal(sys.argv[1], sys.argv[2]) PY echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2 return 0 diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 3ec3679..6605bb3 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -9,15 +9,14 @@ calls when it needs an operator-reviewed egress change: Each tool call: the agent passes the full proposed file plus a justification text. The sidecar validates the proposal syntactically, -writes it to the host's per-bottle queue dir, and holds the tool-call +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 file alongside the -proposal. The sidecar sees the response and returns `{status, notes}` -to the agent. +approve / modify / reject, and writes a response row. The sidecar sees +the response and returns `{status, notes}` to the agent. This module defines the host-side library: dataclasses for the queue -file shapes, queue read/write helpers, the audit log writer, and the +record shapes, queue read/write helpers, the audit log writer, and the diff renderer. The in-container sidecar lives in bot_bottle/supervise_server.py; the supervise daemon's container lifecycle is owned by the sidecar bundle (PRD 0024). @@ -86,7 +85,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) # `routes edit ` verb writes entries with this action. ACTION_OPERATOR_EDIT = "operator-edit" -QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db" DEFAULT_POLL_INTERVAL_SEC = 0.5 HOST_DB_FILENAME = "bot-bottle.db" @@ -99,10 +97,6 @@ def bot_bottle_root() -> Path: return Path.home() / ".bot-bottle" -def queue_dir_for_slug(slug: str) -> Path: - return bot_bottle_root() / "queue" / slug - - def audit_dir() -> Path: return bot_bottle_root() / "audit" @@ -115,8 +109,7 @@ def host_db_path() -> Path: return bot_bottle_root() / HOST_DB_FILENAME -def queue_db_path(queue_dir: Path) -> Path: - del queue_dir +def queue_db_path() -> Path: env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip() return Path(env_path) if env_path else host_db_path() @@ -126,9 +119,7 @@ def queue_db_path(queue_dir: Path) -> Path: @dataclass(frozen=True) class Proposal: - """One pending tool-call from the agent. The sidecar writes one - of these to the queue dir on a tool call; the operator's TUI - reads them; the sidecar polls for a matching Response.""" + """One pending tool-call from the agent.""" id: str bottle_slug: str @@ -182,7 +173,7 @@ class Proposal: @dataclass(frozen=True) class Response: """The operator's decision on a proposal. The TUI writes one of - these to the queue dir; the sidecar reads it and returns the + these to the queue table; the sidecar reads it and returns the `{status, notes}` pair to the agent's tool call. `final_file` carries the file content the supervisor will @@ -238,33 +229,38 @@ class AuditEntry: # --- Queue I/O ------------------------------------------------------------- -def write_proposal(queue_dir: Path, proposal: Proposal) -> Path: +def write_proposal(proposal: Proposal) -> Path: """Persist `proposal` in the queue database, mode 0o600. Directory is created if missing.""" - return _QueueStore(queue_dir).write_proposal(proposal) + return _QueueStore(proposal.bottle_slug).write_proposal(proposal) -def read_proposal(queue_dir: Path, proposal_id: str) -> Proposal: - return _QueueStore(queue_dir).read_proposal(proposal_id) +def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal: + return _QueueStore(bottle_slug).read_proposal(proposal_id) -def list_pending_proposals(queue_dir: Path) -> list[Proposal]: - """All proposals in `queue_dir` that do not yet have a matching +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(queue_dir).list_pending_proposals() + return _QueueStore(bottle_slug).list_pending_proposals() -def write_response(queue_dir: Path, response: Response) -> Path: - return _QueueStore(queue_dir).write_response(response) +def list_all_pending_proposals() -> list[Proposal]: + """All pending proposals across bottles, sorted FIFO.""" + return _QueueStore("").list_all_pending_proposals() -def read_response(queue_dir: Path, proposal_id: str) -> Response: - return _QueueStore(queue_dir).read_response(proposal_id) +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( - queue_dir: Path, + bottle_slug: str, proposal_id: str, *, poll_interval: float = DEFAULT_POLL_INTERVAL_SEC, @@ -276,7 +272,7 @@ def wait_for_response( natural shape, since the operator's response time is unbounded. Polls SQLite so the implementation stays portable and stdlib-only.""" - store = _QueueStore(queue_dir) + store = _QueueStore(bottle_slug) while True: try: return store.read_response(proposal_id) @@ -287,10 +283,10 @@ def wait_for_response( time.sleep(poll_interval) -def archive_proposal(queue_dir: Path, proposal_id: str) -> None: +def archive_proposal(bottle_slug: str, proposal_id: str) -> None: """Mark both proposal and response rows processed. Idempotent — missing rows are silently skipped.""" - _QueueStore(queue_dir).archive_proposal(proposal_id) + _QueueStore(bottle_slug).archive_proposal(proposal_id) # --- Audit log ------------------------------------------------------------- @@ -333,9 +329,9 @@ def sha256_hex(content: str) -> str: class _QueueStore: - def __init__(self, queue_dir: Path) -> None: - self.queue_key = _queue_key(queue_dir) - self.db_path = queue_db_path(queue_dir) + def __init__(self, queue_key: str) -> None: + self.queue_key = queue_key + self.db_path = queue_db_path() self.db_path.parent.mkdir(parents=True, exist_ok=True) self._init() @@ -396,6 +392,25 @@ class _QueueStore: ).fetchall() return [_proposal_from_row(row) for row in rows] + def list_all_pending_proposals(self) -> list[Proposal]: + if not self.db_path.is_file(): + return [] + with self._connect() as conn: + rows = conn.execute( + """ + SELECT p.* FROM supervise_proposals p + WHERE p.archived = 0 + AND NOT EXISTS ( + SELECT 1 FROM supervise_responses r + WHERE r.queue_key = p.queue_key + AND r.proposal_id = p.id + AND r.archived = 0 + ) + ORDER BY p.arrival_timestamp, p.id + """ + ).fetchall() + return [_proposal_from_row(row) for row in rows] + def write_response(self, response: Response) -> Path: with self._connect() as conn: conn.execute( @@ -597,13 +612,6 @@ def _audit_entry_from_row(row: sqlite3.Row) -> AuditEntry: ) -def _queue_key(queue_dir: Path) -> str: - env_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip() - if env_slug: - return env_slug - return queue_dir.name - - # --- Sidecar plan + abstract lifecycle ------------------------------------- @@ -611,39 +619,33 @@ def _queue_key(queue_dir: Path) -> str: class SupervisePlan: """Output of Supervise.prepare; consumed by .start. - `queue_dir` is the host directory bind-mounted into the sidecar - at /run/supervise/queue. `internal_network` is empty at prepare - time; the backend's launch step fills it via dataclasses.replace - before calling .start.""" + `db_path` is the host database bind-mounted into the sidecar 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 - queue_dir: Path db_path: Path internal_network: str = "" class Supervise(ABC): - """Per-bottle supervise sidecar. Encapsulates the host-side - prepare (queue dir staging); the sidecar's start/stop lifecycle - is backend-specific.""" + """Per-bottle supervise sidecar. Encapsulates host-side database + staging; the sidecar's start/stop lifecycle is backend-specific.""" def prepare( self, slug: str, stage_dir: Path, ) -> SupervisePlan: - """Stage the per-bottle queue dir on the host. Returns the - plan; `internal_network` must be set by the launch step before - .start runs.""" + """Stage the host database. Returns the plan; `internal_network` + must be set by the launch step before .start runs.""" del stage_dir - queue_dir = queue_dir_for_slug(slug) - queue_dir.mkdir(parents=True, exist_ok=True) db_path = host_db_path() - _QueueStore(queue_dir) + _QueueStore(slug) _AuditStore(db_path) return SupervisePlan( slug=slug, - queue_dir=queue_dir, db_path=db_path, ) @@ -664,7 +666,6 @@ __all__ = [ "DEFAULT_POLL_INTERVAL_SEC", "DB_PATH_IN_CONTAINER", "Proposal", - "QUEUE_DIR_IN_CONTAINER", "Response", "STATUSES", "STATUS_APPROVED", @@ -688,8 +689,8 @@ __all__ = [ "bot_bottle_root", "host_db_path", "list_pending_proposals", + "list_all_pending_proposals", "queue_db_path", - "queue_dir_for_slug", "read_audit_entries", "read_proposal", "read_response", diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index 5fca484..089e106 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -7,14 +7,13 @@ config changes when stuck. The tools are `egress-allow`, Each queued tool call: 1. Validates the proposed file syntactically. - 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from - the host's ~/.bot-bottle/queue//). - 3. Blocks polling for a matching Response file. + 2. Writes a Proposal to the host SQLite database. + 3. Blocks polling for a matching Response row. 4. Returns the operator's `{status, notes}` to the agent. The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at -container creation by the backend's start step). The queue dir comes -from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`). +container creation by the backend's start step). SUPERVISE_DB_PATH +points at the bind-mounted host database. Speaks MCP over HTTP+JSON-RPC. Methods handled: @@ -42,7 +41,6 @@ import typing import urllib.error import urllib.request from dataclasses import dataclass -from pathlib import Path try: # Same-directory imports inside the bundle container; these files are @@ -277,7 +275,6 @@ def validate_proposed_file(tool: str, content: str) -> None: @dataclass(frozen=True) class ServerConfig: bottle_slug: str - queue_dir: Path response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS @@ -376,7 +373,7 @@ def handle_tools_call( current_file_hash=_sv.sha256_hex(proposed_file), ) try: - _sv.write_proposal(config.queue_dir, proposal) + _sv.write_proposal(proposal) except OSError as e: raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e sys.stderr.write( @@ -387,7 +384,7 @@ def handle_tools_call( deadline = time.monotonic() + config.response_timeout_seconds try: response = _sv.wait_for_response( - config.queue_dir, + config.bottle_slug, proposal.id, poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS, deadline=deadline, @@ -399,7 +396,7 @@ def handle_tools_call( "isError": False, } try: - _sv.archive_proposal(config.queue_dir, proposal.id) + _sv.archive_proposal(config.bottle_slug, proposal.id) except OSError as e: raise _RpcInternalError(f"failed to archive proposal: {e}") from e @@ -539,7 +536,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler): class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): allow_reuse_address = True daemon_threads = True - config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path()) + config: ServerConfig = ServerConfig(bottle_slug="") # --- Entry point ----------------------------------------------------------- @@ -548,21 +545,18 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): def serve( *, bottle_slug: str, - queue_dir: Path, port: int = _sv.SUPERVISE_PORT, bind: str = "0.0.0.0", response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS, ) -> typing.NoReturn: - queue_dir.mkdir(parents=True, exist_ok=True) server = MCPServer((bind, port), MCPHandler) server.config = ServerConfig( bottle_slug=bottle_slug, - queue_dir=queue_dir, response_timeout_seconds=response_timeout_seconds, ) sys.stderr.write( f"supervise listening on {bind}:{port}; " - f"slug={bottle_slug!r}; queue={queue_dir}; " + f"slug={bottle_slug!r}; " f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type] ) sys.stderr.flush() @@ -581,7 +575,6 @@ def main(argv: list[str]) -> int: if not bottle_slug: sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n") return 2 - queue_dir = Path(os.environ.get("SUPERVISE_QUEUE_DIR", _sv.QUEUE_DIR_IN_CONTAINER)) port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT))) bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0") try: @@ -591,7 +584,6 @@ def main(argv: list[str]) -> int: return 2 serve( bottle_slug=bottle_slug, - queue_dir=queue_dir, port=port, bind=bind, response_timeout_seconds=response_timeout_seconds, diff --git a/docs/prds/prd-new-sqlite-local-storage.md b/docs/prds/prd-new-sqlite-local-storage.md index bde2f6a..df5981e 100644 --- a/docs/prds/prd-new-sqlite-local-storage.md +++ b/docs/prds/prd-new-sqlite-local-storage.md @@ -27,12 +27,10 @@ one-off persistence. 1. Supervise proposals and responses are persisted through SQLite. 2. Audit entries are persisted through SQLite. -3. Existing public supervise helpers keep their current call shape where - practical: `write_proposal`, `read_proposal`, `list_pending_proposals`, - `write_response`, `read_response`, `wait_for_response`, - `archive_proposal`, `write_audit_entry`, and `read_audit_entries`. -4. The sidecar queue mount still works across docker, smolmachines, and - macOS-container backends. +3. Supervise queue helpers use the bottle slug / queue key instead of a queue + directory path. +4. The sidecar receives the host database mount across docker, smolmachines, + and macOS-container backends. 5. The implementation stays stdlib-only. 6. Unit tests cover queue round-trips, pending discovery, response waits, archive semantics, audit round-trips, and path creation. @@ -57,11 +55,9 @@ Queue and audit state use the host-level local database: The supervise sidecar receives that database as a writable bind mount at `/run/supervise/bot-bottle.db` and gets the path through `SUPERVISE_DB_PATH`. -The existing per-slug queue directory mount remains in place for compatibility -with the supervise sidecar contract and any adjacent tooling that still expects a -queue directory, but the active queue records live in the host database. This -creates the shared host database that later forge/native lifecycle work can -extend in separate PRDs. +No per-slug queue directory is mounted into the sidecar. This creates the shared +host database that later forge/native lifecycle work can extend in separate +PRDs. ### Tables @@ -113,9 +109,8 @@ CREATE TABLE supervise_audit_entries ( ### Compatibility -The existing helper functions keep accepting `Path` arguments for queue -directories. Internally, they map the queue directory to a queue key and perform -equivalent operations against `~/.bot-bottle/bot-bottle.db`: +The queue helpers take a bottle slug / queue key and perform equivalent +operations against `~/.bot-bottle/bot-bottle.db`: - `list_pending_proposals` returns non-archived proposals without a non-archived response, sorted by arrival time. @@ -123,9 +118,9 @@ equivalent operations against `~/.bot-bottle/bot-bottle.db`: moving files into `processed/`. - `wait_for_response` keeps the current polling behavior but polls SQLite. -The old path helpers (`queue_dir_for_slug`, `audit_dir`, `audit_log_path`) stay -available for compatibility. `audit_log_path` no longer describes the active -storage location; callers should use `read_audit_entries`. +The old audit path helpers (`audit_dir`, `audit_log_path`) stay available for +compatibility. `audit_log_path` no longer describes the active storage location; +callers should use `read_audit_entries`. ## Implementation chunks diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index e874423..fa83cb3 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -107,7 +107,6 @@ def _egress_plan( def _supervise_plan() -> SupervisePlan: return SupervisePlan( slug=SLUG, - queue_dir=STATE / "supervise" / "queue", db_path=STATE / "bot-bottle.db", internal_network=f"bot-bottle-net-{SLUG}", ) @@ -394,7 +393,6 @@ class TestSidecarBundleShape(unittest.TestCase): env_strings = sc["environment"] self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings) self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings) - self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings)) self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings)) def test_volumes_always_includes_egress_ca(self): @@ -411,8 +409,6 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate/creds/upstream-known_hosts", targets) self.assertIn("/run/supervise/bot-bottle.db", targets) - self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise") - for t in targets)) def test_extra_hosts_omitted_for_git_upstreams(self): sc = self._render(with_git=True)["services"]["sidecars"] diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 86d5231..0e8589b 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -74,7 +74,6 @@ def _plan( if supervise: supervise_plan = SupervisePlan( slug="demo-abc12", - queue_dir=Path("/tmp/queue"), db_path=Path("/tmp/bot-bottle.db"), ) return DockerBottlePlan( diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 97c45c5..3ccfbfb 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -77,7 +77,6 @@ def _plan( if supervise: supervise_plan = SupervisePlan( slug="demo-abc12", - queue_dir=Path("/tmp/queue"), db_path=Path("/tmp/bot-bottle.db"), ) return DockerBottlePlan( diff --git a/tests/unit/test_egress_addon_log_redaction.py b/tests/unit/test_egress_addon_log_redaction.py index 87290c3..dbff136 100644 --- a/tests/unit/test_egress_addon_log_redaction.py +++ b/tests/unit/test_egress_addon_log_redaction.py @@ -47,7 +47,6 @@ def _addon() -> EgressAddon: a: EgressAddon = EgressAddon.__new__(EgressAddon) a.config = Config(routes=(), log=LOG_FULL) a.safe_tokens = set() - a._supervise_queue_dir = "" a._supervise_slug = "" a._token_allow_timeout = 300.0 return a diff --git a/tests/unit/test_egress_addon_request_flow.py b/tests/unit/test_egress_addon_request_flow.py index f374dd5..ed8d8b2 100644 --- a/tests/unit/test_egress_addon_request_flow.py +++ b/tests/unit/test_egress_addon_request_flow.py @@ -212,7 +212,6 @@ def _addon(config: Config) -> EgressAddon: a: EgressAddon = EgressAddon.__new__(EgressAddon) a.config = config a.safe_tokens = set() - a._supervise_queue_dir = "" a._supervise_slug = "" a._token_allow_timeout = 300.0 a.routes_path = "/nonexistent/routes.yaml" @@ -386,10 +385,10 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace: def _sha256_hex(_payload: Any) -> str: return "hash" - def _noop(_a: Any, _b: Any) -> None: + def _noop(*_args: Any) -> None: return None - def _read_response(_qd: Any, _pid: Any) -> Any: + def _read_response(_slug: Any, _pid: Any) -> Any: if response_status is None: raise OSError("not written yet") # forces poll -> timeout return types.SimpleNamespace(status=response_status) @@ -409,7 +408,6 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace: class TestSuperviseBranch(unittest.TestCase): def _supervised_addon(self) -> EgressAddon: addon = _addon(Config(routes=(Route(host="api.example.com"),))) - addon._supervise_queue_dir = "/tmp/egress-queue" addon._supervise_slug = "test-bottle" addon._token_allow_timeout = 0.05 return addon @@ -632,14 +630,13 @@ class TestRedactSurfaces(unittest.TestCase): class TestSuperviseWriteFailure(unittest.TestCase): def test_write_proposal_oserror_blocks(self) -> None: addon = _addon(Config(routes=(Route(host="api.example.com"),))) - addon._supervise_queue_dir = "/tmp/egress-queue" addon._supervise_slug = "test-bottle" addon._token_allow_timeout = 0.05 flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")) fake = _fake_sv("approved") - def _raise(_qd: Any, _p: Any) -> None: + def _raise(_p: Any) -> None: raise OSError("disk full") fake.write_proposal = _raise diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 2f4e2f0..6cc0a99 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -213,7 +213,6 @@ class TestHookRender(unittest.TestCase): self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook) self.assertIn("_sv.write_proposal", hook) self.assertIn("_sv.read_response", hook) - self.assertIn("SUPERVISE_QUEUE_DIR", hook) self.assertIn("SUPERVISE_BOTTLE_SLUG", hook) self.assertIn("supervisor approved # gitleaks:allow", hook) self.assertIn("supervisor rejected # gitleaks:allow", hook) diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py index d96a055..4d1ecb0 100644 --- a/tests/unit/test_macos_container_launch.py +++ b/tests/unit/test_macos_container_launch.py @@ -72,7 +72,6 @@ def _plan( git_gate_plan = SimpleNamespace(upstreams=()) supervise_plan = ( SimpleNamespace( - queue_dir=Path("/state/supervise/queue"), db_path=Path("/state/bot-bottle.db"), ) if supervise else None @@ -143,10 +142,6 @@ class TestMacosContainerLaunchArgv(unittest.TestCase): "type=bind,source=/state/bot-bottle.db,target=/run/supervise/bot-bottle.db", argv, ) - self.assertIn( - "type=bind,source=/state/supervise/queue,target=/run/supervise/queue", - argv, - ) def test_sidecar_argv_registers_canary_env_as_sensitive(self): plan = _plan(stage_dir=self.stage_dir, canary=True) diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index df92867..2f93c13 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -130,7 +130,6 @@ def _plan( if supervise: supervise_plan = SupervisePlan( slug="demo-abc12", - queue_dir=Path("/tmp/queue"), db_path=Path("/tmp/bot-bottle.db"), ) return SmolmachinesBottlePlan( diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 3d7419e..88a594d 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -112,33 +112,44 @@ class TestResponseRoundtrip(unittest.TestCase): class TestQueueIO(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.") - self.queue_dir = Path(self._tmp.name) + self._home_patch = self._patch_home(Path(self._tmp.name)) + self.slug = "dev" def tearDown(self): + self._home_patch() self._tmp.cleanup() + def _patch_home(self, fake_home: Path): + original = supervise.bot_bottle_root + + def fake_root() -> Path: + return fake_home / ".bot-bottle" + + supervise.bot_bottle_root = fake_root # type: ignore[assignment] + return lambda: setattr(supervise, "bot_bottle_root", original) + def test_write_and_read_proposal(self): p = _proposal() - path = write_proposal(self.queue_dir, p) + path = write_proposal(p) self.assertTrue(path.exists()) - self.assertEqual(queue_db_path(self.queue_dir), path) + self.assertEqual(queue_db_path(), path) self.assertEqual(0o600, path.stat().st_mode & 0o777) - loaded = read_proposal(self.queue_dir, p.id) + loaded = read_proposal(self.slug, p.id) self.assertEqual(p, loaded) def test_list_pending_excludes_responded(self): a = _proposal(justification="first") b = _proposal(justification="second") - write_proposal(self.queue_dir, a) - write_proposal(self.queue_dir, b) - write_response(self.queue_dir, Response( + write_proposal(a) + write_proposal(b) + write_response(self.slug, Response( proposal_id=a.id, status=STATUS_APPROVED, notes="", )) - pending = list_pending_proposals(self.queue_dir) + pending = list_pending_proposals(self.slug) self.assertEqual([b.id], [p.id for p in pending]) - def test_list_pending_returns_empty_for_missing_dir(self): - self.assertEqual([], list_pending_proposals(self.queue_dir / "nope")) + def test_list_pending_returns_empty_for_missing_slug(self): + self.assertEqual([], list_pending_proposals("nope")) def test_list_pending_sorted_by_arrival(self): # Fabricate two with explicit timestamps. @@ -155,30 +166,30 @@ class TestQueueIO(unittest.TestCase): now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), ) # Write in reverse order. - write_proposal(self.queue_dir, b) - write_proposal(self.queue_dir, a) - ordered = list_pending_proposals(self.queue_dir) + write_proposal(b) + write_proposal(a) + ordered = list_pending_proposals(self.slug) self.assertEqual([a.id, b.id], [p.id for p in ordered]) def test_write_and_read_response(self): r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no") - write_response(self.queue_dir, r) - self.assertEqual(r, read_response(self.queue_dir, "xyz")) + write_response(self.slug, r) + self.assertEqual(r, read_response(self.slug, "xyz")) def test_wait_for_response_returns_when_file_appears(self): p = _proposal() - write_proposal(self.queue_dir, p) + write_proposal(p) def write_after_delay(): time.sleep(0.05) - write_response(self.queue_dir, Response( + write_response(self.slug, Response( proposal_id=p.id, status=STATUS_APPROVED, notes="ok", )) t = threading.Thread(target=write_after_delay) t.start() try: - r = wait_for_response(self.queue_dir, p.id, poll_interval=0.01) + r = wait_for_response(self.slug, p.id, poll_interval=0.01) finally: t.join() self.assertEqual(STATUS_APPROVED, r.status) @@ -188,24 +199,24 @@ class TestQueueIO(unittest.TestCase): deadline = time.monotonic() + 0.05 with self.assertRaises(TimeoutError): wait_for_response( - self.queue_dir, "never", + self.slug, "never", poll_interval=0.01, deadline=deadline, ) - def test_archive_proposal_moves_both_files(self): + def test_archive_proposal_hides_rows(self): p = _proposal() - write_proposal(self.queue_dir, p) - write_response(self.queue_dir, Response( + write_proposal(p) + write_response(self.slug, Response( proposal_id=p.id, status=STATUS_APPROVED, notes="", )) - archive_proposal(self.queue_dir, p.id) - self.assertEqual([], list_pending_proposals(self.queue_dir)) + archive_proposal(self.slug, p.id) + self.assertEqual([], list_pending_proposals(self.slug)) with self.assertRaises(FileNotFoundError): - read_response(self.queue_dir, p.id) + read_response(self.slug, p.id) def test_archive_is_idempotent_on_missing_files(self): # Should not raise. - archive_proposal(self.queue_dir, "nope") + archive_proposal(self.slug, "nope") class TestAuditLog(unittest.TestCase): @@ -381,7 +392,6 @@ class TestSupervisePrepare(unittest.TestCase): def test_prepare_creates_queue(self): plan = _StubSupervise().prepare("dev", self.stage_dir) - self.assertTrue(plan.queue_dir.is_dir()) self.assertTrue(plan.db_path.is_file()) self.assertEqual("dev", plan.slug) self.assertEqual("", plan.internal_network) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 47de267..836a221 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -77,9 +77,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): def test_walks_all_slug_subdirs(self): for slug in ("dev", "api"): - qdir = supervise.queue_dir_for_slug(slug) - qdir.mkdir(parents=True) - supervise.write_proposal(qdir, _proposal(slug=slug)) + supervise.write_proposal(_proposal(slug=slug)) pending = supervise_cli.discover_pending() self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending}) @@ -97,18 +95,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), ) for p in (late, early): - qdir = supervise.queue_dir_for_slug(p.bottle_slug) - qdir.mkdir(parents=True, exist_ok=True) - supervise.write_proposal(qdir, p) + supervise.write_proposal(p) pending = supervise_cli.discover_pending() self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending]) def test_excludes_already_responded(self): p = _proposal() - qdir = supervise.queue_dir_for_slug("dev") - qdir.mkdir(parents=True) - supervise.write_proposal(qdir, p) - supervise.write_response(qdir, supervise.Response( + supervise.write_proposal(p) + supervise.write_response("dev", supervise.Response( proposal_id=p.id, status=STATUS_APPROVED, notes="", )) self.assertEqual([], supervise_cli.discover_pending()) @@ -123,10 +117,8 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW): p = _proposal(tool=tool) - qdir = supervise.queue_dir_for_slug("dev") - qdir.mkdir(parents=True, exist_ok=True) - supervise.write_proposal(qdir, p) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) + supervise.write_proposal(p) + return supervise_cli.QueuedProposal(proposal=p) def test_approve_writes_response(self): qp = self._enqueue() @@ -135,7 +127,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): return_value=("routes: []\n", "routes:\n - host: example.com\n"), ): supervise_cli.approve(qp) - resp = read_response(qp.queue_dir, qp.proposal.id) + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual(STATUS_APPROVED, resp.status) self.assertIsNone(resp.final_file) @@ -150,7 +142,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): final_file="routes:\n - host: edited.example.com\n", notes="tweaked", ) - resp = read_response(qp.queue_dir, qp.proposal.id) + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file) self.assertEqual("tweaked", resp.notes) @@ -158,7 +150,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def test_reject_writes_rejection(self): qp = self._enqueue() supervise_cli.reject(qp, reason="nope") - resp = read_response(qp.queue_dir, qp.proposal.id) + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual("nope", resp.notes) @@ -181,36 +173,33 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def test_approve_gitleaks_allow_leaves_response_for_gate(self): qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) supervise_cli.approve(qp, notes="dummy fixture") - # Gate polls the queue dir for the response; TUI must not archive it. - resp = read_response(qp.queue_dir, qp.proposal.id) + # Gate polls the DB for the response; TUI must not archive it. + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual("dummy fixture", resp.notes) - self.assertFalse((qp.queue_dir / "processed").exists()) def test_tui_gitleaks_allow_requires_reason(self): qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) with patch.object(supervise_cli, "_prompt", return_value=""): status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] self.assertEqual("approve aborted (empty reason)", status) - self.assertFalse((qp.queue_dir / "processed").exists()) def test_tui_gitleaks_allow_writes_reason(self): qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) with patch.object(supervise_cli, "_prompt", return_value="test fixture"): status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] self.assertIn("approved gitleaks-allow", status) - resp = read_response(qp.queue_dir, qp.proposal.id) + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual("test fixture", resp.notes) def test_approve_token_allow_leaves_response_for_egress(self): qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW) supervise_cli.approve(qp, notes="false positive") - # The egress addon polls the queue dir for the response; the TUI must + # The egress addon polls the DB for the response; the TUI must # not archive it (the addon archives after reading). - resp = read_response(qp.queue_dir, qp.proposal.id) + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual("false positive", resp.notes) - self.assertFalse((qp.queue_dir / "processed").exists()) def test_token_allow_writes_no_audit_log(self): qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW) @@ -222,14 +211,13 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): with patch.object(supervise_cli, "_prompt", return_value=""): status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] self.assertEqual("approve aborted (empty reason)", status) - self.assertFalse((qp.queue_dir / "processed").exists()) def test_tui_token_allow_writes_reason(self): qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW) with patch.object(supervise_cli, "_prompt", return_value="legit"): status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] self.assertIn("approved egress-token-allow", status) - resp = read_response(qp.queue_dir, qp.proposal.id) + resp = read_response(qp.proposal.bottle_slug, qp.proposal.id) self.assertEqual("legit", resp.notes) def test_suffix_for_token_allow_is_txt(self): diff --git a/tests/unit/test_supervise_edge.py b/tests/unit/test_supervise_edge.py index d709a3f..87fb289 100644 --- a/tests/unit/test_supervise_edge.py +++ b/tests/unit/test_supervise_edge.py @@ -7,7 +7,6 @@ from __future__ import annotations import tempfile import time import unittest -from pathlib import Path from unittest.mock import patch from bot_bottle import supervise @@ -39,61 +38,56 @@ class TestPathHelpers(unittest.TestCase): def test_bot_bottle_root(self) -> None: self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle")) - def test_queue_dir_for_slug(self) -> None: - self.assertIn("slug", str(supervise.queue_dir_for_slug("slug"))) - - def test_queue_db_path_for_slug_dir(self) -> None: - self.assertEqual( - supervise.host_db_path(), - supervise.queue_db_path(Path("/tmp/queue")), - ) + def test_queue_db_path_is_host_db_path(self) -> None: + self.assertEqual(supervise.host_db_path(), supervise.queue_db_path()) class TestReadMalformed(unittest.TestCase): def test_read_proposal_missing_row(self) -> None: with tempfile.TemporaryDirectory() as d: - with self.assertRaises(FileNotFoundError): - read_proposal(Path(d), "p") + with patch.dict("os.environ", {"HOME": d}), \ + self.assertRaises(FileNotFoundError): + read_proposal("slug", "p") def test_read_response_missing_row(self) -> None: with tempfile.TemporaryDirectory() as d: - with self.assertRaises(FileNotFoundError): - read_response(Path(d), "p") + with patch.dict("os.environ", {"HOME": d}), \ + self.assertRaises(FileNotFoundError): + read_response("slug", "p") - def test_list_pending_ignores_legacy_json_files(self) -> None: + def test_list_pending_reads_db_only(self) -> None: with tempfile.TemporaryDirectory() as d: - qd = Path(d) - (qd / "bad.proposal.json").write_text("{ not json") - (qd / "arr.proposal.json").write_text("[]") - supervise.write_proposal(qd, _proposal()) # one valid - pending = list_pending_proposals(qd) + with patch.dict("os.environ", {"HOME": d}): + supervise.write_proposal(_proposal()) + pending = list_pending_proposals("slug") self.assertEqual(1, len(pending)) self.assertEqual("slug", pending[0].bottle_slug) def test_list_pending_skips_when_response_present(self) -> None: with tempfile.TemporaryDirectory() as d: - qd = Path(d) - p = _proposal() - supervise.write_proposal(qd, p) - supervise.write_response(qd, supervise.Response( - proposal_id=p.id, - status=STATUS_APPROVED, - notes="", - )) - self.assertEqual([], list_pending_proposals(qd)) + with patch.dict("os.environ", {"HOME": d}): + p = _proposal() + supervise.write_proposal(p) + supervise.write_response("slug", supervise.Response( + proposal_id=p.id, + status=STATUS_APPROVED, + notes="", + )) + self.assertEqual([], list_pending_proposals("slug")) class TestWaitForResponse(unittest.TestCase): def test_missing_response_times_out(self) -> None: with tempfile.TemporaryDirectory() as d: - with self.assertRaises(TimeoutError): - wait_for_response(Path(d), "p", deadline=time.monotonic()) + with patch.dict("os.environ", {"HOME": d}), \ + self.assertRaises(TimeoutError): + wait_for_response("slug", "p", deadline=time.monotonic()) - def test_legacy_response_file_does_not_count(self) -> None: + def test_empty_db_response_does_not_count(self) -> None: with tempfile.TemporaryDirectory() as d: - (Path(d) / "p.response.json").write_text("{}") # dict but from_dict raises - with self.assertRaises(TimeoutError): - wait_for_response(Path(d), "p", deadline=time.monotonic()) + with patch.dict("os.environ", {"HOME": d}), \ + self.assertRaises(TimeoutError): + wait_for_response("slug", "p", deadline=time.monotonic()) class TestReadAuditEntries(unittest.TestCase): diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 8a95aa6..e41894f 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -112,7 +112,7 @@ class TestRpcErrorTaxonomy(unittest.TestCase): validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n") def test_unknown_tool_in_tools_call_is_client_error(self): - config = ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")) + config = ServerConfig(bottle_slug="dev") with self.assertRaises(_RpcClientError) as cm: handle_tools_call({"name": "no-such-tool", "arguments": {}}, config) self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) @@ -122,7 +122,6 @@ class TestRpcInternalErrorOnIoFailure(unittest.TestCase): def test_write_proposal_os_error_raises_internal(self): config = ServerConfig( bottle_slug="dev", - queue_dir=Path("/unused"), ) with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \ self.assertRaises(_RpcInternalError) as cm: @@ -266,21 +265,31 @@ class TestHandleToolsList(unittest.TestCase): class TestHandleToolsCall(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.") - self.queue_dir = Path(self._tmp.name) - self.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir) + self._home_patch = self._patch_home(Path(self._tmp.name)) + self.config = ServerConfig(bottle_slug="dev") def tearDown(self): + self._home_patch() self._tmp.cleanup() + def _patch_home(self, fake_home: Path): + original = _sv.bot_bottle_root + + def fake_root() -> Path: + return fake_home / ".bot-bottle" + + _sv.bot_bottle_root = fake_root # type: ignore[assignment] + return lambda: setattr(_sv, "bot_bottle_root", original) + def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: """Background thread: poll the queue for a fresh proposal, write a matching response. Returns the thread so the test can join it.""" def runner(): for _ in range(200): - pending = _sv.list_pending_proposals(self.queue_dir) + pending = _sv.list_pending_proposals("dev") if pending: p = pending[0] - _sv.write_response(self.queue_dir, _sv.Response( + _sv.write_response("dev", _sv.Response( proposal_id=p.id, status=status, notes=notes, )) return @@ -413,13 +422,11 @@ class TestHandleToolsCall(unittest.TestCase): finally: responder.join() # No pending proposals left after archive. - self.assertEqual([], _sv.list_pending_proposals(self.queue_dir)) - self.assertFalse((self.queue_dir / "processed").exists()) + self.assertEqual([], _sv.list_pending_proposals("dev")) def test_pending_response_times_out_without_archive(self): config = ServerConfig( bottle_slug="dev", - queue_dir=self.queue_dir, response_timeout_seconds=0.05, ) result = handle_tools_call( @@ -437,8 +444,7 @@ class TestHandleToolsCall(unittest.TestCase): text = result["content"][0]["text"] # type: ignore[index] self.assertIn("status: pending", text) self.assertIn("proposal remains queued", text) - self.assertEqual(1, len(_sv.list_pending_proposals(self.queue_dir))) - self.assertFalse((self.queue_dir / "processed").exists()) + self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) class TestHandleListEgressRoutes(unittest.TestCase): @@ -460,7 +466,7 @@ class TestHandleListEgressRoutes(unittest.TestCase): with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()): result = handle_list_egress_routes( {}, - ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")), + ServerConfig(bottle_slug="dev"), ) self.assertFalse(result["isError"]) # type: ignore[index] @@ -475,7 +481,7 @@ class TestHandleListEgressRoutes(unittest.TestCase): with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()): result = handle_list_egress_routes( {}, - ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")), + ServerConfig(bottle_slug="dev"), ) self.assertTrue(result["isError"]) # type: ignore[index] @@ -543,7 +549,6 @@ class TestHttpEndToEnd(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.") - self.queue_dir = Path(self._tmp.name) # Pick a random port by binding to :0 first. import socket s = socket.socket() @@ -551,7 +556,7 @@ class TestHttpEndToEnd(unittest.TestCase): self.port = s.getsockname()[1] s.close() self.server = MCPServer(("127.0.0.1", self.port), MCPHandler) - self.server.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir) + self.server.config = ServerConfig(bottle_slug="dev") self.thread = threading.Thread( target=self.server.serve_forever, daemon=True, )