Skip to main content

agent_doc_sqlite/
state_store.rs

1//! Project-controller SQLite state store.
2//!
3//! This module owns every `rusqlite::Connection` interaction for the project
4//! controller's durable actor/lease/dispatch/diagnostic/layout state, plus the
5//! storage and status types those queries read and write. It deliberately
6//! depends only on `rusqlite`, `serde_json`, and `std` so that
7//! `agent-doc-orchestration` can call into it without pulling the bundled
8//! SQLite C build into its own compile graph.
9//!
10//! Orchestration glue (ops-log, `sessions.json`/`session-actors.json`
11//! projection, `read_bootstrap`, drift verification) stays in
12//! `agent-doc-orchestration`; this module exposes the SQL primitives those
13//! callers stitch together.
14
15use anyhow::{Context, Result};
16pub use rusqlite::Connection;
17use rusqlite::{OptionalExtension, params};
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23const STATE_DB_FILE: &str = "state.db";
24
25// ---------------------------------------------------------------------------
26// Storage types (moved from agent-doc-orchestration::session_actor).
27// ---------------------------------------------------------------------------
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ActorState {
32    Starting,
33    Ready,
34    Busy,
35    WaitingInput,
36    Closed,
37    Blocked,
38}
39
40impl ActorState {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Starting => "starting",
44            Self::Ready => "ready",
45            Self::Busy => "busy",
46            Self::WaitingInput => "waiting_input",
47            Self::Closed => "closed",
48            Self::Blocked => "blocked",
49        }
50    }
51
52    pub fn parse(raw: &str) -> Option<Self> {
53        match raw {
54            "starting" => Some(Self::Starting),
55            "ready" => Some(Self::Ready),
56            "busy" => Some(Self::Busy),
57            "waiting_input" => Some(Self::WaitingInput),
58            "closed" => Some(Self::Closed),
59            "blocked" => Some(Self::Blocked),
60            _ => None,
61        }
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ActorLastTransition {
67    pub caller: String,
68    pub reason: String,
69    pub timestamp: u64,
70    pub prior_generation: u64,
71    pub new_generation: u64,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ActorRecord {
76    pub document_id: String,
77    pub session_id: String,
78    pub generation: u64,
79    pub pane_id: String,
80    pub window_id: String,
81    pub harness: String,
82    pub state: ActorState,
83    pub last_transition: ActorLastTransition,
84}
85
86// ---------------------------------------------------------------------------
87// Status types (moved from agent-doc-orchestration::project_controller).
88// ---------------------------------------------------------------------------
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ActorTransitionStatus {
92    pub prior_generation: u64,
93    pub new_generation: u64,
94    pub caller: String,
95    pub reason: String,
96    pub old_pane: Option<String>,
97    pub new_pane: String,
98    pub old_window: Option<String>,
99    pub new_window: Option<String>,
100    pub timestamp: u64,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104pub struct SupervisorLeaseStatus {
105    pub generation: u64,
106    pub supervisor_pid: Option<u32>,
107    pub supervisor_socket: Option<String>,
108    pub last_heartbeat: Option<u64>,
109    pub runtime_state: Option<String>,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct DispatchAttemptStatus {
114    pub receipt_id: u64,
115    pub generation: u64,
116    pub command_kind: String,
117    pub accepted_stage: Option<String>,
118    pub failed_stage: Option<String>,
119    pub diagnostic_payload: Option<String>,
120    pub result_status: Option<String>,
121    pub proof_scope: Option<String>,
122    pub dispatch_start_proven: bool,
123    pub timestamp: u64,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub struct DispatchAttemptInsert<'a> {
128    pub document_id: &'a str,
129    pub generation: u64,
130    pub command_kind: &'a str,
131    pub accepted_stage: Option<&'a str>,
132    pub failed_stage: Option<&'a str>,
133    pub diagnostic_payload: &'a str,
134    pub result_status: &'a str,
135    pub proof_scope: &'a str,
136    pub dispatch_start_proven: bool,
137}
138
139#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
140pub struct ProjectionDiagnosticStatus {
141    pub projection: String,
142    pub message: String,
143    pub source_generation: Option<u64>,
144    pub intended_hash: Option<String>,
145    pub retry_status: Option<String>,
146    pub timestamp: u64,
147}
148
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150pub struct QueueHeadStatus {
151    pub document_id: String,
152    pub queue_name: String,
153    pub generation: Option<u64>,
154    pub head_id: Option<String>,
155    pub prompt: String,
156    pub state: String,
157    pub priority: u64,
158    pub selected_at: u64,
159    pub updated_at: u64,
160}
161
162#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub struct QueueControlStatus {
164    pub receipt_id: u64,
165    pub scope_kind: String,
166    pub scope_id: String,
167    pub state: String,
168    pub reason: Option<String>,
169    pub operation_receipt_id: Option<u64>,
170    pub updated_at: u64,
171}
172
173#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct QueueControlInsert<'a> {
175    pub scope_kind: &'a str,
176    pub scope_id: &'a str,
177    pub state: &'a str,
178    pub reason: Option<&'a str>,
179    pub operation_receipt_id: Option<u64>,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct QueueBackpressureStatus {
184    pub receipt_id: u64,
185    pub document_id: String,
186    pub generation: Option<u64>,
187    pub command_kind: String,
188    pub capacity_class: String,
189    pub reason: String,
190    pub dispatch_receipt_id: Option<u64>,
191    pub timestamp: u64,
192}
193
194#[derive(Clone, Debug, PartialEq, Eq)]
195pub struct QueueBackpressureInsert<'a> {
196    pub document_id: &'a str,
197    pub generation: Option<u64>,
198    pub command_kind: &'a str,
199    pub capacity_class: &'a str,
200    pub reason: &'a str,
201    pub dispatch_receipt_id: Option<u64>,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
205pub struct AdminOperationStatus {
206    pub receipt_id: u64,
207    pub operation_kind: String,
208    pub document_id: Option<String>,
209    pub status: String,
210    pub diagnostic_payload: Option<String>,
211    pub timestamp: u64,
212}
213
214#[derive(Clone, Debug, PartialEq, Eq)]
215pub struct ProjectionDiagnosticInsert<'a> {
216    pub projection: &'a str,
217    pub document_id: &'a str,
218    pub message: &'a str,
219    pub source_generation: Option<u64>,
220    pub intended_hash: Option<&'a str>,
221    pub retry_status: &'a str,
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225pub struct SessionOperatorStatus {
226    pub record: Option<ActorRecord>,
227    pub transitions: Vec<ActorTransitionStatus>,
228    pub supervisor_lease: Option<SupervisorLeaseStatus>,
229    pub dispatch_attempts: Vec<DispatchAttemptStatus>,
230    pub projection_diagnostics: Vec<ProjectionDiagnosticStatus>,
231}
232
233#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct ControlPlaneStoreCounts {
235    pub actor_documents: usize,
236    pub live_actor_documents: usize,
237    pub actor_transitions: usize,
238    pub supervisor_leases: usize,
239    pub dispatch_receipts: usize,
240    pub queue_heads: usize,
241    pub document_cycles: usize,
242    pub pending_mutations: usize,
243    pub projection_diagnostics: usize,
244    pub admin_operations: usize,
245    pub queue_controls: usize,
246    pub queue_backpressure: usize,
247    pub crash_recovery_markers: usize,
248    pub layout_states: usize,
249}
250
251impl ControlPlaneStoreCounts {
252    pub fn total_authoritative_rows(&self) -> usize {
253        self.actor_documents
254            + self.actor_transitions
255            + self.supervisor_leases
256            + self.dispatch_receipts
257            + self.queue_heads
258            + self.document_cycles
259            + self.pending_mutations
260            + self.projection_diagnostics
261            + self.admin_operations
262            + self.queue_controls
263            + self.queue_backpressure
264            + self.crash_recovery_markers
265            + self.layout_states
266    }
267}
268
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct SessionActorCloseoutMutation<'a> {
271    pub item_id: &'a str,
272    pub mutation_kind: &'a str,
273    pub status: &'a str,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct SessionActorCloseoutCommit<'a> {
278    pub document_id: &'a str,
279    pub cycle_id: &'a str,
280    pub cycle_state: &'a str,
281    pub queue_name: &'a str,
282    pub queue_head_id: Option<&'a str>,
283    pub queue_head_prompt: Option<&'a str>,
284    pub queue_head_state: &'a str,
285    pub response_commit: Option<&'a str>,
286    pub mutations: Vec<SessionActorCloseoutMutation<'a>>,
287}
288
289// ---------------------------------------------------------------------------
290// Connection + schema.
291// ---------------------------------------------------------------------------
292
293pub fn state_db_path(project_root: &Path) -> PathBuf {
294    project_root.join(".agent-doc").join(STATE_DB_FILE)
295}
296
297pub fn open_state_db(project_root: &Path) -> Result<Connection> {
298    let path = state_db_path(project_root);
299    if let Some(parent) = path.parent() {
300        std::fs::create_dir_all(parent)?;
301    }
302    let conn =
303        Connection::open(&path).with_context(|| format!("failed to open {}", path.display()))?;
304    initialize_state_db(&conn)?;
305    Ok(conn)
306}
307
308pub fn initialize_state_db(conn: &Connection) -> Result<()> {
309    conn.execute_batch(
310        r#"
311        PRAGMA foreign_keys = ON;
312        PRAGMA journal_mode = WAL;
313        PRAGMA busy_timeout = 5000;
314
315        CREATE TABLE IF NOT EXISTS documents (
316            document_id TEXT PRIMARY KEY,
317            canonical_path TEXT NOT NULL,
318            session_id TEXT NOT NULL,
319            generation INTEGER NOT NULL,
320            pane_id TEXT NOT NULL,
321            window_id TEXT NOT NULL,
322            harness_id TEXT NOT NULL,
323            actor_state TEXT NOT NULL,
324            launch_mode TEXT,
325            controller_epoch INTEGER,
326            last_transition_id INTEGER
327        );
328
329        CREATE TABLE IF NOT EXISTS actor_transitions (
330            id INTEGER PRIMARY KEY AUTOINCREMENT,
331            document_id TEXT NOT NULL,
332            prior_generation INTEGER NOT NULL,
333            new_generation INTEGER NOT NULL,
334            caller TEXT NOT NULL,
335            reason TEXT NOT NULL,
336            old_pane TEXT,
337            new_pane TEXT NOT NULL,
338            old_window TEXT,
339            new_window TEXT,
340            timestamp INTEGER NOT NULL
341        );
342
343        CREATE TABLE IF NOT EXISTS supervisor_leases (
344            document_id TEXT NOT NULL,
345            generation INTEGER NOT NULL,
346            supervisor_pid INTEGER,
347            supervisor_socket TEXT,
348            last_heartbeat INTEGER,
349            runtime_state TEXT,
350            PRIMARY KEY (document_id, generation)
351        );
352
353        CREATE TABLE IF NOT EXISTS dispatch_attempts (
354            id INTEGER PRIMARY KEY AUTOINCREMENT,
355            document_id TEXT NOT NULL,
356            generation INTEGER NOT NULL,
357            command_kind TEXT NOT NULL,
358            accepted_stage TEXT,
359            failed_stage TEXT,
360            diagnostic_payload TEXT,
361            result_status TEXT,
362            proof_scope TEXT,
363            dispatch_start_proven INTEGER NOT NULL DEFAULT 0,
364            timestamp INTEGER NOT NULL
365        );
366
367        CREATE TABLE IF NOT EXISTS projection_diagnostics (
368            id INTEGER PRIMARY KEY AUTOINCREMENT,
369            projection TEXT NOT NULL,
370            document_id TEXT NOT NULL,
371            message TEXT NOT NULL,
372            timestamp INTEGER NOT NULL
373        );
374
375        CREATE TABLE IF NOT EXISTS queue_heads (
376            document_id TEXT NOT NULL,
377            queue_name TEXT NOT NULL,
378            generation INTEGER,
379            head_id TEXT,
380            prompt TEXT NOT NULL,
381            state TEXT NOT NULL,
382            priority INTEGER NOT NULL DEFAULT 0,
383            selected_at INTEGER NOT NULL,
384            updated_at INTEGER NOT NULL,
385            PRIMARY KEY (document_id, queue_name)
386        );
387
388        CREATE TABLE IF NOT EXISTS queue_controls (
389            id INTEGER PRIMARY KEY AUTOINCREMENT,
390            scope_kind TEXT NOT NULL,
391            scope_id TEXT NOT NULL,
392            state TEXT NOT NULL,
393            reason TEXT,
394            operation_receipt_id INTEGER,
395            updated_at INTEGER NOT NULL,
396            UNIQUE(scope_kind, scope_id)
397        );
398
399        CREATE TABLE IF NOT EXISTS queue_backpressure (
400            id INTEGER PRIMARY KEY AUTOINCREMENT,
401            document_id TEXT NOT NULL,
402            generation INTEGER,
403            command_kind TEXT NOT NULL,
404            capacity_class TEXT NOT NULL,
405            reason TEXT NOT NULL,
406            dispatch_receipt_id INTEGER,
407            timestamp INTEGER NOT NULL
408        );
409
410        CREATE TABLE IF NOT EXISTS document_cycles (
411            document_id TEXT NOT NULL,
412            cycle_id TEXT NOT NULL,
413            state TEXT NOT NULL,
414            queue_head_id TEXT,
415            response_commit TEXT,
416            updated_at INTEGER NOT NULL,
417            PRIMARY KEY (document_id, cycle_id)
418        );
419
420        CREATE TABLE IF NOT EXISTS pending_mutations (
421            id INTEGER PRIMARY KEY AUTOINCREMENT,
422            document_id TEXT NOT NULL,
423            cycle_id TEXT NOT NULL,
424            item_id TEXT NOT NULL,
425            mutation_kind TEXT NOT NULL,
426            status TEXT NOT NULL,
427            recorded_at INTEGER NOT NULL,
428            UNIQUE(document_id, cycle_id, item_id, mutation_kind)
429        );
430
431        CREATE TABLE IF NOT EXISTS admin_operations (
432            id INTEGER PRIMARY KEY AUTOINCREMENT,
433            operation_kind TEXT NOT NULL,
434            document_id TEXT,
435            status TEXT NOT NULL,
436            diagnostic_payload TEXT,
437            timestamp INTEGER NOT NULL
438        );
439
440        CREATE TABLE IF NOT EXISTS crash_recovery_markers (
441            id INTEGER PRIMARY KEY AUTOINCREMENT,
442            marker_kind TEXT NOT NULL,
443            document_id TEXT,
444            generation INTEGER,
445            status TEXT NOT NULL,
446            diagnostic_payload TEXT,
447            timestamp INTEGER NOT NULL
448        );
449
450        CREATE TABLE IF NOT EXISTS layout_states (
451            scope TEXT PRIMARY KEY,
452            columns_json TEXT NOT NULL,
453            updated_at INTEGER NOT NULL
454        );
455        "#,
456    )?;
457    ensure_dispatch_attempt_receipt_columns(conn)?;
458    ensure_projection_diagnostic_columns(conn)?;
459    ensure_queue_head_columns(conn)?;
460    Ok(())
461}
462
463fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
464    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
465    let mut rows = stmt.query([])?;
466    while let Some(row) = rows.next()? {
467        let existing: String = row.get(1)?;
468        if existing == column {
469            return Ok(());
470        }
471    }
472    conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {definition}"), [])?;
473    Ok(())
474}
475
476fn ensure_dispatch_attempt_receipt_columns(conn: &Connection) -> Result<()> {
477    ensure_column(
478        conn,
479        "dispatch_attempts",
480        "result_status",
481        "result_status TEXT",
482    )?;
483    ensure_column(conn, "dispatch_attempts", "proof_scope", "proof_scope TEXT")?;
484    ensure_column(
485        conn,
486        "dispatch_attempts",
487        "dispatch_start_proven",
488        "dispatch_start_proven INTEGER NOT NULL DEFAULT 0",
489    )?;
490    Ok(())
491}
492
493fn ensure_projection_diagnostic_columns(conn: &Connection) -> Result<()> {
494    ensure_column(
495        conn,
496        "projection_diagnostics",
497        "source_generation",
498        "source_generation INTEGER",
499    )?;
500    ensure_column(
501        conn,
502        "projection_diagnostics",
503        "intended_hash",
504        "intended_hash TEXT",
505    )?;
506    ensure_column(
507        conn,
508        "projection_diagnostics",
509        "retry_status",
510        "retry_status TEXT",
511    )?;
512    Ok(())
513}
514
515fn ensure_queue_head_columns(conn: &Connection) -> Result<()> {
516    ensure_column(conn, "queue_heads", "generation", "generation INTEGER")?;
517    ensure_column(
518        conn,
519        "queue_heads",
520        "priority",
521        "priority INTEGER NOT NULL DEFAULT 0",
522    )?;
523    Ok(())
524}
525
526// ---------------------------------------------------------------------------
527// Conversion + time helpers.
528// ---------------------------------------------------------------------------
529
530pub fn sqlite_i64(value: u64, name: &str) -> Result<i64> {
531    i64::try_from(value).with_context(|| format!("{name} is too large for sqlite INTEGER"))
532}
533
534pub fn sqlite_u64(value: i64, name: &str) -> Result<u64> {
535    u64::try_from(value).with_context(|| format!("{name} is negative in sqlite state"))
536}
537
538pub fn timestamp_secs() -> u64 {
539    SystemTime::now()
540        .duration_since(UNIX_EPOCH)
541        .unwrap_or_default()
542        .as_secs()
543}
544
545fn count_rows(conn: &Connection, sql: &str, label: &str) -> Result<usize> {
546    let count: i64 = conn
547        .query_row(sql, [], |row| row.get(0))
548        .with_context(|| format!("failed to count {label} in controller state"))?;
549    usize::try_from(count).with_context(|| format!("{label} count is negative"))
550}
551
552pub fn load_control_plane_store_counts(conn: &Connection) -> Result<ControlPlaneStoreCounts> {
553    Ok(ControlPlaneStoreCounts {
554        actor_documents: count_rows(conn, "SELECT COUNT(*) FROM documents", "actor documents")?,
555        live_actor_documents: count_rows(
556            conn,
557            "SELECT COUNT(*) FROM documents WHERE actor_state != 'closed'",
558            "live actor documents",
559        )?,
560        actor_transitions: count_rows(
561            conn,
562            "SELECT COUNT(*) FROM actor_transitions",
563            "actor transitions",
564        )?,
565        supervisor_leases: count_rows(
566            conn,
567            "SELECT COUNT(*) FROM supervisor_leases",
568            "supervisor leases",
569        )?,
570        dispatch_receipts: count_rows(
571            conn,
572            "SELECT COUNT(*) FROM dispatch_attempts",
573            "dispatch attempts",
574        )?,
575        queue_heads: count_rows(conn, "SELECT COUNT(*) FROM queue_heads", "queue heads")?,
576        document_cycles: count_rows(
577            conn,
578            "SELECT COUNT(*) FROM document_cycles",
579            "document cycles",
580        )?,
581        pending_mutations: count_rows(
582            conn,
583            "SELECT COUNT(*) FROM pending_mutations",
584            "pending mutations",
585        )?,
586        projection_diagnostics: count_rows(
587            conn,
588            "SELECT COUNT(*) FROM projection_diagnostics",
589            "projection diagnostics",
590        )?,
591        admin_operations: count_rows(
592            conn,
593            "SELECT COUNT(*) FROM admin_operations",
594            "admin operations",
595        )?,
596        queue_controls: count_rows(
597            conn,
598            "SELECT COUNT(*) FROM queue_controls",
599            "queue controls",
600        )?,
601        queue_backpressure: count_rows(
602            conn,
603            "SELECT COUNT(*) FROM queue_backpressure",
604            "queue backpressure",
605        )?,
606        crash_recovery_markers: count_rows(
607            conn,
608            "SELECT COUNT(*) FROM crash_recovery_markers",
609            "crash recovery markers",
610        )?,
611        layout_states: count_rows(conn, "SELECT COUNT(*) FROM layout_states", "layout states")?,
612    })
613}
614
615// ---------------------------------------------------------------------------
616// Actor reads.
617// ---------------------------------------------------------------------------
618
619pub fn actor_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ActorRecord> {
620    let actor_state: String = row.get("actor_state")?;
621    let state = ActorState::parse(&actor_state).ok_or(rusqlite::Error::InvalidQuery)?;
622    let generation: i64 = row.get("generation")?;
623    let transition_prior: i64 = row.get("prior_generation")?;
624    let transition_new: i64 = row.get("new_generation")?;
625    let transition_timestamp: i64 = row.get("timestamp")?;
626    Ok(ActorRecord {
627        document_id: row.get("document_id")?,
628        session_id: row.get("session_id")?,
629        generation: sqlite_u64(generation, "generation")
630            .map_err(|_| rusqlite::Error::InvalidQuery)?,
631        pane_id: row.get("pane_id")?,
632        window_id: row.get("window_id")?,
633        harness: row.get("harness_id")?,
634        state,
635        last_transition: ActorLastTransition {
636            caller: row.get("caller")?,
637            reason: row.get("reason")?,
638            timestamp: sqlite_u64(transition_timestamp, "transition timestamp")
639                .map_err(|_| rusqlite::Error::InvalidQuery)?,
640            prior_generation: sqlite_u64(transition_prior, "transition prior_generation")
641                .map_err(|_| rusqlite::Error::InvalidQuery)?,
642            new_generation: sqlite_u64(transition_new, "transition new_generation")
643                .map_err(|_| rusqlite::Error::InvalidQuery)?,
644        },
645    })
646}
647
648pub fn load_actor_record_from_db(
649    conn: &Connection,
650    document_id: &str,
651) -> Result<Option<ActorRecord>> {
652    conn.query_row(
653        r#"
654        SELECT
655            d.document_id,
656            d.session_id,
657            d.generation,
658            d.pane_id,
659            d.window_id,
660            d.harness_id,
661            d.actor_state,
662            t.caller,
663            t.reason,
664            t.timestamp,
665            t.prior_generation,
666            t.new_generation
667        FROM documents d
668        JOIN actor_transitions t ON t.id = d.last_transition_id
669        WHERE d.document_id = ?1
670        "#,
671        params![document_id],
672        actor_record_from_row,
673    )
674    .optional()
675    .context("failed to load actor record from controller state")
676}
677
678pub fn load_actor_store_from_db(conn: &Connection) -> Result<BTreeMap<String, ActorRecord>> {
679    let mut stmt = conn.prepare(
680        r#"
681        SELECT
682            d.document_id,
683            d.session_id,
684            d.generation,
685            d.pane_id,
686            d.window_id,
687            d.harness_id,
688            d.actor_state,
689            t.caller,
690            t.reason,
691            t.timestamp,
692            t.prior_generation,
693            t.new_generation
694        FROM documents d
695        JOIN actor_transitions t ON t.id = d.last_transition_id
696        ORDER BY d.document_id
697        "#,
698    )?;
699    let mut store = BTreeMap::new();
700    for row in stmt.query_map([], actor_record_from_row)? {
701        let record = row?;
702        store.insert(record.document_id.clone(), record);
703    }
704    Ok(store)
705}
706
707pub fn load_actor_transitions_from_db(
708    conn: &Connection,
709    document_id: &str,
710) -> Result<Vec<ActorTransitionStatus>> {
711    let mut stmt = conn.prepare(
712        r#"
713        SELECT
714            prior_generation,
715            new_generation,
716            caller,
717            reason,
718            old_pane,
719            new_pane,
720            old_window,
721            new_window,
722            timestamp
723        FROM actor_transitions
724        WHERE document_id = ?1
725        ORDER BY id
726        "#,
727    )?;
728    let mut transitions = Vec::new();
729    for row in stmt.query_map(params![document_id], |row| {
730        let prior_generation: i64 = row.get("prior_generation")?;
731        let new_generation: i64 = row.get("new_generation")?;
732        let timestamp: i64 = row.get("timestamp")?;
733        Ok(ActorTransitionStatus {
734            prior_generation: sqlite_u64(prior_generation, "transition prior_generation")
735                .map_err(|_| rusqlite::Error::InvalidQuery)?,
736            new_generation: sqlite_u64(new_generation, "transition new_generation")
737                .map_err(|_| rusqlite::Error::InvalidQuery)?,
738            caller: row.get("caller")?,
739            reason: row.get("reason")?,
740            old_pane: row.get("old_pane")?,
741            new_pane: row.get("new_pane")?,
742            old_window: row.get("old_window")?,
743            new_window: row.get("new_window")?,
744            timestamp: sqlite_u64(timestamp, "transition timestamp")
745                .map_err(|_| rusqlite::Error::InvalidQuery)?,
746        })
747    })? {
748        transitions.push(row?);
749    }
750    Ok(transitions)
751}
752
753pub fn load_supervisor_lease_from_db(
754    conn: &Connection,
755    document_id: &str,
756    generation: u64,
757) -> Result<Option<SupervisorLeaseStatus>> {
758    conn.query_row(
759        r#"
760        SELECT
761            generation,
762            supervisor_pid,
763            supervisor_socket,
764            last_heartbeat,
765            runtime_state
766        FROM supervisor_leases
767        WHERE document_id = ?1 AND generation = ?2
768        "#,
769        params![document_id, sqlite_i64(generation, "generation")?],
770        |row| {
771            let generation: i64 = row.get("generation")?;
772            let supervisor_pid: Option<i64> = row.get("supervisor_pid")?;
773            let last_heartbeat: Option<i64> = row.get("last_heartbeat")?;
774            Ok(SupervisorLeaseStatus {
775                generation: sqlite_u64(generation, "supervisor lease generation")
776                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
777                supervisor_pid: supervisor_pid.and_then(|pid| u32::try_from(pid).ok()),
778                supervisor_socket: row.get("supervisor_socket")?,
779                last_heartbeat: last_heartbeat
780                    .map(|value| sqlite_u64(value, "supervisor last heartbeat"))
781                    .transpose()
782                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
783                runtime_state: row.get("runtime_state")?,
784            })
785        },
786    )
787    .optional()
788    .context("failed to load supervisor lease from controller state")
789}
790
791pub fn load_dispatch_attempts_from_db(
792    conn: &Connection,
793    document_id: &str,
794) -> Result<Vec<DispatchAttemptStatus>> {
795    let mut stmt = conn.prepare(
796        r#"
797        SELECT
798            id,
799            generation,
800            command_kind,
801            accepted_stage,
802            failed_stage,
803            diagnostic_payload,
804            result_status,
805            proof_scope,
806            dispatch_start_proven,
807            timestamp
808        FROM dispatch_attempts
809        WHERE document_id = ?1
810        ORDER BY id DESC
811        LIMIT 10
812        "#,
813    )?;
814    let mut attempts = Vec::new();
815    for row in stmt.query_map(params![document_id], |row| {
816        let receipt_id: i64 = row.get("id")?;
817        let generation: i64 = row.get("generation")?;
818        let dispatch_start_proven: i64 = row.get("dispatch_start_proven")?;
819        let timestamp: i64 = row.get("timestamp")?;
820        Ok(DispatchAttemptStatus {
821            receipt_id: sqlite_u64(receipt_id, "dispatch receipt id")
822                .map_err(|_| rusqlite::Error::InvalidQuery)?,
823            generation: sqlite_u64(generation, "dispatch generation")
824                .map_err(|_| rusqlite::Error::InvalidQuery)?,
825            command_kind: row.get("command_kind")?,
826            accepted_stage: row.get("accepted_stage")?,
827            failed_stage: row.get("failed_stage")?,
828            diagnostic_payload: row.get("diagnostic_payload")?,
829            result_status: row.get("result_status")?,
830            proof_scope: row.get("proof_scope")?,
831            dispatch_start_proven: dispatch_start_proven != 0,
832            timestamp: sqlite_u64(timestamp, "dispatch timestamp")
833                .map_err(|_| rusqlite::Error::InvalidQuery)?,
834        })
835    })? {
836        attempts.push(row?);
837    }
838    attempts.reverse();
839    Ok(attempts)
840}
841
842pub fn load_projection_diagnostics_from_db(
843    conn: &Connection,
844    document_id: &str,
845) -> Result<Vec<ProjectionDiagnosticStatus>> {
846    let mut stmt = conn.prepare(
847        r#"
848        SELECT projection, message, source_generation, intended_hash, retry_status, timestamp
849        FROM projection_diagnostics
850        WHERE document_id = ?1
851        ORDER BY id DESC
852        LIMIT 10
853        "#,
854    )?;
855    let mut diagnostics = Vec::new();
856    for row in stmt.query_map(params![document_id], |row| {
857        let timestamp: i64 = row.get("timestamp")?;
858        let source_generation: Option<i64> = row.get("source_generation")?;
859        Ok(ProjectionDiagnosticStatus {
860            projection: row.get("projection")?,
861            message: row.get("message")?,
862            source_generation: source_generation
863                .map(|generation| sqlite_u64(generation, "projection source generation"))
864                .transpose()
865                .map_err(|_| rusqlite::Error::InvalidQuery)?,
866            intended_hash: row.get("intended_hash")?,
867            retry_status: row.get("retry_status")?,
868            timestamp: sqlite_u64(timestamp, "projection diagnostic timestamp")
869                .map_err(|_| rusqlite::Error::InvalidQuery)?,
870        })
871    })? {
872        diagnostics.push(row?);
873    }
874    diagnostics.reverse();
875    Ok(diagnostics)
876}
877
878pub fn load_queue_head_from_db(
879    conn: &Connection,
880    document_id: &str,
881    queue_name: &str,
882) -> Result<Option<QueueHeadStatus>> {
883    conn.query_row(
884        r#"
885        SELECT
886            document_id,
887            queue_name,
888            generation,
889            head_id,
890            prompt,
891            state,
892            priority,
893            selected_at,
894            updated_at
895        FROM queue_heads
896        WHERE document_id = ?1 AND queue_name = ?2
897        "#,
898        params![document_id, queue_name],
899        |row| {
900            let generation: Option<i64> = row.get("generation")?;
901            let priority: i64 = row.get("priority")?;
902            let selected_at: i64 = row.get("selected_at")?;
903            let updated_at: i64 = row.get("updated_at")?;
904            Ok(QueueHeadStatus {
905                document_id: row.get("document_id")?,
906                queue_name: row.get("queue_name")?,
907                generation: generation
908                    .map(|generation| sqlite_u64(generation, "queue head generation"))
909                    .transpose()
910                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
911                head_id: row.get("head_id")?,
912                prompt: row.get("prompt")?,
913                state: row.get("state")?,
914                priority: sqlite_u64(priority, "queue head priority")
915                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
916                selected_at: sqlite_u64(selected_at, "queue head selected_at")
917                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
918                updated_at: sqlite_u64(updated_at, "queue head updated_at")
919                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
920            })
921        },
922    )
923    .optional()
924    .context("failed to load queue head from controller state")
925}
926
927pub fn load_queue_control_from_db(
928    conn: &Connection,
929    scope_kind: &str,
930    scope_id: &str,
931) -> Result<Option<QueueControlStatus>> {
932    conn.query_row(
933        r#"
934        SELECT
935            id,
936            scope_kind,
937            scope_id,
938            state,
939            reason,
940            operation_receipt_id,
941            updated_at
942        FROM queue_controls
943        WHERE scope_kind = ?1 AND scope_id = ?2
944        "#,
945        params![scope_kind, scope_id],
946        |row| {
947            let receipt_id: i64 = row.get("id")?;
948            let operation_receipt_id: Option<i64> = row.get("operation_receipt_id")?;
949            let updated_at: i64 = row.get("updated_at")?;
950            Ok(QueueControlStatus {
951                receipt_id: sqlite_u64(receipt_id, "queue control receipt id")
952                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
953                scope_kind: row.get("scope_kind")?,
954                scope_id: row.get("scope_id")?,
955                state: row.get("state")?,
956                reason: row.get("reason")?,
957                operation_receipt_id: operation_receipt_id
958                    .map(|value| sqlite_u64(value, "queue control operation receipt id"))
959                    .transpose()
960                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
961                updated_at: sqlite_u64(updated_at, "queue control updated_at")
962                    .map_err(|_| rusqlite::Error::InvalidQuery)?,
963            })
964        },
965    )
966    .optional()
967    .context("failed to load queue control from controller state")
968}
969
970pub fn load_effective_queue_control_from_db(
971    conn: &Connection,
972    document_id: &str,
973    project_scope_id: &str,
974) -> Result<Option<QueueControlStatus>> {
975    if let Some(control) = load_queue_control_from_db(conn, "document", document_id)?
976        && control.state != "resumed"
977    {
978        return Ok(Some(control));
979    }
980    let project = load_queue_control_from_db(conn, "project", project_scope_id)?;
981    Ok(project.filter(|control| control.state != "resumed"))
982}
983
984pub fn load_admin_operations_from_db(
985    conn: &Connection,
986    document_id: Option<&str>,
987    limit: usize,
988) -> Result<Vec<AdminOperationStatus>> {
989    let limit = i64::try_from(limit.max(1)).context("admin operation limit too large")?;
990    let mut operations = Vec::new();
991    if let Some(document_id) = document_id {
992        let mut stmt = conn.prepare(
993            r#"
994            SELECT id, operation_kind, document_id, status, diagnostic_payload, timestamp
995            FROM admin_operations
996            WHERE document_id = ?1
997            ORDER BY id DESC
998            LIMIT ?2
999            "#,
1000        )?;
1001        for row in stmt.query_map(params![document_id, limit], admin_operation_from_row)? {
1002            operations.push(row?);
1003        }
1004    } else {
1005        let mut stmt = conn.prepare(
1006            r#"
1007            SELECT id, operation_kind, document_id, status, diagnostic_payload, timestamp
1008            FROM admin_operations
1009            ORDER BY id DESC
1010            LIMIT ?1
1011            "#,
1012        )?;
1013        for row in stmt.query_map(params![limit], admin_operation_from_row)? {
1014            operations.push(row?);
1015        }
1016    }
1017    operations.reverse();
1018    Ok(operations)
1019}
1020
1021pub fn load_queue_backpressure_from_db(
1022    conn: &Connection,
1023    document_id: &str,
1024    limit: usize,
1025) -> Result<Vec<QueueBackpressureStatus>> {
1026    let limit = i64::try_from(limit.max(1)).context("queue backpressure limit too large")?;
1027    let mut stmt = conn.prepare(
1028        r#"
1029        SELECT
1030            id,
1031            document_id,
1032            generation,
1033            command_kind,
1034            capacity_class,
1035            reason,
1036            dispatch_receipt_id,
1037            timestamp
1038        FROM queue_backpressure
1039        WHERE document_id = ?1
1040        ORDER BY id DESC
1041        LIMIT ?2
1042        "#,
1043    )?;
1044    let mut receipts = Vec::new();
1045    for row in stmt.query_map(params![document_id, limit], |row| {
1046        let receipt_id: i64 = row.get("id")?;
1047        let generation: Option<i64> = row.get("generation")?;
1048        let dispatch_receipt_id: Option<i64> = row.get("dispatch_receipt_id")?;
1049        let timestamp: i64 = row.get("timestamp")?;
1050        Ok(QueueBackpressureStatus {
1051            receipt_id: sqlite_u64(receipt_id, "queue backpressure receipt id")
1052                .map_err(|_| rusqlite::Error::InvalidQuery)?,
1053            document_id: row.get("document_id")?,
1054            generation: generation
1055                .map(|generation| sqlite_u64(generation, "queue backpressure generation"))
1056                .transpose()
1057                .map_err(|_| rusqlite::Error::InvalidQuery)?,
1058            command_kind: row.get("command_kind")?,
1059            capacity_class: row.get("capacity_class")?,
1060            reason: row.get("reason")?,
1061            dispatch_receipt_id: dispatch_receipt_id
1062                .map(|receipt_id| sqlite_u64(receipt_id, "queue backpressure dispatch receipt id"))
1063                .transpose()
1064                .map_err(|_| rusqlite::Error::InvalidQuery)?,
1065            timestamp: sqlite_u64(timestamp, "queue backpressure timestamp")
1066                .map_err(|_| rusqlite::Error::InvalidQuery)?,
1067        })
1068    })? {
1069        receipts.push(row?);
1070    }
1071    receipts.reverse();
1072    Ok(receipts)
1073}
1074
1075fn admin_operation_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AdminOperationStatus> {
1076    let receipt_id: i64 = row.get("id")?;
1077    let timestamp: i64 = row.get("timestamp")?;
1078    Ok(AdminOperationStatus {
1079        receipt_id: sqlite_u64(receipt_id, "admin operation receipt id")
1080            .map_err(|_| rusqlite::Error::InvalidQuery)?,
1081        operation_kind: row.get("operation_kind")?,
1082        document_id: row.get("document_id")?,
1083        status: row.get("status")?,
1084        diagnostic_payload: row.get("diagnostic_payload")?,
1085        timestamp: sqlite_u64(timestamp, "admin operation timestamp")
1086            .map_err(|_| rusqlite::Error::InvalidQuery)?,
1087    })
1088}
1089
1090pub fn load_session_operator_status_from_db(
1091    conn: &Connection,
1092    document_id: &str,
1093) -> Result<SessionOperatorStatus> {
1094    let record = load_actor_record_from_db(conn, document_id)?;
1095    let supervisor_lease = match &record {
1096        Some(record) => load_supervisor_lease_from_db(conn, document_id, record.generation)?,
1097        None => None,
1098    };
1099    Ok(SessionOperatorStatus {
1100        record,
1101        transitions: load_actor_transitions_from_db(conn, document_id)?,
1102        supervisor_lease,
1103        dispatch_attempts: load_dispatch_attempts_from_db(conn, document_id)?,
1104        projection_diagnostics: load_projection_diagnostics_from_db(conn, document_id)?,
1105    })
1106}
1107
1108// ---------------------------------------------------------------------------
1109// Actor writes.
1110// ---------------------------------------------------------------------------
1111
1112pub fn insert_actor_transition(
1113    conn: &Connection,
1114    previous: Option<&ActorRecord>,
1115    record: &ActorRecord,
1116) -> Result<i64> {
1117    conn.execute(
1118        r#"
1119        INSERT INTO actor_transitions (
1120            document_id,
1121            prior_generation,
1122            new_generation,
1123            caller,
1124            reason,
1125            old_pane,
1126            new_pane,
1127            old_window,
1128            new_window,
1129            timestamp
1130        )
1131        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1132        "#,
1133        params![
1134            record.document_id,
1135            sqlite_i64(record.last_transition.prior_generation, "prior_generation")?,
1136            sqlite_i64(record.last_transition.new_generation, "new_generation")?,
1137            record.last_transition.caller,
1138            record.last_transition.reason,
1139            previous.map(|prior| prior.pane_id.as_str()),
1140            record.pane_id,
1141            previous.map(|prior| prior.window_id.as_str()),
1142            record.window_id,
1143            sqlite_i64(record.last_transition.timestamp, "transition timestamp")?,
1144        ],
1145    )?;
1146    Ok(conn.last_insert_rowid())
1147}
1148
1149/// Upsert the authoritative `documents` row for an actor.
1150///
1151/// `launch_mode` and `controller_epoch` are the lifted orchestration tendril:
1152/// the orchestration caller computes them from `read_bootstrap` (launch mode
1153/// short string + bootstrap epoch) and passes them in. Pass `None`/`None` when
1154/// no bootstrap state is available.
1155pub fn upsert_actor_document(
1156    conn: &Connection,
1157    record: &ActorRecord,
1158    transition_id: i64,
1159    launch_mode: Option<String>,
1160    controller_epoch: Option<i64>,
1161) -> Result<()> {
1162    conn.execute(
1163        r#"
1164        INSERT INTO documents (
1165            document_id,
1166            canonical_path,
1167            session_id,
1168            generation,
1169            pane_id,
1170            window_id,
1171            harness_id,
1172            actor_state,
1173            launch_mode,
1174            controller_epoch,
1175            last_transition_id
1176        )
1177        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1178        ON CONFLICT(document_id) DO UPDATE SET
1179            canonical_path = excluded.canonical_path,
1180            session_id = excluded.session_id,
1181            generation = excluded.generation,
1182            pane_id = excluded.pane_id,
1183            window_id = excluded.window_id,
1184            harness_id = excluded.harness_id,
1185            actor_state = excluded.actor_state,
1186            launch_mode = excluded.launch_mode,
1187            controller_epoch = excluded.controller_epoch,
1188            last_transition_id = excluded.last_transition_id
1189        "#,
1190        params![
1191            record.document_id,
1192            record.document_id,
1193            record.session_id,
1194            sqlite_i64(record.generation, "generation")?,
1195            record.pane_id,
1196            record.window_id,
1197            record.harness,
1198            record.state.as_str(),
1199            launch_mode,
1200            controller_epoch,
1201            transition_id,
1202        ],
1203    )?;
1204    Ok(())
1205}
1206
1207pub fn upsert_supervisor_lease_in_db(
1208    conn: &Connection,
1209    record: &ActorRecord,
1210    supervisor_pid: Option<u32>,
1211    supervisor_socket: Option<&str>,
1212    runtime_state: &str,
1213) -> Result<()> {
1214    conn.execute(
1215        r#"
1216        INSERT INTO supervisor_leases (
1217            document_id,
1218            generation,
1219            supervisor_pid,
1220            supervisor_socket,
1221            last_heartbeat,
1222            runtime_state
1223        )
1224        VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1225        ON CONFLICT(document_id, generation) DO UPDATE SET
1226            supervisor_pid = COALESCE(excluded.supervisor_pid, supervisor_leases.supervisor_pid),
1227            supervisor_socket = COALESCE(excluded.supervisor_socket, supervisor_leases.supervisor_socket),
1228            last_heartbeat = excluded.last_heartbeat,
1229            runtime_state = excluded.runtime_state
1230        "#,
1231        params![
1232            record.document_id,
1233            sqlite_i64(record.generation, "generation")?,
1234            supervisor_pid.map(i64::from),
1235            supervisor_socket,
1236            sqlite_i64(timestamp_secs(), "supervisor heartbeat timestamp")?,
1237            runtime_state,
1238        ],
1239    )?;
1240    Ok(())
1241}
1242
1243/// `#qflood`: is a dispatch already in flight (accepted, not yet consumed) for this
1244/// document at `generation`? Mirrors the open-dispatch shape the restart reconciler
1245/// keys on. An open dispatch means the current turn already has work queued/running,
1246/// so an auto re-fire would only pile a redundant trigger into the busy pane.
1247pub fn has_open_in_flight_dispatch(
1248    conn: &Connection,
1249    document_id: &str,
1250    generation: u64,
1251) -> Result<bool> {
1252    let count: i64 = conn.query_row(
1253        r#"
1254        SELECT COUNT(*)
1255        FROM dispatch_attempts
1256        WHERE document_id = ?1
1257          AND generation = ?2
1258          AND failed_stage IS NULL
1259          AND COALESCE(result_status, '') IN ('accepted', 'queued', 'running')
1260          AND dispatch_start_proven = 0
1261        "#,
1262        params![document_id, generation as i64],
1263        |row| row.get(0),
1264    )?;
1265    Ok(count > 0)
1266}
1267
1268/// `#ctlrecycle`: is ANY dispatch in flight across every document/generation? The
1269/// controller's idle self-recycle (R1) uses this as its idle proof — it must never
1270/// exit while a turn is mid-dispatch for any session it coordinates. Same open-set
1271/// definition as [`has_open_in_flight_dispatch`] without the document/generation
1272/// filter.
1273pub fn has_any_open_in_flight_dispatch(conn: &Connection) -> Result<bool> {
1274    let count: i64 = conn.query_row(
1275        r#"
1276        SELECT COUNT(*)
1277        FROM dispatch_attempts
1278        WHERE failed_stage IS NULL
1279          AND COALESCE(result_status, '') IN ('accepted', 'queued', 'running')
1280          AND dispatch_start_proven = 0
1281        "#,
1282        [],
1283        |row| row.get(0),
1284    )?;
1285    Ok(count > 0)
1286}
1287
1288/// `#qflood`: mark every open in-flight dispatch for this document consumed. Called
1289/// when the actor transitions to `Ready` (the turn finished → its dispatch is done),
1290/// keeping the open-dispatch set accurate for the next busy episode's coalescing and
1291/// for restart recovery. Returns the number of receipts released.
1292pub fn mark_open_dispatches_consumed(conn: &Connection, document_id: &str) -> Result<usize> {
1293    let released = conn.execute(
1294        r#"
1295        UPDATE dispatch_attempts
1296        SET dispatch_start_proven = 1
1297        WHERE document_id = ?1
1298          AND failed_stage IS NULL
1299          AND COALESCE(result_status, '') IN ('accepted', 'queued', 'running')
1300          AND dispatch_start_proven = 0
1301        "#,
1302        params![document_id],
1303    )?;
1304    Ok(released)
1305}
1306
1307pub fn insert_dispatch_attempt_in_db(
1308    conn: &Connection,
1309    attempt: &DispatchAttemptInsert<'_>,
1310) -> Result<u64> {
1311    conn.execute(
1312        r#"
1313        INSERT INTO dispatch_attempts (
1314            document_id,
1315            generation,
1316            command_kind,
1317            accepted_stage,
1318            failed_stage,
1319            diagnostic_payload,
1320            result_status,
1321            proof_scope,
1322            dispatch_start_proven,
1323            timestamp
1324        )
1325        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1326        "#,
1327        params![
1328            attempt.document_id,
1329            sqlite_i64(attempt.generation, "generation")?,
1330            attempt.command_kind,
1331            attempt.accepted_stage,
1332            attempt.failed_stage,
1333            attempt.diagnostic_payload,
1334            attempt.result_status,
1335            attempt.proof_scope,
1336            if attempt.dispatch_start_proven {
1337                1_i64
1338            } else {
1339                0_i64
1340            },
1341            sqlite_i64(timestamp_secs(), "dispatch attempt timestamp")?,
1342        ],
1343    )?;
1344    sqlite_u64(conn.last_insert_rowid(), "dispatch receipt id")
1345}
1346
1347pub fn insert_projection_diagnostic(
1348    conn: &Connection,
1349    projection: &str,
1350    document_id: &str,
1351    message: &str,
1352) -> Result<()> {
1353    insert_projection_diagnostic_with_metadata(
1354        conn,
1355        &ProjectionDiagnosticInsert {
1356            projection,
1357            document_id,
1358            message,
1359            source_generation: None,
1360            intended_hash: None,
1361            retry_status: "retry_pending",
1362        },
1363    )
1364}
1365
1366pub fn insert_projection_diagnostic_with_metadata(
1367    conn: &Connection,
1368    diagnostic: &ProjectionDiagnosticInsert<'_>,
1369) -> Result<()> {
1370    conn.execute(
1371        r#"
1372        INSERT INTO projection_diagnostics (
1373            projection,
1374            document_id,
1375            message,
1376            source_generation,
1377            intended_hash,
1378            retry_status,
1379            timestamp
1380        )
1381        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1382        "#,
1383        params![
1384            diagnostic.projection,
1385            diagnostic.document_id,
1386            diagnostic.message,
1387            diagnostic
1388                .source_generation
1389                .map(|generation| sqlite_i64(generation, "projection source generation"))
1390                .transpose()?,
1391            diagnostic.intended_hash,
1392            diagnostic.retry_status,
1393            sqlite_i64(timestamp_secs(), "projection diagnostic timestamp")?
1394        ],
1395    )?;
1396    Ok(())
1397}
1398
1399fn evict_cross_document_actor_pane_bindings_tx(
1400    conn: &Connection,
1401    owner_document_id: &str,
1402    pane_id: &str,
1403    caller: &str,
1404    timestamp: u64,
1405    launch_mode: Option<String>,
1406    controller_epoch: Option<i64>,
1407) -> Result<Vec<String>> {
1408    if pane_id.is_empty() {
1409        return Ok(Vec::new());
1410    }
1411    let store = load_actor_store_from_db(conn)?;
1412    let mut evicted = Vec::new();
1413    for prior in store.values() {
1414        if prior.document_id == owner_document_id || prior.pane_id != pane_id {
1415            continue;
1416        }
1417        let mut next = prior.clone();
1418        next.state = ActorState::Closed;
1419        next.pane_id.clear();
1420        next.window_id.clear();
1421        next.last_transition = ActorLastTransition {
1422            caller: caller.to_string(),
1423            reason: format!("evicted_cross_document_pane owner={owner_document_id} pane={pane_id}"),
1424            timestamp,
1425            prior_generation: prior.generation,
1426            new_generation: prior.generation,
1427        };
1428        let transition_id = insert_actor_transition(conn, Some(prior), &next)?;
1429        upsert_actor_document(
1430            conn,
1431            &next,
1432            transition_id,
1433            launch_mode.clone(),
1434            controller_epoch,
1435        )?;
1436        evicted.push(prior.document_id.clone());
1437    }
1438    Ok(evicted)
1439}
1440
1441/// Run the compare-and-swap actor write transaction.
1442///
1443/// Mirrors the prior `project_controller::store_actor_record` transaction body:
1444/// load the previous record, enforce the optional CAS expectation, reject
1445/// generation regressions, recover cross-document pane aliases, insert the
1446/// transition, then upsert the document row using the lifted
1447/// `launch_mode`/`controller_epoch` bootstrap tendril.
1448pub fn store_actor_record_tx(
1449    conn: &mut Connection,
1450    expected_prior_generation: Option<u64>,
1451    record: &ActorRecord,
1452    launch_mode: Option<String>,
1453    controller_epoch: Option<i64>,
1454) -> Result<Vec<String>> {
1455    let tx = conn.transaction()?;
1456    let previous = load_actor_record_from_db(&tx, &record.document_id)?;
1457    let prior_generation = previous
1458        .as_ref()
1459        .map(|prior| prior.generation)
1460        .unwrap_or(record.last_transition.prior_generation);
1461    if let Some(expected) = expected_prior_generation
1462        && prior_generation != expected
1463    {
1464        anyhow::bail!(
1465            "controller actor generation compare-and-swap failed for {}: expected {}, found {}",
1466            record.document_id,
1467            expected,
1468            prior_generation
1469        );
1470    }
1471    if record.generation < prior_generation {
1472        anyhow::bail!(
1473            "controller actor generation regression for {}: attempted {}, current {}",
1474            record.document_id,
1475            record.generation,
1476            prior_generation
1477        );
1478    }
1479    let evicted_document_ids = if record.state != ActorState::Closed {
1480        evict_cross_document_actor_pane_bindings_tx(
1481            &tx,
1482            &record.document_id,
1483            &record.pane_id,
1484            &record.last_transition.caller,
1485            record.last_transition.timestamp,
1486            launch_mode.clone(),
1487            controller_epoch,
1488        )?
1489    } else {
1490        Vec::new()
1491    };
1492    let transition_id = insert_actor_transition(&tx, previous.as_ref(), record)?;
1493    upsert_actor_document(&tx, record, transition_id, launch_mode, controller_epoch)?;
1494    tx.commit()?;
1495    Ok(evicted_document_ids)
1496}
1497
1498/// True when the `documents` table holds no rows yet.
1499///
1500/// Orchestration uses this to gate the legacy `session-actors.json` read so the
1501/// JSON is only loaded when a migration could actually run.
1502pub fn actor_documents_empty(conn: &Connection) -> Result<bool> {
1503    let count: i64 = conn.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))?;
1504    Ok(count == 0)
1505}
1506
1507/// Migrate a legacy `session-actors.json` store into the empty sqlite state.
1508///
1509/// The orchestration caller loads the legacy JSON and the bootstrap-derived
1510/// `launch_mode`/`controller_epoch` tendril, then hands them to this routine,
1511/// which only runs when the `documents` table is still empty.
1512pub fn migrate_actor_store_tx(
1513    conn: &mut Connection,
1514    store: &BTreeMap<String, ActorRecord>,
1515    launch_mode: Option<String>,
1516    controller_epoch: Option<i64>,
1517) -> Result<()> {
1518    let count: i64 = conn.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))?;
1519    if count > 0 {
1520        return Ok(());
1521    }
1522    let tx = conn.transaction()?;
1523    for record in store.values() {
1524        let transition_id = insert_actor_transition(&tx, None, record)?;
1525        upsert_actor_document(
1526            &tx,
1527            record,
1528            transition_id,
1529            launch_mode.clone(),
1530            controller_epoch,
1531        )?;
1532    }
1533    tx.commit()?;
1534    Ok(())
1535}
1536
1537// ---------------------------------------------------------------------------
1538// Control-plane durable facts.
1539// ---------------------------------------------------------------------------
1540
1541pub fn upsert_queue_head_in_db(
1542    conn: &Connection,
1543    document_id: &str,
1544    queue_name: &str,
1545    head_id: Option<&str>,
1546    prompt: &str,
1547    state: &str,
1548) -> Result<()> {
1549    let now = sqlite_i64(timestamp_secs(), "queue head timestamp")?;
1550    conn.execute(
1551        r#"
1552        INSERT INTO queue_heads (
1553            document_id,
1554            queue_name,
1555            head_id,
1556            prompt,
1557            state,
1558            selected_at,
1559            updated_at
1560        )
1561        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
1562        ON CONFLICT(document_id, queue_name) DO UPDATE SET
1563            head_id = excluded.head_id,
1564            prompt = excluded.prompt,
1565            state = excluded.state,
1566            updated_at = excluded.updated_at
1567        "#,
1568        params![document_id, queue_name, head_id, prompt, state, now],
1569    )?;
1570    Ok(())
1571}
1572
1573pub fn upsert_queue_control_in_db(
1574    conn: &Connection,
1575    control: &QueueControlInsert<'_>,
1576) -> Result<QueueControlStatus> {
1577    let now = sqlite_i64(timestamp_secs(), "queue control timestamp")?;
1578    conn.execute(
1579        r#"
1580        INSERT INTO queue_controls (
1581            scope_kind,
1582            scope_id,
1583            state,
1584            reason,
1585            operation_receipt_id,
1586            updated_at
1587        )
1588        VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1589        ON CONFLICT(scope_kind, scope_id) DO UPDATE SET
1590            state = excluded.state,
1591            reason = excluded.reason,
1592            operation_receipt_id = excluded.operation_receipt_id,
1593            updated_at = excluded.updated_at
1594        "#,
1595        params![
1596            control.scope_kind,
1597            control.scope_id,
1598            control.state,
1599            control.reason,
1600            control
1601                .operation_receipt_id
1602                .map(|receipt_id| sqlite_i64(receipt_id, "queue control operation receipt id"))
1603                .transpose()?,
1604            now
1605        ],
1606    )?;
1607    load_queue_control_from_db(conn, control.scope_kind, control.scope_id)?
1608        .context("missing queue control after upsert")
1609}
1610
1611pub fn insert_queue_backpressure_in_db(
1612    conn: &Connection,
1613    backpressure: &QueueBackpressureInsert<'_>,
1614) -> Result<QueueBackpressureStatus> {
1615    conn.execute(
1616        r#"
1617        INSERT INTO queue_backpressure (
1618            document_id,
1619            generation,
1620            command_kind,
1621            capacity_class,
1622            reason,
1623            dispatch_receipt_id,
1624            timestamp
1625        )
1626        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1627        "#,
1628        params![
1629            backpressure.document_id,
1630            backpressure
1631                .generation
1632                .map(|generation| sqlite_i64(generation, "queue backpressure generation"))
1633                .transpose()?,
1634            backpressure.command_kind,
1635            backpressure.capacity_class,
1636            backpressure.reason,
1637            backpressure
1638                .dispatch_receipt_id
1639                .map(|receipt_id| sqlite_i64(receipt_id, "queue backpressure dispatch receipt id"))
1640                .transpose()?,
1641            sqlite_i64(timestamp_secs(), "queue backpressure timestamp")?
1642        ],
1643    )?;
1644    let receipt_id = sqlite_u64(conn.last_insert_rowid(), "queue backpressure receipt id")?;
1645    Ok(QueueBackpressureStatus {
1646        receipt_id,
1647        document_id: backpressure.document_id.to_string(),
1648        generation: backpressure.generation,
1649        command_kind: backpressure.command_kind.to_string(),
1650        capacity_class: backpressure.capacity_class.to_string(),
1651        reason: backpressure.reason.to_string(),
1652        dispatch_receipt_id: backpressure.dispatch_receipt_id,
1653        timestamp: timestamp_secs(),
1654    })
1655}
1656
1657pub fn upsert_document_cycle_state_in_db(
1658    conn: &Connection,
1659    document_id: &str,
1660    cycle_id: &str,
1661    state: &str,
1662    queue_head_id: Option<&str>,
1663    response_commit: Option<&str>,
1664) -> Result<()> {
1665    conn.execute(
1666        r#"
1667        INSERT INTO document_cycles (
1668            document_id,
1669            cycle_id,
1670            state,
1671            queue_head_id,
1672            response_commit,
1673            updated_at
1674        )
1675        VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1676        ON CONFLICT(document_id, cycle_id) DO UPDATE SET
1677            state = excluded.state,
1678            queue_head_id = excluded.queue_head_id,
1679            response_commit = excluded.response_commit,
1680            updated_at = excluded.updated_at
1681        "#,
1682        params![
1683            document_id,
1684            cycle_id,
1685            state,
1686            queue_head_id,
1687            response_commit,
1688            sqlite_i64(timestamp_secs(), "document cycle timestamp")?
1689        ],
1690    )?;
1691    Ok(())
1692}
1693
1694pub fn commit_session_actor_closeout_in_db(
1695    conn: &mut Connection,
1696    closeout: &SessionActorCloseoutCommit<'_>,
1697) -> Result<()> {
1698    let now = sqlite_i64(timestamp_secs(), "session actor closeout timestamp")?;
1699    let tx = conn.transaction()?;
1700
1701    if let Some(prompt) = closeout.queue_head_prompt {
1702        tx.execute(
1703            r#"
1704            INSERT INTO queue_heads (
1705                document_id,
1706                queue_name,
1707                head_id,
1708                prompt,
1709                state,
1710                selected_at,
1711                updated_at
1712            )
1713            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
1714            ON CONFLICT(document_id, queue_name) DO UPDATE SET
1715                head_id = excluded.head_id,
1716                prompt = excluded.prompt,
1717                state = excluded.state,
1718                updated_at = excluded.updated_at
1719            "#,
1720            params![
1721                closeout.document_id,
1722                closeout.queue_name,
1723                closeout.queue_head_id,
1724                prompt,
1725                closeout.queue_head_state,
1726                now
1727            ],
1728        )?;
1729    }
1730
1731    tx.execute(
1732        r#"
1733        INSERT INTO document_cycles (
1734            document_id,
1735            cycle_id,
1736            state,
1737            queue_head_id,
1738            response_commit,
1739            updated_at
1740        )
1741        VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1742        ON CONFLICT(document_id, cycle_id) DO UPDATE SET
1743            state = excluded.state,
1744            queue_head_id = excluded.queue_head_id,
1745            response_commit = excluded.response_commit,
1746            updated_at = excluded.updated_at
1747        "#,
1748        params![
1749            closeout.document_id,
1750            closeout.cycle_id,
1751            closeout.cycle_state,
1752            closeout.queue_head_id,
1753            closeout.response_commit,
1754            now
1755        ],
1756    )?;
1757
1758    for mutation in &closeout.mutations {
1759        tx.execute(
1760            r#"
1761            INSERT INTO pending_mutations (
1762                document_id,
1763                cycle_id,
1764                item_id,
1765                mutation_kind,
1766                status,
1767                recorded_at
1768            )
1769            VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1770            ON CONFLICT(document_id, cycle_id, item_id, mutation_kind) DO UPDATE SET
1771                status = excluded.status,
1772                recorded_at = excluded.recorded_at
1773            "#,
1774            params![
1775                closeout.document_id,
1776                closeout.cycle_id,
1777                mutation.item_id,
1778                mutation.mutation_kind,
1779                mutation.status,
1780                now
1781            ],
1782        )?;
1783    }
1784
1785    tx.commit()?;
1786    Ok(())
1787}
1788
1789pub fn insert_admin_operation_in_db(
1790    conn: &Connection,
1791    operation_kind: &str,
1792    document_id: Option<&str>,
1793    status: &str,
1794    diagnostic_payload: Option<&str>,
1795) -> Result<u64> {
1796    conn.execute(
1797        r#"
1798        INSERT INTO admin_operations (
1799            operation_kind,
1800            document_id,
1801            status,
1802            diagnostic_payload,
1803            timestamp
1804        )
1805        VALUES (?1, ?2, ?3, ?4, ?5)
1806        "#,
1807        params![
1808            operation_kind,
1809            document_id,
1810            status,
1811            diagnostic_payload,
1812            sqlite_i64(timestamp_secs(), "admin operation timestamp")?
1813        ],
1814    )?;
1815    sqlite_u64(conn.last_insert_rowid(), "admin operation receipt id")
1816}
1817
1818pub fn insert_crash_recovery_marker_in_db(
1819    conn: &Connection,
1820    marker_kind: &str,
1821    document_id: Option<&str>,
1822    generation: Option<u64>,
1823    status: &str,
1824    diagnostic_payload: Option<&str>,
1825) -> Result<()> {
1826    conn.execute(
1827        r#"
1828        INSERT INTO crash_recovery_markers (
1829            marker_kind,
1830            document_id,
1831            generation,
1832            status,
1833            diagnostic_payload,
1834            timestamp
1835        )
1836        VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1837        "#,
1838        params![
1839            marker_kind,
1840            document_id,
1841            generation
1842                .map(|value| sqlite_i64(value, "crash recovery marker generation"))
1843                .transpose()?,
1844            status,
1845            diagnostic_payload,
1846            sqlite_i64(timestamp_secs(), "crash recovery marker timestamp")?
1847        ],
1848    )?;
1849    Ok(())
1850}
1851
1852// ---------------------------------------------------------------------------
1853// Layout state.
1854// ---------------------------------------------------------------------------
1855
1856pub fn load_layout_state_from_db(conn: &Connection, scope: &str) -> Result<Vec<String>> {
1857    let raw: Option<String> = conn
1858        .query_row(
1859            "SELECT columns_json FROM layout_states WHERE scope = ?1",
1860            params![scope],
1861            |row| row.get(0),
1862        )
1863        .optional()?;
1864    match raw {
1865        Some(raw) => serde_json::from_str(&raw).context("failed to parse layout state from sqlite"),
1866        None => Ok(Vec::new()),
1867    }
1868}
1869
1870pub fn store_layout_state_in_db(conn: &Connection, scope: &str, columns: &[String]) -> Result<()> {
1871    let columns_json = serde_json::to_string(columns)?;
1872    conn.execute(
1873        r#"
1874        INSERT INTO layout_states (scope, columns_json, updated_at)
1875        VALUES (?1, ?2, ?3)
1876        ON CONFLICT(scope) DO UPDATE SET
1877            columns_json = excluded.columns_json,
1878            updated_at = excluded.updated_at
1879        "#,
1880        params![
1881            scope,
1882            columns_json,
1883            sqlite_i64(timestamp_secs(), "layout state timestamp")?
1884        ],
1885    )?;
1886    Ok(())
1887}
1888
1889pub fn layout_scope_exists(conn: &Connection, scope: &str) -> Result<bool> {
1890    let exists: Option<i64> = conn
1891        .query_row(
1892            "SELECT 1 FROM layout_states WHERE scope = ?1",
1893            params![scope],
1894            |row| row.get(0),
1895        )
1896        .optional()?;
1897    Ok(exists.is_some())
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902    use super::*;
1903
1904    #[test]
1905    fn control_plane_store_counts_extended_fact_categories() -> Result<()> {
1906        let dir = tempfile::TempDir::new()?;
1907        let mut conn = open_state_db(dir.path())?;
1908        let record = ActorRecord {
1909            document_id: "tasks/control-plane.md".to_string(),
1910            session_id: "session-control-plane".to_string(),
1911            generation: 1,
1912            pane_id: "%1".to_string(),
1913            window_id: "@1".to_string(),
1914            harness: "codex".to_string(),
1915            state: ActorState::Ready,
1916            last_transition: ActorLastTransition {
1917                caller: "test".to_string(),
1918                reason: "store_actor_categories".to_string(),
1919                timestamp: timestamp_secs(),
1920                prior_generation: 0,
1921                new_generation: 1,
1922            },
1923        };
1924
1925        store_actor_record_tx(
1926            &mut conn,
1927            None,
1928            &record,
1929            Some("managed".to_string()),
1930            Some(7),
1931        )?;
1932        upsert_supervisor_lease_in_db(
1933            &conn,
1934            &record,
1935            Some(1234),
1936            Some("supervisor.sock"),
1937            "ready",
1938        )?;
1939        insert_dispatch_attempt_in_db(
1940            &conn,
1941            &DispatchAttemptInsert {
1942                document_id: &record.document_id,
1943                generation: record.generation,
1944                command_kind: "managed_reopen",
1945                accepted_stage: Some("accepted"),
1946                failed_stage: None,
1947                diagnostic_payload: "store actor count test",
1948                result_status: "accepted",
1949                proof_scope: "accepted_only",
1950                dispatch_start_proven: false,
1951            },
1952        )?;
1953        upsert_queue_head_in_db(
1954            &conn,
1955            &record.document_id,
1956            "agent:queue",
1957            Some("ctrlplane-storeactor"),
1958            "do [#ctrlplane-storeactor]",
1959            "selected",
1960        )?;
1961        let queue_head =
1962            load_queue_head_from_db(&conn, &record.document_id, "agent:queue")?.unwrap();
1963        assert_eq!(queue_head.generation, None);
1964        assert_eq!(queue_head.priority, 0);
1965        upsert_document_cycle_state_in_db(
1966            &conn,
1967            &record.document_id,
1968            "cycle-storeactor",
1969            "preflight_started",
1970            Some("ctrlplane-storeactor"),
1971            None,
1972        )?;
1973        commit_session_actor_closeout_in_db(
1974            &mut conn,
1975            &SessionActorCloseoutCommit {
1976                document_id: &record.document_id,
1977                cycle_id: "cycle-storeactor",
1978                cycle_state: "committed",
1979                queue_name: "agent:queue",
1980                queue_head_id: Some("ctrlplane-storeactor"),
1981                queue_head_prompt: Some("do [#ctrlplane-storeactor]"),
1982                queue_head_state: "consumed",
1983                response_commit: Some("commit-storeactor"),
1984                mutations: vec![SessionActorCloseoutMutation {
1985                    item_id: "ctrlplane-storeactor",
1986                    mutation_kind: "backlog_completion",
1987                    status: "done",
1988                }],
1989            },
1990        )?;
1991        insert_projection_diagnostic(
1992            &conn,
1993            "session-actors.json",
1994            &record.document_id,
1995            "projection lag",
1996        )?;
1997        insert_admin_operation_in_db(
1998            &conn,
1999            "projection_repair",
2000            Some(&record.document_id),
2001            "accepted",
2002            Some("store actor count test"),
2003        )?;
2004        let queue_control = upsert_queue_control_in_db(
2005            &conn,
2006            &QueueControlInsert {
2007                scope_kind: "document",
2008                scope_id: &record.document_id,
2009                state: "paused",
2010                reason: Some("store actor count test"),
2011                operation_receipt_id: Some(1),
2012            },
2013        )?;
2014        insert_queue_backpressure_in_db(
2015            &conn,
2016            &QueueBackpressureInsert {
2017                document_id: &record.document_id,
2018                generation: Some(record.generation),
2019                command_kind: "managed_reopen",
2020                capacity_class: "queue_paused",
2021                reason: "store actor count test",
2022                dispatch_receipt_id: Some(queue_control.receipt_id),
2023            },
2024        )?;
2025        insert_crash_recovery_marker_in_db(
2026            &conn,
2027            "startup_reconcile",
2028            Some(&record.document_id),
2029            Some(record.generation),
2030            "pending",
2031            Some("store actor count test"),
2032        )?;
2033        store_layout_state_in_db(&conn, "default", &["%1".to_string()])?;
2034
2035        let counts = load_control_plane_store_counts(&conn)?;
2036        assert_eq!(counts.actor_documents, 1);
2037        assert_eq!(counts.live_actor_documents, 1);
2038        assert_eq!(counts.actor_transitions, 1);
2039        assert_eq!(counts.supervisor_leases, 1);
2040        assert_eq!(counts.dispatch_receipts, 1);
2041        assert_eq!(counts.queue_heads, 1);
2042        assert_eq!(counts.document_cycles, 1);
2043        assert_eq!(counts.pending_mutations, 1);
2044        assert_eq!(counts.projection_diagnostics, 1);
2045        assert_eq!(counts.admin_operations, 1);
2046        assert_eq!(counts.queue_controls, 1);
2047        assert_eq!(counts.queue_backpressure, 1);
2048        assert_eq!(counts.crash_recovery_markers, 1);
2049        assert_eq!(counts.layout_states, 1);
2050        assert_eq!(counts.total_authoritative_rows(), 13);
2051
2052        Ok(())
2053    }
2054}