PRD: SQLite local storage #320
@@ -66,6 +66,8 @@ COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
COPY bot_bottle/queue_store.py /app/queue_store.py
|
||||
COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""SQLite-backed audit store for supervise (PRD 0013)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .supervise import AuditEntry
|
||||
|
||||
|
||||
def _sv() -> object:
|
||||
|
didericis marked this conversation as resolved
|
||||
"""Lazy import of supervise to avoid a circular-import at module init time.
|
||||
Mirrors our own module identity so patches on supervise.bot_bottle_root
|
||||
propagate correctly in both flat (sidecar / sys.path-injection tests) and
|
||||
package contexts."""
|
||||
import sys
|
||||
sv_name = "supervise" if __name__ == "audit_store" else "bot_bottle.supervise"
|
||||
|
didericis
commented
The need for two names smells a bit here... why exactly do we need to import from two directions? Would rather create some sort of wrapper for the audit_store if needed to properly resolve dependencies. The need for two names smells a bit here... why exactly do we need to import from two directions? Would rather create some sort of wrapper for the audit_store if needed to properly resolve dependencies.
didericis-claude
commented
The two names exist because the store modules run in two different identity contexts at runtime:
If The clean fix is to extract The two names exist because the store modules run in two different identity contexts at runtime:
1. **Package context** (`bot_bottle.audit_store`): `supervise` is at `sys.modules["bot_bottle.supervise"]`. Tests patch `bot_bottle.supervise.bot_bottle_root`.
2. **Sidecar / injected-path context** (`audit_store`): `supervise.py` is copied flat to `/app/` with no package. `test_supervise_server.py` also injects `bot_bottle/` into `sys.path` and imports `supervise` directly, so patches go on `sys.modules["supervise"]`.
If `get_supervise_mod()` always returned `bot_bottle.supervise`, sidecar imports and the server tests would see an unpatched module — the wrong object.
The clean fix is to extract `Proposal`, `Response`, `AuditEntry`, and `host_db_path` into a standalone `_supervise_types.py` module that neither `supervise.py` nor the store files import circularly. That eliminates `get_supervise_mod()` entirely. Happy to do it as a follow-up or inline here if you prefer — it touches `supervise.py` substantially.
didericis
commented
@didericis-claude can do it now @didericis-claude can do it now
|
||||
if sv_name in sys.modules:
|
||||
return sys.modules[sv_name]
|
||||
try:
|
||||
import bot_bottle.supervise as _m
|
||||
except ImportError:
|
||||
import supervise as _m # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
return _m
|
||||
|
||||
|
||||
def _audit_entry_from_row(row: sqlite3.Row) -> AuditEntry:
|
||||
|
didericis
commented
put this on the AuditStore class put this on the AuditStore class
didericis-claude
commented
Done — moved to Done — moved to `AuditStore._row_to_entry` (static method). `_proposal_from_row` and `_response_from_row` similarly moved to `QueueStore._row_to_proposal` / `_row_to_response`.
|
||||
m = _sv()
|
||||
return m.AuditEntry( # type: ignore[attr-defined]
|
||||
timestamp=row["timestamp"],
|
||||
bottle_slug=row["bottle_slug"],
|
||||
component=row["component"],
|
||||
operator_action=row["operator_action"],
|
||||
operator_notes=row["operator_notes"],
|
||||
justification=row["justification"],
|
||||
diff=row["diff"],
|
||||
)
|
||||
|
||||
|
||||
def _host_db_path() -> Path:
|
||||
|
didericis
commented
method for this is unnecessary method for this is unnecessary
didericis-claude
commented
Removed — inlined as Removed — inlined as `db_path or get_supervise_mod().host_db_path()` directly in `__init__`.
|
||||
return _sv().host_db_path() # type: ignore[attr-defined,no-any-return]
|
||||
|
||||
|
||||
class AuditStore:
|
||||
"""SQLite-backed persistent store for supervise audit entries."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self.db_path = db_path or _host_db_path()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init()
|
||||
|
||||
def write_audit_entry(self, entry: AuditEntry) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO supervise_audit_entries (
|
||||
timestamp, bottle_slug, component, operator_action,
|
||||
operator_notes, justification, diff
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
entry.timestamp,
|
||||
entry.bottle_slug,
|
||||
entry.component,
|
||||
entry.operator_action,
|
||||
entry.operator_notes,
|
||||
entry.justification,
|
||||
entry.diff,
|
||||
),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
|
||||
if not self.db_path.is_file():
|
||||
return []
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM supervise_audit_entries
|
||||
WHERE component = ? AND bottle_slug = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(component, slug),
|
||||
).fetchall()
|
||||
return [_audit_entry_from_row(row) for row in rows]
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supervise_audit_entries (
|
||||
|
didericis
commented
I think we probably do want a migration framework baked in from the beginning... would be very painful to introduce later given we don't have control over client side sqlite dbs. Is there anything in the sqlite3 stdlib module we could leverage or should we create a basic set of I think we probably do want a migration framework baked in from the beginning... would be very painful to introduce later given we don't have control over client side sqlite dbs. Is there anything in the sqlite3 stdlib module we could leverage or should we create a basic set of `up` and `down` migration scripts? Regardless, think we should probably change how the table gets created here.
didericis-claude
commented
Added. Each store now has a I used a Added. Each store now has a `_MIGRATIONS` list (one SQL string per version). `_init()` reads the stored version from a shared `schema_versions` table keyed by module name, applies only the pending entries, and updates the version after each one. No third-party deps — pure sqlite3 stdlib.
I used a `schema_versions` table rather than `PRAGMA user_version` because both stores share the same database file; a single global integer would cause one store to skip its migrations when the other had already advanced the counter.
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
bottle_slug TEXT NOT NULL,
|
||||
component TEXT NOT NULL,
|
||||
operator_action TEXT NOT NULL,
|
||||
operator_notes TEXT NOT NULL,
|
||||
justification TEXT NOT NULL,
|
||||
diff TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def _chmod(self) -> None:
|
||||
try:
|
||||
self.db_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AuditStore"]
|
||||
@@ -0,0 +1,248 @@
|
||||
"""SQLite-backed queue store for supervise proposals and responses (PRD 0013)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .supervise import Proposal, Response
|
||||
|
||||
|
||||
def _sv() -> object:
|
||||
"""Lazy import of supervise to avoid a circular-import at module init time.
|
||||
By the time any QueueStore method is called, both modules are fully loaded.
|
||||
|
||||
Mirrors our own module identity: when we are 'queue_store' (sidecar flat
|
||||
context or tests that inject bot_bottle/ into sys.path) we use the flat
|
||||
'supervise' module so that patches on supervise.bot_bottle_root propagate
|
||||
correctly. When we are 'bot_bottle.queue_store' we use 'bot_bottle.supervise'."""
|
||||
import sys
|
||||
sv_name = "supervise" if __name__ == "queue_store" else "bot_bottle.supervise"
|
||||
if sv_name in sys.modules:
|
||||
return sys.modules[sv_name]
|
||||
try:
|
||||
import bot_bottle.supervise as _m
|
||||
except ImportError:
|
||||
import supervise as _m # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
return _m
|
||||
|
||||
|
||||
def _proposal_from_row(row: sqlite3.Row) -> Proposal:
|
||||
m = _sv()
|
||||
return m.Proposal( # type: ignore[attr-defined]
|
||||
id=row["id"],
|
||||
bottle_slug=row["bottle_slug"],
|
||||
tool=row["tool"],
|
||||
proposed_file=row["proposed_file"],
|
||||
justification=row["justification"],
|
||||
arrival_timestamp=row["arrival_timestamp"],
|
||||
current_file_hash=row["current_file_hash"],
|
||||
)
|
||||
|
||||
|
||||
def _response_from_row(row: sqlite3.Row) -> Response:
|
||||
m = _sv()
|
||||
return m.Response( # type: ignore[attr-defined]
|
||||
proposal_id=row["proposal_id"],
|
||||
status=row["status"],
|
||||
notes=row["notes"],
|
||||
final_file=row["final_file"],
|
||||
)
|
||||
|
didericis marked this conversation as resolved
didericis
commented
Same comments about these method and the lazy import from AuditStore apply here Same comments about these method and the lazy import from AuditStore apply here
didericis-claude
commented
All applied here too: All applied here too: `_sv` → `get_supervise_mod()`, row helpers moved to `_row_to_proposal` / `_row_to_response` static methods on `QueueStore`, `_host_db_path()` inlined.
|
||||
|
||||
|
||||
def _host_db_path() -> Path:
|
||||
return _sv().host_db_path() # type: ignore[attr-defined,no-any-return]
|
||||
|
||||
|
||||
class QueueStore:
|
||||
"""SQLite-backed persistent store for supervise proposals and responses."""
|
||||
|
||||
def __init__(self, queue_key: str, db_path: Path | None = None) -> None:
|
||||
self.queue_key = queue_key
|
||||
if db_path is not None:
|
||||
self.db_path = db_path
|
||||
else:
|
||||
|
didericis
commented
Should probably be a Should probably be a `DbStore` base class which gets passed a `db_path` and a `migrations` object (see https://gitea.dideric.is/didericis/bot-bottle/pulls/320/files#issuecomment-2887 for shape of migrations object)
didericis-claude
commented
Agreed on Agreed on `DbStore(db_path, migrations)`. Plan: subclass constructors resolve `db_path` (env-var check etc.) and then call `super().__init__(db_path, migrations)`. `DbStore` owns `_connect`, `_chmod`, and `_init` (which delegates to `migrations.apply(conn)`). The `SUPERVISE_DB_PATH` env-var path-resolution stays in `QueueStore.__init__` since only the queue store needs it.
|
||||
# In the sidecar container SUPERVISE_DB_PATH points at the
|
||||
# bind-mounted host DB. On the host this env var is never set,
|
||||
# so we always fall through to host_db_path().
|
||||
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
||||
self.db_path = Path(env_path) if env_path else _host_db_path()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init()
|
||||
|
||||
def write_proposal(self, proposal: Proposal) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO supervise_proposals (
|
||||
queue_key, id, bottle_slug, tool, proposed_file, justification,
|
||||
arrival_timestamp, current_file_hash, archived
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(
|
||||
self.queue_key,
|
||||
proposal.id,
|
||||
proposal.bottle_slug,
|
||||
proposal.tool,
|
||||
proposal.proposed_file,
|
||||
proposal.justification,
|
||||
proposal.arrival_timestamp,
|
||||
proposal.current_file_hash,
|
||||
),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def read_proposal(self, proposal_id: str) -> Proposal:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM supervise_proposals
|
||||
WHERE queue_key = ? AND id = ? AND archived = 0
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise FileNotFoundError(proposal_id)
|
||||
return _proposal_from_row(row)
|
||||
|
||||
def list_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 p.queue_key = ?
|
||||
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
|
||||
""",
|
||||
(self.queue_key,),
|
||||
).fetchall()
|
||||
return [_proposal_from_row(row) for row in rows]
|
||||
|
||||
def list_all_pending_proposals(self) -> list[Proposal]:
|
||||
if not self.db_path.is_file():
|
||||
return []
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT p.* FROM supervise_proposals p
|
||||
WHERE p.archived = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM supervise_responses r
|
||||
WHERE r.queue_key = p.queue_key
|
||||
AND r.proposal_id = p.id
|
||||
AND r.archived = 0
|
||||
)
|
||||
ORDER BY p.arrival_timestamp, p.id
|
||||
"""
|
||||
).fetchall()
|
||||
return [_proposal_from_row(row) for row in rows]
|
||||
|
||||
def write_response(self, response: Response) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO supervise_responses (
|
||||
queue_key, proposal_id, status, notes, final_file, archived
|
||||
) VALUES (?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(
|
||||
self.queue_key,
|
||||
response.proposal_id,
|
||||
response.status,
|
||||
response.notes,
|
||||
response.final_file,
|
||||
),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def read_response(self, proposal_id: str) -> Response:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM supervise_responses
|
||||
WHERE queue_key = ? AND proposal_id = ? AND archived = 0
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise FileNotFoundError(proposal_id)
|
||||
return _response_from_row(row)
|
||||
|
||||
def archive_proposal(self, proposal_id: str) -> None:
|
||||
if not self.db_path.is_file():
|
||||
return
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE supervise_proposals SET archived = 1
|
||||
WHERE queue_key = ? AND id = ?
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE supervise_responses SET archived = 1
|
||||
WHERE queue_key = ? AND proposal_id = ?
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init(self) -> None:
|
||||
|
didericis
commented
Same issue about migrations also applies here Same issue about migrations also applies here
didericis-claude
commented
Same migration runner applied here — Same migration runner applied here — `_MIGRATIONS` list with proposals (v1) and responses (v2), tracked under `queue_store` key in `schema_versions`.
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supervise_proposals (
|
||||
queue_key TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
bottle_slug TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
proposed_file TEXT NOT NULL,
|
||||
justification TEXT NOT NULL,
|
||||
arrival_timestamp TEXT NOT NULL,
|
||||
current_file_hash TEXT NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (queue_key, id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supervise_responses (
|
||||
queue_key TEXT NOT NULL,
|
||||
proposal_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
final_file TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (queue_key, proposal_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def _chmod(self) -> None:
|
||||
try:
|
||||
self.db_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["QueueStore"]
|
||||
@@ -33,8 +33,6 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import difflib
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC
|
||||
@@ -109,11 +107,6 @@ def host_db_path() -> Path:
|
||||
return bot_bottle_root() / HOST_DB_FILENAME
|
||||
|
||||
|
||||
def queue_db_path() -> Path:
|
||||
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
||||
return Path(env_path) if env_path else host_db_path()
|
||||
|
||||
|
||||
# --- Dataclasses -----------------------------------------------------------
|
||||
|
||||
|
||||
|
didericis
commented
Don't think this should be a separate db path: we want to indicate that there's a single db on the host that's used for this. I also don't think we want to make it possible to have a separate supervisor db with an env var: it'll likely be important to have supervisor proposals and running bottles accessible to the same query for creating a good dashboard UI. Don't think this should be a separate db path: we want to indicate that there's a single db on the host that's used for this. I also don't think we want to make it possible to have a separate supervisor db with an env var: it'll likely be important to have supervisor proposals and running bottles accessible to the same query for creating a good dashboard UI.
|
||||
@@ -226,37 +219,46 @@ class AuditEntry:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
try:
|
||||
from .queue_store import QueueStore
|
||||
from .audit_store import AuditStore
|
||||
except ImportError:
|
||||
# Sidecar bundle: files are flat-copied under /app, not a package.
|
||||
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
|
||||
|
||||
# --- Queue I/O -------------------------------------------------------------
|
||||
|
||||
|
||||
def write_proposal(proposal: Proposal) -> Path:
|
||||
"""Persist `proposal` in the queue database, mode 0o600.
|
||||
Directory is created if missing."""
|
||||
return _QueueStore(proposal.bottle_slug).write_proposal(proposal)
|
||||
return QueueStore(proposal.bottle_slug).write_proposal(proposal)
|
||||
|
||||
|
||||
def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal:
|
||||
return _QueueStore(bottle_slug).read_proposal(proposal_id)
|
||||
return QueueStore(bottle_slug).read_proposal(proposal_id)
|
||||
|
||||
|
||||
def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
|
||||
"""All proposals for `bottle_slug` that do not yet have a matching
|
||||
response. Sorted by `arrival_timestamp` so the operator
|
||||
sees the queue FIFO."""
|
||||
return _QueueStore(bottle_slug).list_pending_proposals()
|
||||
return QueueStore(bottle_slug).list_pending_proposals()
|
||||
|
||||
|
||||
def list_all_pending_proposals() -> list[Proposal]:
|
||||
"""All pending proposals across bottles, sorted FIFO."""
|
||||
return _QueueStore("").list_all_pending_proposals()
|
||||
return QueueStore("").list_all_pending_proposals()
|
||||
|
||||
|
||||
def write_response(bottle_slug: str, response: Response) -> Path:
|
||||
return _QueueStore(bottle_slug).write_response(response)
|
||||
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)
|
||||
return QueueStore(bottle_slug).read_response(proposal_id)
|
||||
|
||||
|
||||
def wait_for_response(
|
||||
@@ -272,7 +274,7 @@ def wait_for_response(
|
||||
natural shape, since the operator's response time is unbounded.
|
||||
|
||||
Polls SQLite so the implementation stays portable and stdlib-only."""
|
||||
store = _QueueStore(bottle_slug)
|
||||
store = QueueStore(bottle_slug)
|
||||
while True:
|
||||
try:
|
||||
return store.read_response(proposal_id)
|
||||
@@ -286,7 +288,7 @@ def wait_for_response(
|
||||
def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
|
||||
"""Mark both proposal and response rows processed.
|
||||
Idempotent — missing rows are silently skipped."""
|
||||
_QueueStore(bottle_slug).archive_proposal(proposal_id)
|
||||
QueueStore(bottle_slug).archive_proposal(proposal_id)
|
||||
|
||||
|
||||
# --- Audit log -------------------------------------------------------------
|
||||
@@ -294,12 +296,12 @@ def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
|
||||
|
||||
def write_audit_entry(entry: AuditEntry) -> Path:
|
||||
"""Append `entry` to the host supervise audit table."""
|
||||
return _AuditStore().write_audit_entry(entry)
|
||||
return AuditStore().write_audit_entry(entry)
|
||||
|
||||
|
||||
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
|
||||
"""Load all audit entries for the given component+slug."""
|
||||
return _AuditStore().read_audit_entries(component, slug)
|
||||
return AuditStore().read_audit_entries(component, slug)
|
||||
|
||||
|
||||
# --- Diff rendering --------------------------------------------------------
|
||||
@@ -325,293 +327,6 @@ def sha256_hex(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# --- SQLite storage --------------------------------------------------------
|
||||
|
||||
|
||||
class _QueueStore:
|
||||
def __init__(self, queue_key: str) -> None:
|
||||
self.queue_key = queue_key
|
||||
self.db_path = queue_db_path()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init()
|
||||
|
||||
def write_proposal(self, proposal: Proposal) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO supervise_proposals (
|
||||
queue_key, id, bottle_slug, tool, proposed_file, justification,
|
||||
arrival_timestamp, current_file_hash, archived
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(
|
||||
self.queue_key,
|
||||
proposal.id,
|
||||
proposal.bottle_slug,
|
||||
proposal.tool,
|
||||
proposal.proposed_file,
|
||||
proposal.justification,
|
||||
proposal.arrival_timestamp,
|
||||
proposal.current_file_hash,
|
||||
),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def read_proposal(self, proposal_id: str) -> Proposal:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM supervise_proposals
|
||||
WHERE queue_key = ? AND id = ? AND archived = 0
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise FileNotFoundError(proposal_id)
|
||||
return _proposal_from_row(row)
|
||||
|
||||
def list_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 p.queue_key = ?
|
||||
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
|
||||
""",
|
||||
(self.queue_key,),
|
||||
).fetchall()
|
||||
return [_proposal_from_row(row) for row in rows]
|
||||
|
||||
def list_all_pending_proposals(self) -> list[Proposal]:
|
||||
if not self.db_path.is_file():
|
||||
return []
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT p.* FROM supervise_proposals p
|
||||
WHERE p.archived = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM supervise_responses r
|
||||
WHERE r.queue_key = p.queue_key
|
||||
AND r.proposal_id = p.id
|
||||
AND r.archived = 0
|
||||
)
|
||||
ORDER BY p.arrival_timestamp, p.id
|
||||
"""
|
||||
).fetchall()
|
||||
return [_proposal_from_row(row) for row in rows]
|
||||
|
||||
def write_response(self, response: Response) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO supervise_responses (
|
||||
queue_key, proposal_id, status, notes, final_file, archived
|
||||
) VALUES (?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(
|
||||
self.queue_key,
|
||||
response.proposal_id,
|
||||
response.status,
|
||||
response.notes,
|
||||
response.final_file,
|
||||
),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def read_response(self, proposal_id: str) -> Response:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM supervise_responses
|
||||
WHERE queue_key = ? AND proposal_id = ? AND archived = 0
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise FileNotFoundError(proposal_id)
|
||||
return _response_from_row(row)
|
||||
|
||||
def archive_proposal(self, proposal_id: str) -> None:
|
||||
if not self.db_path.is_file():
|
||||
return
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE supervise_proposals SET archived = 1
|
||||
WHERE queue_key = ? AND id = ?
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE supervise_responses SET archived = 1
|
||||
WHERE queue_key = ? AND proposal_id = ?
|
||||
""",
|
||||
(self.queue_key, proposal_id),
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supervise_proposals (
|
||||
queue_key TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
bottle_slug TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
proposed_file TEXT NOT NULL,
|
||||
justification TEXT NOT NULL,
|
||||
arrival_timestamp TEXT NOT NULL,
|
||||
current_file_hash TEXT NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (queue_key, id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supervise_responses (
|
||||
queue_key TEXT NOT NULL,
|
||||
proposal_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
final_file TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (queue_key, proposal_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def _chmod(self) -> None:
|
||||
try:
|
||||
self.db_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class _AuditStore:
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self.db_path = db_path or host_db_path()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init()
|
||||
|
||||
def write_audit_entry(self, entry: AuditEntry) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO supervise_audit_entries (
|
||||
timestamp, bottle_slug, component, operator_action,
|
||||
operator_notes, justification, diff
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
entry.timestamp,
|
||||
entry.bottle_slug,
|
||||
entry.component,
|
||||
entry.operator_action,
|
||||
entry.operator_notes,
|
||||
entry.justification,
|
||||
entry.diff,
|
||||
),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
|
||||
if not self.db_path.is_file():
|
||||
return []
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM supervise_audit_entries
|
||||
WHERE component = ? AND bottle_slug = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(component, slug),
|
||||
).fetchall()
|
||||
return [_audit_entry_from_row(row) for row in rows]
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supervise_audit_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
bottle_slug TEXT NOT NULL,
|
||||
component TEXT NOT NULL,
|
||||
operator_action TEXT NOT NULL,
|
||||
operator_notes TEXT NOT NULL,
|
||||
justification TEXT NOT NULL,
|
||||
diff TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def _chmod(self) -> None:
|
||||
try:
|
||||
self.db_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _proposal_from_row(row: sqlite3.Row) -> Proposal:
|
||||
return Proposal(
|
||||
id=row["id"],
|
||||
bottle_slug=row["bottle_slug"],
|
||||
tool=row["tool"],
|
||||
proposed_file=row["proposed_file"],
|
||||
justification=row["justification"],
|
||||
arrival_timestamp=row["arrival_timestamp"],
|
||||
current_file_hash=row["current_file_hash"],
|
||||
)
|
||||
|
||||
|
||||
def _response_from_row(row: sqlite3.Row) -> Response:
|
||||
return Response(
|
||||
proposal_id=row["proposal_id"],
|
||||
status=row["status"],
|
||||
notes=row["notes"],
|
||||
final_file=row["final_file"],
|
||||
)
|
||||
|
||||
|
||||
def _audit_entry_from_row(row: sqlite3.Row) -> AuditEntry:
|
||||
return AuditEntry(
|
||||
timestamp=row["timestamp"],
|
||||
bottle_slug=row["bottle_slug"],
|
||||
component=row["component"],
|
||||
operator_action=row["operator_action"],
|
||||
operator_notes=row["operator_notes"],
|
||||
justification=row["justification"],
|
||||
diff=row["diff"],
|
||||
)
|
||||
|
||||
|
||||
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
||||
|
||||
|
||||
@@ -642,8 +357,8 @@ class Supervise(ABC):
|
||||
must be set by the launch step before .start runs."""
|
||||
del stage_dir
|
||||
db_path = host_db_path()
|
||||
_QueueStore(slug)
|
||||
_AuditStore(db_path)
|
||||
QueueStore(slug)
|
||||
AuditStore(db_path)
|
||||
return SupervisePlan(
|
||||
slug=slug,
|
||||
db_path=db_path,
|
||||
@@ -662,10 +377,12 @@ def _require_str(raw: dict[str, object], key: str) -> str:
|
||||
__all__ = [
|
||||
"ACTION_OPERATOR_EDIT",
|
||||
"AuditEntry",
|
||||
"AuditStore",
|
||||
"COMPONENT_FOR_TOOL",
|
||||
"DEFAULT_POLL_INTERVAL_SEC",
|
||||
"DB_PATH_IN_CONTAINER",
|
||||
"Proposal",
|
||||
"QueueStore",
|
||||
"Response",
|
||||
"STATUSES",
|
||||
"STATUS_APPROVED",
|
||||
@@ -690,7 +407,6 @@ __all__ = [
|
||||
"host_db_path",
|
||||
"list_pending_proposals",
|
||||
"list_all_pending_proposals",
|
||||
"queue_db_path",
|
||||
"read_audit_entries",
|
||||
"read_proposal",
|
||||
"read_response",
|
||||
|
||||
@@ -20,7 +20,6 @@ from bot_bottle.supervise import (
|
||||
archive_proposal,
|
||||
host_db_path,
|
||||
list_pending_proposals,
|
||||
queue_db_path,
|
||||
read_audit_entries,
|
||||
read_proposal,
|
||||
read_response,
|
||||
@@ -132,7 +131,7 @@ class TestQueueIO(unittest.TestCase):
|
||||
p = _proposal()
|
||||
path = write_proposal(p)
|
||||
self.assertTrue(path.exists())
|
||||
self.assertEqual(queue_db_path(), path)
|
||||
self.assertEqual(host_db_path(), path)
|
||||
self.assertEqual(0o600, path.stat().st_mode & 0o777)
|
||||
loaded = read_proposal(self.slug, p.id)
|
||||
self.assertEqual(p, loaded)
|
||||
|
||||
@@ -38,9 +38,6 @@ class TestPathHelpers(unittest.TestCase):
|
||||
def test_bot_bottle_root(self) -> None:
|
||||
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
|
||||
|
||||
def test_queue_db_path_is_host_db_path(self) -> None:
|
||||
self.assertEqual(supervise.host_db_path(), supervise.queue_db_path())
|
||||
|
||||
|
||||
class TestReadMalformed(unittest.TestCase):
|
||||
def test_read_proposal_missing_row(self) -> None:
|
||||
|
||||
call this
get_supervise_modfor legibility.Done — renamed to
get_supervise_mod()in bothaudit_store.pyandqueue_store.py(e8e4f6f).