PRD: SQLite local storage #320

Open
didericis-codex wants to merge 10 commits from sqlite-local-storage into main
6 changed files with 394 additions and 312 deletions
Showing only changes of commit 244ad6a914 - Show all commits
+2
View File
@@ -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
+120
View File
@@ -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
Review

call this get_supervise_mod for legibility.

call this `get_supervise_mod` for legibility.
Review

Done — renamed to get_supervise_mod() in both audit_store.py and queue_store.py (e8e4f6f).

Done — renamed to `get_supervise_mod()` in both `audit_store.py` and `queue_store.py` (`e8e4f6f`).
"""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"
Review

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.
Review

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.

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.
Review

@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:
Review

put this on the AuditStore class

put this on the AuditStore class
Review

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.

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:
Review

method for this is unnecessary

method for this is unnecessary
Review

Removed — inlined as db_path or get_supervise_mod().host_db_path() directly in __init__.

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 (
Review

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.

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.
Review

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.

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"]
+248
View File
@@ -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
Review

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
Review

All applied here too: _svget_supervise_mod(), row helpers moved to _row_to_proposal / _row_to_response static methods on QueueStore, _host_db_path() inlined.

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:
Review

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)

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)
Outdated
Review

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.

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:
Review

Same issue about migrations also applies here

Same issue about migrations also applies here
Review

Same migration runner applied here — _MIGRATIONS list with proposals (v1) and responses (v2), tracked under queue_store key in schema_versions.

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"]
+23 -307
View File
@@ -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
1
@@ -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 -----------------------------------------------------------
Outdated
Review

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",
+1 -2
View File
@@ -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)
-3
View File
@@ -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: