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