1"""SQLite state store (single writer: the controller). All access under one lock (sqlite + threads gotcha, WP29)."""2from __future__ import annotations3import json, os, sqlite3, threading4from .contracts import utcnow56SCHEMA = """7CREATE TABLE IF NOT EXISTS runs (8 run_id TEXT PRIMARY KEY, task_id TEXT, occupation TEXT, arm TEXT, rep INTEGER, model TEXT, stage TEXT,9 node TEXT, session_id TEXT, status TEXT, assigned_utc TEXT, started_utc TEXT, ended_utc TEXT,10 wall_s REAL, timed_out INTEGER, exit_code INTEGER, result_subtype TEXT, cost_usd REAL, num_turns INTEGER,11 recovery_attempted INTEGER DEFAULT 0, recovery_success INTEGER, recovery_cost_usd REAL, recovery_wall_s REAL,12 selfcheck_pass_first INTEGER, selfcheck_pass_final INTEGER,13 n_events INTEGER, claude_version TEXT, order_index INTEGER, seed INTEGER, notes TEXT14);15CREATE TABLE IF NOT EXISTS events (16 event_id TEXT PRIMARY KEY, run_id TEXT, seq INTEGER, received_utc TEXT, elapsed_s REAL, session_id TEXT,17 event_type TEXT, subtype TEXT, tool_name TEXT, summary TEXT, status TEXT, error_class TEXT, usage TEXT, payload_sha256 TEXT18);19CREATE INDEX IF NOT EXISTS ix_events_run ON events(run_id, seq);20CREATE TABLE IF NOT EXISTS artifacts (run_id TEXT, path TEXT, sha256 TEXT, bytes INTEGER, PRIMARY KEY(run_id, path));21CREATE TABLE IF NOT EXISTS receipts (run_id TEXT PRIMARY KEY, receipt TEXT, evaluated_utc TEXT, det_completion INTEGER, det_score REAL, n_violations INTEGER, abstention_status TEXT, abstention_justified INTEGER);22CREATE TABLE IF NOT EXISTS judgments (run_id TEXT, judge_idx INTEGER, judge_model TEXT, score REAL, scores TEXT, rationale TEXT, cost_usd REAL, judged_utc TEXT, raw TEXT, PRIMARY KEY(run_id, judge_idx));23CREATE TABLE IF NOT EXISTS metrics (run_id TEXT PRIMARY KEY, metrics TEXT);24CREATE TABLE IF NOT EXISTS interventions (id INTEGER PRIMARY KEY AUTOINCREMENT, utc TEXT, run_id TEXT, minutes REAL, description TEXT);25CREATE TABLE IF NOT EXISTS ledger (id INTEGER PRIMARY KEY AUTOINCREMENT, utc TEXT, run_id TEXT, kind TEXT, cost_usd REAL);26"""2728class Store:29 def __init__(self, path: str):30 os.makedirs(os.path.dirname(path), exist_ok=True)31 self.path = path; self.lock = threading.RLock()32 self.con = sqlite3.connect(path, check_same_thread=False, timeout=60)33 self.con.row_factory = sqlite3.Row34 with self.lock:35 self.con.executescript(SCHEMA); self.con.commit()36 with self.lock:37 cols = [r[1] for r in self.con.execute("PRAGMA table_info(runs)")]38 if "interrupt_s" not in cols: self.con.execute("ALTER TABLE runs ADD COLUMN interrupt_s INTEGER"); self.con.commit()3940 # --- generic helpers ---41 def exec(self, sql, args=()):42 with self.lock:43 cur = self.con.execute(sql, args); self.con.commit(); return cur44 def q(self, sql, args=()):45 with self.lock:46 return [dict(r) for r in self.con.execute(sql, args).fetchall()]47 def one(self, sql, args=()):48 r = self.q(sql, args); return r[0] if r else None4950 # --- runs ---51 def add_run(self, **kw):52 kw.setdefault("assigned_utc", utcnow()); kw.setdefault("status", "assigned")53 cols = ",".join(kw); ph = ",".join("?" * len(kw))54 self.exec(f"INSERT OR IGNORE INTO runs ({cols}) VALUES ({ph})", tuple(kw.values()))55 def update_run(self, run_id, **kw):56 sets = ",".join(f"{k}=?" for k in kw)57 self.exec(f"UPDATE runs SET {sets} WHERE run_id=?", (*kw.values(), run_id))58 def get_run(self, run_id):59 return self.one("SELECT * FROM runs WHERE run_id=?", (run_id,))60 def runs(self, where="1=1", args=()):61 return self.q(f"SELECT * FROM runs WHERE {where} ORDER BY order_index", args)6263 # --- events ---64 def add_event(self, ev: dict):65 with self.lock:66 self.con.execute("INSERT OR IGNORE INTO events (event_id,run_id,seq,received_utc,elapsed_s,session_id,event_type,subtype,tool_name,summary,status,error_class,usage,payload_sha256) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",67 (ev["event_id"], ev["run_id"], ev["seq"], ev["received_utc"], ev["elapsed_s"], ev.get("session_id"), ev.get("event_type"), ev.get("subtype"), ev.get("tool_name"), ev.get("summary"), ev.get("status"), ev.get("error_class"), json.dumps(ev.get("usage")) if ev.get("usage") is not None else None, ev.get("payload_sha256")))68 self.con.commit()69 def events(self, run_id):70 return self.q("SELECT * FROM events WHERE run_id=? ORDER BY seq", (run_id,))7172 def add_artifacts(self, run_id, items):73 with self.lock:74 self.con.executemany("INSERT OR REPLACE INTO artifacts VALUES (?,?,?,?)", [(run_id, p, h, b) for p, h, b in items]); self.con.commit()75 def set_receipt(self, run_id, receipt: dict):76 self.exec("INSERT OR REPLACE INTO receipts VALUES (?,?,?,?,?,?,?,?)", (run_id, json.dumps(receipt, default=str), utcnow(), int(bool(receipt.get("det_completion"))), float(receipt.get("det_score") or 0), len(receipt.get("critical_violations") or []), (receipt.get("abstention") or {}).get("status"), None if (receipt.get("abstention") or {}).get("justified") is None else int(bool(receipt["abstention"]["justified"]))))77 def set_judgment(self, run_id, idx, model, score, scores, rationale, cost, raw):78 self.exec("INSERT OR REPLACE INTO judgments VALUES (?,?,?,?,?,?,?,?,?)", (run_id, idx, model, score, json.dumps(scores), rationale, cost, utcnow(), raw[:20000] if raw else None))79 def set_metrics(self, run_id, m: dict):80 self.exec("INSERT OR REPLACE INTO metrics VALUES (?,?)", (run_id, json.dumps(m, default=str)))81 def ledger_add(self, run_id, kind, cost):82 self.exec("INSERT INTO ledger (utc,run_id,kind,cost_usd) VALUES (?,?,?,?)", (utcnow(), run_id, kind, float(cost or 0)))83 def ledger_total(self) -> float:84 r = self.one("SELECT COALESCE(SUM(cost_usd),0) AS t FROM ledger"); return float(r["t"]) if r else 0.085 def add_intervention(self, run_id, minutes, description):86 self.exec("INSERT INTO interventions (utc,run_id,minutes,description) VALUES (?,?,?,?)", (utcnow(), run_id, minutes, description))87