fix(supervise): store queue rows in host sqlite db
This commit is contained in:
@@ -34,6 +34,7 @@ from ...egress import (
|
|||||||
from ...git_gate import GIT_GATE_HOSTNAME
|
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,
|
||||||
QUEUE_DIR_IN_CONTAINER,
|
QUEUE_DIR_IN_CONTAINER,
|
||||||
SUPERVISE_HOSTNAME,
|
SUPERVISE_HOSTNAME,
|
||||||
SUPERVISE_PORT,
|
SUPERVISE_PORT,
|
||||||
@@ -163,9 +164,16 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if sp is not None:
|
if sp is not None:
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
|
volumes.append({
|
||||||
|
"type": "bind",
|
||||||
|
"source": str(sp.db_path),
|
||||||
|
"target": DB_PATH_IN_CONTAINER,
|
||||||
|
"read_only": False,
|
||||||
|
})
|
||||||
volumes.append({
|
volumes.append({
|
||||||
"type": "bind",
|
"type": "bind",
|
||||||
"source": str(sp.queue_dir),
|
"source": str(sp.queue_dir),
|
||||||
|
|||||||
@@ -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 QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import DB_PATH_IN_CONTAINER, QUEUE_DIR_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 (
|
||||||
@@ -379,6 +379,7 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|||||||
if plan.supervise_plan is not None:
|
if plan.supervise_plan is not None:
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
@@ -405,6 +406,7 @@ 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.queue_dir), QUEUE_DIR_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 QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import DB_PATH_IN_CONTAINER, QUEUE_DIR_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 (
|
||||||
@@ -369,9 +369,11 @@ def _bundle_launch_spec(
|
|||||||
daemons.append("supervise")
|
daemons.append("supervise")
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_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.queue_dir), QUEUE_DIR_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 —
|
||||||
|
|||||||
@@ -234,9 +234,10 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
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", "")
|
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||||
@@ -277,31 +278,19 @@ for i, finding in enumerate(raw, 1):
|
|||||||
])
|
])
|
||||||
|
|
||||||
payload = "\n".join(lines).rstrip() + "\n"
|
payload = "\n".join(lines).rstrip() + "\n"
|
||||||
proposal_id = str(uuid.uuid4())
|
proposal = _sv.Proposal.new(
|
||||||
proposal = {
|
bottle_slug=slug,
|
||||||
"id": proposal_id,
|
tool=_sv.TOOL_GITLEAKS_ALLOW,
|
||||||
"bottle_slug": slug,
|
proposed_file=payload,
|
||||||
"tool": "gitleaks-allow",
|
justification=(
|
||||||
"proposed_file": payload,
|
|
||||||
"justification": (
|
|
||||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||||
"approve only for dummy test fixtures or confirmed false positives"
|
"approve only for dummy test fixtures or confirmed false positives"
|
||||||
),
|
),
|
||||||
"arrival_timestamp": datetime.datetime.now(
|
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||||
datetime.timezone.utc
|
now=datetime.datetime.now(datetime.timezone.utc),
|
||||||
).isoformat(),
|
)
|
||||||
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
_sv.write_proposal(Path(queue_dir), proposal)
|
||||||
}
|
print(proposal.id)
|
||||||
queue = Path(queue_dir)
|
|
||||||
queue.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = queue / f"{proposal_id}.proposal.json"
|
|
||||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
||||||
with tmp.open("w", encoding="utf-8") as f:
|
|
||||||
json.dump(proposal, f, indent=2)
|
|
||||||
f.write("\n")
|
|
||||||
os.chmod(tmp, 0o600)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
print(proposal_id)
|
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
rc=$?
|
rc=$?
|
||||||
@@ -315,7 +304,6 @@ PY
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
||||||
response_file="$queue_dir/${proposal_id}.response.json"
|
|
||||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||||
case "$timeout" in
|
case "$timeout" in
|
||||||
''|*[!0-9]*)
|
''|*[!0-9]*)
|
||||||
@@ -327,26 +315,36 @@ 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
|
||||||
if [ -f "$response_file" ]; then
|
status=$(python3 - "$queue_dir" "$proposal_id" <<'PY'
|
||||||
status=$(python3 - "$response_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(sys.argv[1], encoding="utf-8") as f:
|
response = _sv.read_response(Path(sys.argv[1]), sys.argv[2])
|
||||||
raw = json.load(f)
|
except FileNotFoundError:
|
||||||
except (OSError, json.JSONDecodeError):
|
sys.exit(2)
|
||||||
sys.exit(1)
|
print(response.status)
|
||||||
status = raw.get("status")
|
|
||||||
if not isinstance(status, str):
|
|
||||||
sys.exit(1)
|
|
||||||
print(status)
|
|
||||||
PY
|
PY
|
||||||
) || status=""
|
)
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
status=""
|
||||||
|
elif [ "$rc" -ne 0 ]; then
|
||||||
|
status="invalid"
|
||||||
|
fi
|
||||||
|
if [ -n "$status" ]; then
|
||||||
case "$status" in
|
case "$status" in
|
||||||
approved|modified)
|
approved|modified)
|
||||||
mkdir -p "$queue_dir/processed"
|
python3 - "$queue_dir" "$proposal_id" <<'PY' || true
|
||||||
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
import sys
|
||||||
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
|
|
||||||
|
_sv.archive_proposal(Path(sys.argv[1]), sys.argv[2])
|
||||||
|
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
|
||||||
;;
|
;;
|
||||||
@@ -499,4 +497,3 @@ if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+50
-20
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import difflib
|
import difflib
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -86,9 +87,9 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
|||||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||||
|
|
||||||
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
||||||
|
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"
|
||||||
QUEUE_DB_FILENAME = "supervise.db"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Paths -----------------------------------------------------------------
|
# --- Paths -----------------------------------------------------------------
|
||||||
@@ -115,7 +116,9 @@ def host_db_path() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def queue_db_path(queue_dir: Path) -> Path:
|
def queue_db_path(queue_dir: Path) -> Path:
|
||||||
return queue_dir / QUEUE_DB_FILENAME
|
del queue_dir
|
||||||
|
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
||||||
|
return Path(env_path) if env_path else host_db_path()
|
||||||
|
|
||||||
|
|
||||||
# --- Dataclasses -----------------------------------------------------------
|
# --- Dataclasses -----------------------------------------------------------
|
||||||
@@ -331,6 +334,7 @@ def sha256_hex(content: str) -> str:
|
|||||||
|
|
||||||
class _QueueStore:
|
class _QueueStore:
|
||||||
def __init__(self, queue_dir: Path) -> None:
|
def __init__(self, queue_dir: Path) -> None:
|
||||||
|
self.queue_key = _queue_key(queue_dir)
|
||||||
self.db_path = queue_db_path(queue_dir)
|
self.db_path = queue_db_path(queue_dir)
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._init()
|
self._init()
|
||||||
@@ -340,11 +344,12 @@ class _QueueStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO supervise_proposals (
|
INSERT OR REPLACE INTO supervise_proposals (
|
||||||
id, bottle_slug, tool, proposed_file, justification,
|
queue_key, id, bottle_slug, tool, proposed_file, justification,
|
||||||
arrival_timestamp, current_file_hash, archived
|
arrival_timestamp, current_file_hash, archived
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
self.queue_key,
|
||||||
proposal.id,
|
proposal.id,
|
||||||
proposal.bottle_slug,
|
proposal.bottle_slug,
|
||||||
proposal.tool,
|
proposal.tool,
|
||||||
@@ -362,9 +367,9 @@ class _QueueStore:
|
|||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM supervise_proposals
|
SELECT * FROM supervise_proposals
|
||||||
WHERE id = ? AND archived = 0
|
WHERE queue_key = ? AND id = ? AND archived = 0
|
||||||
""",
|
""",
|
||||||
(proposal_id,),
|
(self.queue_key, proposal_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise FileNotFoundError(proposal_id)
|
raise FileNotFoundError(proposal_id)
|
||||||
@@ -378,12 +383,16 @@ class _QueueStore:
|
|||||||
"""
|
"""
|
||||||
SELECT p.* FROM supervise_proposals p
|
SELECT p.* FROM supervise_proposals p
|
||||||
WHERE p.archived = 0
|
WHERE p.archived = 0
|
||||||
|
AND p.queue_key = ?
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM supervise_responses r
|
SELECT 1 FROM supervise_responses r
|
||||||
WHERE r.proposal_id = p.id AND r.archived = 0
|
WHERE r.queue_key = p.queue_key
|
||||||
|
AND r.proposal_id = p.id
|
||||||
|
AND r.archived = 0
|
||||||
)
|
)
|
||||||
ORDER BY p.arrival_timestamp, p.id
|
ORDER BY p.arrival_timestamp, p.id
|
||||||
"""
|
""",
|
||||||
|
(self.queue_key,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_proposal_from_row(row) for row in rows]
|
return [_proposal_from_row(row) for row in rows]
|
||||||
|
|
||||||
@@ -392,10 +401,11 @@ class _QueueStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO supervise_responses (
|
INSERT OR REPLACE INTO supervise_responses (
|
||||||
proposal_id, status, notes, final_file, archived
|
queue_key, proposal_id, status, notes, final_file, archived
|
||||||
) VALUES (?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, 0)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
self.queue_key,
|
||||||
response.proposal_id,
|
response.proposal_id,
|
||||||
response.status,
|
response.status,
|
||||||
response.notes,
|
response.notes,
|
||||||
@@ -410,9 +420,9 @@ class _QueueStore:
|
|||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM supervise_responses
|
SELECT * FROM supervise_responses
|
||||||
WHERE proposal_id = ? AND archived = 0
|
WHERE queue_key = ? AND proposal_id = ? AND archived = 0
|
||||||
""",
|
""",
|
||||||
(proposal_id,),
|
(self.queue_key, proposal_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise FileNotFoundError(proposal_id)
|
raise FileNotFoundError(proposal_id)
|
||||||
@@ -423,15 +433,18 @@ class _QueueStore:
|
|||||||
return
|
return
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE supervise_proposals SET archived = 1 WHERE id = ?",
|
"""
|
||||||
(proposal_id,),
|
UPDATE supervise_proposals SET archived = 1
|
||||||
|
WHERE queue_key = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(self.queue_key, proposal_id),
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE supervise_responses SET archived = 1
|
UPDATE supervise_responses SET archived = 1
|
||||||
WHERE proposal_id = ?
|
WHERE queue_key = ? AND proposal_id = ?
|
||||||
""",
|
""",
|
||||||
(proposal_id,),
|
(self.queue_key, proposal_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
@@ -444,25 +457,29 @@ class _QueueStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS supervise_proposals (
|
CREATE TABLE IF NOT EXISTS supervise_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
queue_key TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
bottle_slug TEXT NOT NULL,
|
bottle_slug TEXT NOT NULL,
|
||||||
tool TEXT NOT NULL,
|
tool TEXT NOT NULL,
|
||||||
proposed_file TEXT NOT NULL,
|
proposed_file TEXT NOT NULL,
|
||||||
justification TEXT NOT NULL,
|
justification TEXT NOT NULL,
|
||||||
arrival_timestamp TEXT NOT NULL,
|
arrival_timestamp TEXT NOT NULL,
|
||||||
current_file_hash TEXT NOT NULL,
|
current_file_hash TEXT NOT NULL,
|
||||||
archived INTEGER NOT NULL DEFAULT 0
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS supervise_responses (
|
CREATE TABLE IF NOT EXISTS supervise_responses (
|
||||||
proposal_id TEXT PRIMARY KEY,
|
queue_key TEXT NOT NULL,
|
||||||
|
proposal_id TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
notes TEXT NOT NULL,
|
notes TEXT NOT NULL,
|
||||||
final_file TEXT,
|
final_file TEXT,
|
||||||
archived INTEGER NOT NULL DEFAULT 0
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, proposal_id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -580,6 +597,13 @@ 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 -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -594,6 +618,7 @@ class SupervisePlan:
|
|||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
queue_dir: Path
|
queue_dir: Path
|
||||||
|
db_path: Path
|
||||||
internal_network: str = ""
|
internal_network: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -613,9 +638,13 @@ class Supervise(ABC):
|
|||||||
del stage_dir
|
del stage_dir
|
||||||
queue_dir = queue_dir_for_slug(slug)
|
queue_dir = queue_dir_for_slug(slug)
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path = host_db_path()
|
||||||
|
_QueueStore(queue_dir)
|
||||||
|
_AuditStore(db_path)
|
||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
queue_dir=queue_dir,
|
queue_dir=queue_dir,
|
||||||
|
db_path=db_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Helpers ---------------------------------------------------------------
|
# --- Helpers ---------------------------------------------------------------
|
||||||
@@ -633,6 +662,7 @@ __all__ = [
|
|||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"COMPONENT_FOR_TOOL",
|
"COMPONENT_FOR_TOOL",
|
||||||
"DEFAULT_POLL_INTERVAL_SEC",
|
"DEFAULT_POLL_INTERVAL_SEC",
|
||||||
|
"DB_PATH_IN_CONTAINER",
|
||||||
"Proposal",
|
"Proposal",
|
||||||
"QUEUE_DIR_IN_CONTAINER",
|
"QUEUE_DIR_IN_CONTAINER",
|
||||||
"Response",
|
"Response",
|
||||||
|
|||||||
@@ -49,51 +49,50 @@ one-off persistence.
|
|||||||
|
|
||||||
### Database locations
|
### Database locations
|
||||||
|
|
||||||
Queue state remains tied to the mounted per-bottle queue directory:
|
Queue and audit state use the host-level local database:
|
||||||
|
|
||||||
```text
|
|
||||||
~/.bot-bottle/queue/<slug>/supervise.db
|
|
||||||
```
|
|
||||||
|
|
||||||
The supervise sidecar already receives that directory at
|
|
||||||
`/run/supervise/queue`, so both the sidecar and host TUI can read and write the
|
|
||||||
same SQLite file without changing backend mounts.
|
|
||||||
|
|
||||||
Audit state uses the host-level local database:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.bot-bottle/bot-bottle.db
|
~/.bot-bottle/bot-bottle.db
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates the shared host database that later forge/native lifecycle work can
|
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.
|
extend in separate PRDs.
|
||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
|
|
||||||
`supervise_proposals` lives in the per-queue database:
|
`supervise_proposals` lives in the host database:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE supervise_proposals (
|
CREATE TABLE supervise_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
queue_key TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
bottle_slug TEXT NOT NULL,
|
bottle_slug TEXT NOT NULL,
|
||||||
tool TEXT NOT NULL,
|
tool TEXT NOT NULL,
|
||||||
proposed_file TEXT NOT NULL,
|
proposed_file TEXT NOT NULL,
|
||||||
justification TEXT NOT NULL,
|
justification TEXT NOT NULL,
|
||||||
arrival_timestamp TEXT NOT NULL,
|
arrival_timestamp TEXT NOT NULL,
|
||||||
current_file_hash TEXT NOT NULL,
|
current_file_hash TEXT NOT NULL,
|
||||||
archived INTEGER NOT NULL DEFAULT 0
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, id)
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
`supervise_responses` lives in the same per-queue database:
|
`supervise_responses` lives in the host database:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE supervise_responses (
|
CREATE TABLE supervise_responses (
|
||||||
proposal_id TEXT PRIMARY KEY,
|
queue_key TEXT NOT NULL,
|
||||||
|
proposal_id TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
notes TEXT NOT NULL,
|
notes TEXT NOT NULL,
|
||||||
final_file TEXT,
|
final_file TEXT,
|
||||||
archived INTEGER NOT NULL DEFAULT 0
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, proposal_id)
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,8 +114,8 @@ CREATE TABLE supervise_audit_entries (
|
|||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
The existing helper functions keep accepting `Path` arguments for queue
|
The existing helper functions keep accepting `Path` arguments for queue
|
||||||
directories. Internally, they map the queue directory to `supervise.db` and
|
directories. Internally, they map the queue directory to a queue key and perform
|
||||||
perform equivalent operations:
|
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.
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ def _supervise_plan() -> SupervisePlan:
|
|||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=SLUG,
|
slug=SLUG,
|
||||||
queue_dir=STATE / "supervise" / "queue",
|
queue_dir=STATE / "supervise" / "queue",
|
||||||
|
db_path=STATE / "bot-bottle.db",
|
||||||
internal_network=f"bot-bottle-net-{SLUG}",
|
internal_network=f"bot-bottle-net-{SLUG}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -392,6 +393,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
sc = self._render(supervise=True)["services"]["sidecars"]
|
||||||
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.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in 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))
|
||||||
|
|
||||||
@@ -408,6 +410,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertIn("/etc/egress", targets)
|
self.assertIn("/etc/egress", targets)
|
||||||
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.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
||||||
for t in targets))
|
for t in targets))
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ def _plan(
|
|||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
queue_dir=Path("/tmp/queue"),
|
||||||
|
db_path=Path("/tmp/bot-bottle.db"),
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ def _plan(
|
|||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
queue_dir=Path("/tmp/queue"),
|
||||||
|
db_path=Path("/tmp/bot-bottle.db"),
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ class TestHookRender(unittest.TestCase):
|
|||||||
# the suppressed findings for human approval.
|
# the suppressed findings for human approval.
|
||||||
self.assertIn("--ignore-gitleaks-allow", hook)
|
self.assertIn("--ignore-gitleaks-allow", hook)
|
||||||
self.assertIn("--report-format=json", hook)
|
self.assertIn("--report-format=json", hook)
|
||||||
self.assertIn('"tool": "gitleaks-allow"', hook)
|
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_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)
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ def _plan(
|
|||||||
else:
|
else:
|
||||||
git_gate_plan = SimpleNamespace(upstreams=())
|
git_gate_plan = SimpleNamespace(upstreams=())
|
||||||
supervise_plan = (
|
supervise_plan = (
|
||||||
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
|
SimpleNamespace(
|
||||||
|
queue_dir=Path("/state/supervise/queue"),
|
||||||
|
db_path=Path("/state/bot-bottle.db"),
|
||||||
|
)
|
||||||
if supervise else None
|
if supervise else None
|
||||||
)
|
)
|
||||||
agent_provision = SimpleNamespace(
|
agent_provision = SimpleNamespace(
|
||||||
@@ -136,6 +139,10 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
f"type=bind,source={self.stage_dir},target=/etc/egress,readonly",
|
f"type=bind,source={self.stage_dir},target=/etc/egress,readonly",
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"type=bind,source=/state/bot-bottle.db,target=/run/supervise/bot-bottle.db",
|
||||||
|
argv,
|
||||||
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
||||||
argv,
|
argv,
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ def _plan(
|
|||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
queue_dir=Path("/tmp/queue"),
|
||||||
|
db_path=Path("/tmp/bot-bottle.db"),
|
||||||
)
|
)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ 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.queue_dir.is_dir())
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestPathHelpers(unittest.TestCase):
|
|||||||
|
|
||||||
def test_queue_db_path_for_slug_dir(self) -> None:
|
def test_queue_db_path_for_slug_dir(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
Path("/tmp/queue/supervise.db"),
|
supervise.host_db_path(),
|
||||||
supervise.queue_db_path(Path("/tmp/queue")),
|
supervise.queue_db_path(Path("/tmp/queue")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,10 @@ 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("/dev/null/cannot-exist"),
|
queue_dir=Path("/unused"),
|
||||||
)
|
)
|
||||||
with self.assertRaises(_RpcInternalError) as cm:
|
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
|
||||||
|
self.assertRaises(_RpcInternalError) as cm:
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
|
|||||||
Reference in New Issue
Block a user