Skip to main content

mj_controller/
database.rs

1pub use mj_core::storage::*;
2// Normalized controller state and composer history stored in SQLite.
3
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
9use std::sync::{Arc, Mutex, OnceLock, PoisonError};
10use std::thread::{self, JoinHandle};
11use std::time::Duration;
12
13use anyhow::{Context, Result, bail, ensure};
14use chrono::Utc;
15use rusqlite::types::Type;
16use rusqlite::{Connection, OptionalExtension, Transaction, params};
17
18use mj_core::config::data_dir;
19use mj_core::state::{
20    CheckpointMetadata, HostContainerSize, ManagedWorktree, MaterializedExecutionState,
21    MaterializedQueuedPrompt, MaterializedSession, MaterializedSessionSummary, MaterializedTurn,
22    MaterializedTurnOutcome, ProjectionWindow, SessionRecord, SessionResourceAllocation,
23    SessionState, State, TargetLocator, TranscriptBody, TranscriptItem,
24    validate_relay_event_digest, validate_relay_event_frontier,
25};
26use mj_core::subagent::SubagentRecord;
27
28use crate::targets::AdditionalMount;
29use mj_core::relay::RELAY_EVENT_GENESIS_DIGEST;
30use mj_core::workspace::{
31    DEFAULT_WORKSPACE_ID, DetachedDraft, PaneSize, PaneSizes, WorkspaceRecord, new_workspace_id,
32    normalize_workspace_name,
33};
34
35const SCHEMA_VERSION: i64 = 33;
36
37mod session_move;
38pub use session_move::*;
39
40mod schema;
41mod usage;
42pub use usage::*;
43mod events;
44pub use events::*;
45
46pub use schema::database_path;
47#[cfg(test)]
48use schema::{forget_verified_schema, table_has_column};
49use schema::{open, open_reader};
50
51const DATABASE_WRITE_QUEUE_CAPACITY: usize = 256;
52
53/// A queued write, handed either the writer's connection or the reason it
54/// must not be used. The job -- not the lane -- decides what a refusal means
55/// to its caller.
56type DatabaseWriteJob = Box<dyn FnOnce(Result<&mut Connection>) + Send + 'static>;
57
58enum DatabaseWriterMessage {
59    Run {
60        label: &'static str,
61        job: DatabaseWriteJob,
62    },
63    Shutdown,
64}
65
66/// Cloneable submission handle for the daemon's ordered SQLite write lane.
67///
68/// Calling [`DatabaseWriter::execute`] is synchronous and may apply bounded
69/// backpressure, so async and UI callers must invoke database mutations from
70/// their existing supervised blocking tasks.
71#[derive(Clone)]
72pub struct DatabaseWriter {
73    id: u64,
74    sender: SyncSender<DatabaseWriterMessage>,
75}
76
77impl std::fmt::Debug for DatabaseWriter {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        formatter
80            .debug_struct("DatabaseWriter")
81            .field("id", &self.id)
82            .finish_non_exhaustive()
83    }
84}
85
86impl DatabaseWriter {
87    fn execute<T, F>(&self, label: &'static str, operation: F) -> Result<T>
88    where
89        T: Send + 'static,
90        F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
91    {
92        let (reply_tx, reply_rx) = sync_channel(1);
93        self.sender
94            .send(DatabaseWriterMessage::Run {
95                label,
96                job: Box::new(move |connection| {
97                    let reply = match connection {
98                        Ok(connection) => operation(connection),
99                        // The mismatch travels as the operation's own failure,
100                        // so a refused write reports why rather than the
101                        // writer-stopped message a dropped reply would give.
102                        Err(error) => Err(error),
103                    };
104                    let _ = reply_tx.send(reply);
105                }),
106            })
107            .map_err(|_| {
108                anyhow::anyhow!("submit database writer operation {label}: writer stopped")
109            })?;
110        reply_rx
111            .recv()
112            .with_context(|| format!("database writer stopped during {label}"))?
113    }
114}
115
116/// Owns the daemon's writer thread and persistent SQLite connection.
117///
118/// The owner is deliberately not cloneable. Dropping it removes the global
119/// submission handle, drains accepted work in FIFO order, and joins the
120/// thread before releasing the connection.
121pub struct DatabaseWriterOwner {
122    writer: DatabaseWriter,
123    thread: Option<JoinHandle<()>>,
124    stopped: Receiver<Result<()>>,
125}
126
127impl std::fmt::Debug for DatabaseWriterOwner {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter
130            .debug_struct("DatabaseWriterOwner")
131            .field("writer", &self.writer)
132            .finish_non_exhaustive()
133    }
134}
135
136impl DatabaseWriterOwner {
137    pub fn shutdown(mut self) -> Result<()> {
138        self.shutdown_inner()
139    }
140
141    fn shutdown_inner(&mut self) -> Result<()> {
142        if self.thread.is_none() {
143            return Ok(());
144        }
145        clear_database_writer(self.writer.id);
146        let send_result = self.writer.sender.send(DatabaseWriterMessage::Shutdown);
147        let worker_result = self
148            .stopped
149            .recv()
150            .context("database writer stopped without reporting its result")?;
151        let join_result = self
152            .thread
153            .take()
154            .expect("database writer thread checked above")
155            .join();
156        if let Err(panic) = join_result {
157            std::panic::resume_unwind(panic);
158        }
159        match (send_result, worker_result) {
160            (_, Err(error)) => Err(error),
161            (Err(_), Ok(())) => bail!("request database writer shutdown: writer stopped"),
162            (Ok(()), Ok(())) => Ok(()),
163        }
164    }
165}
166
167impl Drop for DatabaseWriterOwner {
168    fn drop(&mut self) {
169        if let Err(error) = self.shutdown_inner() {
170            tracing::error!(%error, "database writer did not shut down cleanly");
171        }
172    }
173}
174
175fn database_writer_slot() -> &'static Mutex<Option<DatabaseWriter>> {
176    static WRITER: OnceLock<Mutex<Option<DatabaseWriter>>> = OnceLock::new();
177    WRITER.get_or_init(|| Mutex::new(None))
178}
179
180fn clear_database_writer(id: u64) {
181    let mut installed = database_writer_slot()
182        .lock()
183        .unwrap_or_else(PoisonError::into_inner);
184    if installed.as_ref().is_some_and(|writer| writer.id == id) {
185        *installed = None;
186    }
187}
188
189/// Install the process-wide writer for a test that owns its data directory.
190///
191/// Production installs this once, in the daemon, after `ControllerStoreGuard`
192/// establishes exclusivity, and the daemon is then the only process that
193/// writes. A test may do the same only because it re-execs itself with its own
194/// `MJ_DATA_DIR` and is therefore alone in its process — which is exactly why
195/// the tests that need this are shaped that way.
196///
197/// The returned owner has to be held for the rest of the test: dropping it
198/// stops the writer, and the next write fails with the message above.
199///
200/// This fixture is compiled unconditionally and hidden from the documentation
201/// because the controller crate's tests need it and a `#[cfg(test)]` item is
202/// invisible to another crate. It is a thin wrapper over
203/// [`start_database_writer`], so nothing test-only leaks into the library.
204#[doc(hidden)]
205#[must_use = "the writer stops when this owner is dropped"]
206pub fn install_isolated_test_writer() -> DatabaseWriterOwner {
207    start_database_writer().expect("install the writer for an isolated test child")
208}
209
210pub fn start_database_writer() -> Result<DatabaseWriterOwner> {
211    start_database_writer_at(&database_path(), true)
212}
213
214fn start_database_writer_at(path: &Path, install_globally: bool) -> Result<DatabaseWriterOwner> {
215    static NEXT_WRITER_ID: AtomicU64 = AtomicU64::new(1);
216
217    let connection = schema::open_writer(path)?;
218    let mut observed_revision = schema::read_schema_state(&connection)?.revision;
219    let path = path.to_owned();
220    let (sender, receiver) = sync_channel(DATABASE_WRITE_QUEUE_CAPACITY);
221    let (stopped_tx, stopped) = sync_channel(1);
222    let id = NEXT_WRITER_ID.fetch_add(1, Ordering::Relaxed);
223    let writer = DatabaseWriter { id, sender };
224    if install_globally {
225        let mut installed = database_writer_slot()
226            .lock()
227            .unwrap_or_else(PoisonError::into_inner);
228        ensure!(installed.is_none(), "database writer is already running");
229        *installed = Some(writer.clone());
230    }
231    let thread = match thread::Builder::new()
232        .name("hel-database-writer".to_owned())
233        .spawn(move || {
234            let mut connection = connection;
235            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
236                loop {
237                    match receiver.recv() {
238                        Ok(DatabaseWriterMessage::Run { label, job }) => {
239                            tracing::trace!(operation = label, "running database writer operation");
240                            // Recheck compatibility even after startup, and
241                            // remember forward progress to detect rollback.
242                            match writer_schema_state(
243                                &path,
244                                &connection,
245                                label,
246                                &mut observed_revision,
247                            ) {
248                                Ok(()) => job(Ok(&mut connection)),
249                                Err(error) => job(Err(error)),
250                            }
251                        }
252                        Ok(DatabaseWriterMessage::Shutdown) => break Ok(()),
253                        Err(error) => {
254                            break Err(error).context("database writer queue disconnected");
255                        }
256                    }
257                }
258            }))
259            .unwrap_or_else(|panic| {
260                let detail = panic
261                    .downcast_ref::<&str>()
262                    .copied()
263                    .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
264                    .unwrap_or("unknown panic payload");
265                Err(anyhow::anyhow!("database writer thread panicked: {detail}"))
266            });
267            clear_database_writer(id);
268            let _ = stopped_tx.send(result);
269        }) {
270        Ok(thread) => thread,
271        Err(error) => {
272            if install_globally {
273                clear_database_writer(id);
274            }
275            return Err(error).context("spawn database writer thread");
276        }
277    };
278    Ok(DatabaseWriterOwner {
279        writer,
280        thread: Some(thread),
281        stopped,
282    })
283}
284
285/// Refuse incompatible stores, rollback, and unreadable metadata before a job.
286fn writer_schema_state(
287    path: &Path,
288    connection: &Connection,
289    label: &'static str,
290    observed_revision: &mut i64,
291) -> Result<()> {
292    let result: Result<()> = (|| {
293        let state = schema::read_schema_state(connection)?;
294        if state.revision < *observed_revision {
295            return Err(StoreSchemaMismatch {
296                found: state.revision,
297                supported: SCHEMA_VERSION,
298                reason: StoreSchemaMismatchReason::Rollback {
299                    previous: *observed_revision,
300                },
301            }
302            .into());
303        }
304        *observed_revision = state.revision;
305        state.ensure_supported()
306    })();
307    if let Err(error) = &result {
308        tracing::error!(
309            operation = label,
310            path = %path.display(),
311            error = %error,
312            "could not establish store compatibility; refusing the operation"
313        );
314    }
315    result.with_context(|| {
316        format!(
317            "check database compatibility before {label} at {}",
318            path.display()
319        )
320    })
321}
322
323fn submit_database_write<T, F>(label: &'static str, operation: F) -> Result<T>
324where
325    T: Send + 'static,
326    F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
327{
328    let writer = database_writer_slot()
329        .lock()
330        .unwrap_or_else(PoisonError::into_inner)
331        .clone();
332    if let Some(writer) = writer {
333        writer.execute(label, operation)
334    } else {
335        // There is one way to write, and this is not it. In production the
336        // daemon installs the writer after `ControllerStoreGuard` establishes
337        // exclusivity, and it is the only process that writes; a caller
338        // reaching here has no exclusivity and would be competing with
339        // whatever does. This used to open `database_path()` directly, which
340        // meant any process without a writer silently wrote to — and migrated
341        // — the real user database as a side effect of doing something else.
342        bail!("database writer is not available for operation {label}")
343    }
344}
345
346pub fn load_state_migrating() -> Result<State> {
347    migrate_legacy_state()?;
348    load_state()
349}
350
351pub fn load_state() -> Result<State> {
352    load_state_from(&database_path())
353}
354
355pub fn list_workspaces() -> Result<Vec<WorkspaceRecord>> {
356    list_workspaces_from(&database_path())
357}
358
359struct DbPaneSize(PaneSize);
360
361impl rusqlite::types::ToSql for DbPaneSize {
362    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
363        Ok(match self.0 {
364            PaneSize::Minimized => "minimized",
365            PaneSize::Standard => "standard",
366            PaneSize::Maximized => "maximized",
367        }
368        .into())
369    }
370}
371
372impl rusqlite::types::FromSql for DbPaneSize {
373    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
374        match value.as_str()? {
375            "minimized" => Ok(Self(PaneSize::Minimized)),
376            "standard" => Ok(Self(PaneSize::Standard)),
377            "maximized" => Ok(Self(PaneSize::Maximized)),
378            other => Err(rusqlite::types::FromSqlError::Other(
379                format!("unknown pane size {other:?}").into(),
380            )),
381        }
382    }
383}
384
385pub fn load_workspace_pane_sizes(workspace_id: &str) -> Result<PaneSizes> {
386    load_workspace_pane_sizes_from(&database_path(), workspace_id)
387}
388
389pub fn load_workspace_pane_sizes_from(path: &Path, workspace_id: &str) -> Result<PaneSizes> {
390    let connection = open_reader(path)?;
391    let sizes = connection
392        .query_row(
393            "SELECT coalesce(p.sessions, 'standard'), coalesce(p.targets, 'standard'),
394                    coalesce(p.quota, 'standard')
395             FROM workspaces w LEFT JOIN workspace_pane_sizes p USING(workspace_id)
396             WHERE w.workspace_id = ?1",
397            [workspace_id],
398            |row| {
399                Ok(PaneSizes {
400                    sessions: row.get::<_, DbPaneSize>(0)?.0,
401                    targets: row.get::<_, DbPaneSize>(1)?.0,
402                    quota: row.get::<_, DbPaneSize>(2)?.0,
403                })
404            },
405        )
406        .optional()?
407        .with_context(|| format!("unknown workspace {workspace_id:?}"))?;
408    sizes.validate()?;
409    Ok(sizes)
410}
411
412pub fn save_workspace_pane_sizes(workspace_id: &str, sizes: PaneSizes) -> Result<()> {
413    let workspace_id = workspace_id.to_owned();
414    submit_database_write("save_workspace_pane_sizes", move |_| {
415        save_workspace_pane_sizes_to(&database_path(), &workspace_id, sizes)
416    })
417}
418
419pub fn save_workspace_pane_sizes_to(
420    path: &Path,
421    workspace_id: &str,
422    sizes: PaneSizes,
423) -> Result<()> {
424    sizes.validate()?;
425    let connection = open(path)?;
426    connection
427        .execute(
428            "INSERT INTO workspace_pane_sizes(workspace_id, sessions, targets, quota)
429         VALUES (?1, ?2, ?3, ?4)
430         ON CONFLICT(workspace_id) DO UPDATE SET
431             sessions = excluded.sessions, targets = excluded.targets, quota = excluded.quota",
432            params![
433                workspace_id,
434                DbPaneSize(sizes.sessions),
435                DbPaneSize(sizes.targets),
436                DbPaneSize(sizes.quota)
437            ],
438        )
439        .with_context(|| format!("save pane sizes for workspace {workspace_id:?}"))?;
440    Ok(())
441}
442
443pub fn list_workspaces_from(path: &Path) -> Result<Vec<WorkspaceRecord>> {
444    let connection = open_reader(path)?;
445    let mut statement = connection.prepare(
446        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
447                count(s.session_id) FILTER (
448                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
449                )
450          FROM workspaces w
451           LEFT JOIN session_contexts c USING(workspace_id)
452           LEFT JOIN sessions s USING(session_id)
453          GROUP BY w.workspace_id
454         HAVING w.workspace_id != 'default' OR count(s.session_id) FILTER (
455                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
456                ) > 0
457          ORDER BY w.last_opened_at DESC, w.created_at DESC, w.workspace_id",
458    )?;
459    let rows = statement.query_map([], |row| {
460        Ok(WorkspaceRecord {
461            id: row.get(0)?,
462            name: row.get(1)?,
463            created_at: row.get(2)?,
464            last_opened_at: row.get(3)?,
465            session_count: row.get(4)?,
466        })
467    })?;
468    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
469}
470
471pub fn create_workspace(name: &str) -> Result<WorkspaceRecord> {
472    let name = name.to_owned();
473    submit_database_write("create_workspace", move |_| {
474        create_workspace_at(&database_path(), &name)
475    })
476}
477
478/// Create the named workspace, or return the concurrently-created winner.
479///
480/// Interactive setup uses this operation after presenting a snapshot of the
481/// workspace list. Several selectors can therefore submit the same normalized
482/// name legitimately. Explicit database creation remains strict through
483/// [`create_workspace`].
484pub fn create_or_get_workspace(name: &str) -> Result<WorkspaceRecord> {
485    let name = name.to_owned();
486    submit_database_write("create_or_get_workspace", move |_| {
487        create_or_get_workspace_at(&database_path(), &name)
488    })
489}
490
491pub fn create_or_get_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
492    let (name, name_key) = normalize_workspace_name(name)?;
493    let id = new_workspace_id()?;
494    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
495    let mut connection = open(path)?;
496    let transaction = connection.transaction()?;
497    transaction
498        .execute(
499            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
500             VALUES (?1, ?2, ?3, ?4, ?4)
501             ON CONFLICT(name_key) DO NOTHING",
502            params![id, name, name_key, now],
503        )
504        .with_context(|| format!("create or find workspace {name:?}"))?;
505    let workspace = transaction.query_row(
506        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
507                count(s.session_id) FILTER (
508                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
509                )
510           FROM workspaces w
511           LEFT JOIN session_contexts c USING(workspace_id)
512           LEFT JOIN sessions s USING(session_id)
513          WHERE w.name_key = ?1
514          GROUP BY w.workspace_id",
515        params![name_key],
516        |row| {
517            Ok(WorkspaceRecord {
518                id: row.get(0)?,
519                name: row.get(1)?,
520                created_at: row.get(2)?,
521                last_opened_at: row.get(3)?,
522                session_count: row.get(4)?,
523            })
524        },
525    )?;
526    transaction.commit()?;
527    Ok(workspace)
528}
529
530pub fn create_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
531    let (name, name_key) = normalize_workspace_name(name)?;
532    let id = new_workspace_id()?;
533    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
534    let connection = open(path)?;
535    connection
536        .execute(
537            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
538             VALUES (?1, ?2, ?3, ?4, ?4)",
539            params![id, name, name_key, now],
540        )
541        .with_context(|| format!("create workspace {name:?}"))?;
542    Ok(WorkspaceRecord {
543        id,
544        name,
545        created_at: now.clone(),
546        last_opened_at: now,
547        session_count: 0,
548    })
549}
550
551pub fn rename_workspace(workspace_id: &str, name: &str) -> Result<()> {
552    let workspace_id = workspace_id.to_owned();
553    let name = name.to_owned();
554    submit_database_write("rename_workspace", move |_| {
555        rename_workspace_at(&database_path(), &workspace_id, &name)
556    })
557}
558
559pub fn rename_workspace_at(path: &Path, workspace_id: &str, name: &str) -> Result<()> {
560    let (name, name_key) = normalize_workspace_name(name)?;
561    let connection = open(path)?;
562    let changed = connection
563        .execute(
564            "UPDATE workspaces SET name = ?2, name_key = ?3 WHERE workspace_id = ?1",
565            params![workspace_id, name, name_key],
566        )
567        .with_context(|| format!("rename workspace to {name:?}"))?;
568    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
569    Ok(())
570}
571
572pub fn touch_workspace(workspace_id: &str) -> Result<()> {
573    let workspace_id = workspace_id.to_owned();
574    submit_database_write("touch_workspace", move |_| {
575        touch_workspace_at(&database_path(), &workspace_id)
576    })
577}
578
579pub fn touch_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
580    let connection = open(path)?;
581    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
582    let changed = connection.execute(
583        "UPDATE workspaces SET last_opened_at = ?2 WHERE workspace_id = ?1",
584        params![workspace_id, now],
585    )?;
586    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
587    Ok(())
588}
589
590/// Delete a workspace that owns no active sessions or recoverable drafts.
591///
592/// Inactive session records are global resume history. Their last workspace
593/// id is retained as historical metadata even when that workspace disappears.
594pub fn delete_workspace(workspace_id: &str) -> Result<()> {
595    let workspace_id = workspace_id.to_owned();
596    submit_database_write("delete_workspace", move |_| {
597        delete_workspace_at(&database_path(), &workspace_id)
598    })
599}
600
601pub fn delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
602    let mut connection = open(path)?;
603    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
604    let active_count = {
605        let mut statement = tx.prepare(
606            "SELECT s.state
607               FROM session_contexts c
608               JOIN sessions s USING(session_id)
609              WHERE c.workspace_id = ?1",
610        )?;
611        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
612        states
613            .collect::<rusqlite::Result<Vec<_>>>()?
614            .into_iter()
615            .filter(|state| parse_session_state(state).is_active())
616            .count()
617    };
618    let draft_count: u64 = tx.query_row(
619        "SELECT count(*) FROM detached_drafts WHERE workspace_id = ?1",
620        [workspace_id],
621        |row| row.get(0),
622    )?;
623    ensure!(
624        active_count == 0 && draft_count == 0,
625        "workspace is not empty ({active_count} active sessions, {draft_count} drafts)"
626    );
627    let changed = tx.execute(
628        "DELETE FROM workspaces WHERE workspace_id = ?1",
629        [workspace_id],
630    )?;
631    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
632    tx.commit()?;
633    Ok(())
634}
635
636/// Force-delete a workspace whose active sessions have already been destroyed.
637///
638/// Drops the workspace's detached drafts and the workspace row in one
639/// immediate transaction that re-checks for active sessions, so a session
640/// created while the destruction ran refuses the deletion instead of losing
641/// the drafts. Inactive session records are global history and are preserved,
642/// exactly as in [`delete_workspace`].
643pub fn force_delete_workspace(workspace_id: &str) -> Result<()> {
644    let workspace_id = workspace_id.to_owned();
645    submit_database_write("force_delete_workspace", move |_| {
646        force_delete_workspace_at(&database_path(), &workspace_id)
647    })
648}
649
650pub fn force_delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
651    let mut connection = open(path)?;
652    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
653    let active_count = {
654        let mut statement = tx.prepare(
655            "SELECT s.state
656               FROM session_contexts c
657               JOIN sessions s USING(session_id)
658              WHERE c.workspace_id = ?1",
659        )?;
660        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
661        states
662            .collect::<rusqlite::Result<Vec<_>>>()?
663            .into_iter()
664            .filter(|state| parse_session_state(state).is_active())
665            .count()
666    };
667    ensure!(
668        active_count == 0,
669        "workspace is not empty ({active_count} active sessions remain)"
670    );
671    tx.execute(
672        "DELETE FROM detached_drafts WHERE workspace_id = ?1",
673        [workspace_id],
674    )?;
675    let changed = tx.execute(
676        "DELETE FROM workspaces WHERE workspace_id = ?1",
677        [workspace_id],
678    )?;
679    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
680    tx.commit()?;
681    Ok(())
682}
683
684/// Move a durable history into the workspace from which it is being resumed.
685///
686/// This is deliberately limited to states accepted by the resume controller;
687/// an active session must never move between live dashboards underneath its
688/// worker or viewers.
689pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
690    let session_id = session_id.to_owned();
691    let workspace_id = workspace_id.to_owned();
692    submit_database_write("reassign_resumable_session_workspace", move |_| {
693        reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
694    })
695}
696
697pub fn reassign_resumable_session_workspace_at(
698    path: &Path,
699    session_id: &str,
700    workspace_id: &str,
701) -> Result<()> {
702    let mut connection = open(path)?;
703    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
704    let (current_workspace, state): (String, String) = tx
705        .query_row(
706            "SELECT c.workspace_id, s.state
707               FROM session_contexts c
708               JOIN sessions s USING(session_id)
709              WHERE c.session_id = ?1",
710            [session_id],
711            |row| Ok((row.get(0)?, row.get(1)?)),
712        )
713        .with_context(|| format!("find resumable session {session_id:?}"))?;
714    ensure!(
715        matches!(
716            parse_session_state(&state),
717            SessionState::Stopped | SessionState::Lost | SessionState::Error
718        ),
719        "session {session_id} is not resumable"
720    );
721    let destination_exists: bool = tx.query_row(
722        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
723        [workspace_id],
724        |row| row.get(0),
725    )?;
726    ensure!(destination_exists, "unknown workspace {workspace_id:?}");
727    if current_workspace != workspace_id {
728        tx.execute(
729            "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
730            params![session_id, workspace_id],
731        )?;
732    }
733    tx.commit()?;
734    Ok(())
735}
736
737pub fn workspace_for_session(session_id: &str) -> Result<Option<String>> {
738    workspace_for_session_at(&database_path(), session_id)
739}
740
741pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
742    open_reader(path)?
743        .query_row(
744            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
745            [session_id],
746            |row| row.get(0),
747        )
748        .optional()
749        .map_err(Into::into)
750}
751
752pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
753    session_ids_for_workspace_at(&database_path(), workspace_id)
754}
755
756/// Return sessions whose current or last workspace id matches `workspace_id`.
757/// Callers deciding live membership must additionally check `SessionState`.
758pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
759    let connection = open_reader(path)?;
760    let mut statement = connection.prepare(
761        "SELECT c.session_id
762           FROM session_contexts c
763           JOIN sessions s USING(session_id)
764          WHERE c.workspace_id = ?1
765          ORDER BY c.created_at, c.session_id",
766    )?;
767    let rows = statement.query_map([workspace_id], |row| row.get(0))?;
768    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
769}
770
771/// Assign a newly-created session context to a workspace. Existing contexts
772/// remain immutable here; only the guarded resume operation may move one.
773pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
774    let session_id = session_id.to_owned();
775    let workspace_id = workspace_id.to_owned();
776    submit_database_write("assign_new_session_workspace", move |_| {
777        assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
778    })
779}
780
781pub fn assign_new_session_workspace_at(
782    path: &Path,
783    session_id: &str,
784    workspace_id: &str,
785) -> Result<()> {
786    let connection = open(path)?;
787    let current: String = connection
788        .query_row(
789            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
790            [session_id],
791            |row| row.get(0),
792        )
793        .with_context(|| format!("find session context {session_id:?}"))?;
794    if current == workspace_id {
795        return Ok(());
796    }
797    ensure!(
798        current == DEFAULT_WORKSPACE_ID,
799        "session {session_id} already belongs to workspace {current}"
800    );
801    let exists: bool = connection.query_row(
802        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
803        [workspace_id],
804        |row| row.get(0),
805    )?;
806    ensure!(exists, "unknown workspace {workspace_id:?}");
807    connection.execute(
808        "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
809        params![session_id, workspace_id],
810    )?;
811    Ok(())
812}
813
814pub fn client_read_frontier(client_id: &str, workspace_id: &str, session_id: &str) -> Result<u64> {
815    client_read_frontier_at(&database_path(), client_id, workspace_id, session_id)
816}
817
818fn client_read_frontier_at(
819    path: &Path,
820    client_id: &str,
821    workspace_id: &str,
822    session_id: &str,
823) -> Result<u64> {
824    let connection = open_reader(path)?;
825    let client: Option<u64> = connection
826        .query_row(
827            "SELECT through_event_ordinal
828               FROM client_read_frontiers
829              WHERE client_id = ?1 AND workspace_id = ?2 AND session_id = ?3",
830            params![client_id, workspace_id, session_id],
831            |row| row.get(0),
832        )
833        .optional()?;
834    if let Some(frontier) = client {
835        return Ok(frontier);
836    }
837    connection
838        .query_row(
839            "SELECT s.viewed_through_event_ordinal
840               FROM sessions s JOIN session_contexts c USING(session_id)
841              WHERE s.session_id = ?1 AND c.workspace_id = ?2",
842            params![session_id, workspace_id],
843            |row| row.get(0),
844        )
845        .with_context(|| format!("find session {session_id:?} in workspace {workspace_id:?}"))
846}
847
848pub fn advance_client_read_frontier(
849    client_id: &str,
850    workspace_id: &str,
851    session_id: &str,
852    through: u64,
853) -> Result<u64> {
854    let client_id = client_id.to_owned();
855    let workspace_id = workspace_id.to_owned();
856    let session_id = session_id.to_owned();
857    submit_database_write("advance_client_read_frontier", move |_| {
858        advance_client_read_frontier_at(
859            &database_path(),
860            &client_id,
861            &workspace_id,
862            &session_id,
863            through,
864        )
865    })
866}
867
868/// What this viewer has stored for this session: an unsent draft and how far
869/// it has read.
870pub fn client_session_state(
871    client_id: &str,
872    workspace_id: &str,
873    session_id: &str,
874) -> Result<ClientSessionState> {
875    let connection = open_reader(&database_path())?;
876    let draft = connection
877        .query_row(
878            "SELECT draft FROM client_session_state
879              WHERE client_id = ?1 AND workspace_id = ?2 AND session_id = ?3",
880            params![client_id, workspace_id, session_id],
881            |row| row.get::<_, String>(0),
882        )
883        .optional()?
884        .unwrap_or_default();
885    let through_event_ordinal = connection
886        .query_row(
887            "SELECT through_event_ordinal FROM client_read_frontiers
888              WHERE client_id = ?1 AND workspace_id = ?2 AND session_id = ?3",
889            params![client_id, workspace_id, session_id],
890            |row| row.get::<_, u64>(0),
891        )
892        .optional()?
893        .unwrap_or_default();
894    Ok(ClientSessionState {
895        draft,
896        through_event_ordinal,
897    })
898}
899
900/// Store one viewer's unsent draft.
901///
902/// An empty draft deletes the row rather than storing emptiness, so a viewer
903/// that cleared its composer stops occupying a row and stops being pruned
904/// later for something it no longer holds.
905pub fn persist_client_draft(
906    client_id: &str,
907    workspace_id: &str,
908    session_id: &str,
909    draft: &str,
910) -> Result<()> {
911    ensure!(!client_id.trim().is_empty(), "client id is empty");
912    let client_id = client_id.to_owned();
913    let workspace_id = workspace_id.to_owned();
914    let session_id = session_id.to_owned();
915    let draft = draft.to_owned();
916    submit_database_write("persist_client_draft", move |connection| {
917        let transaction =
918            connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
919        if draft.is_empty() {
920            transaction.execute(
921                "DELETE FROM client_session_state
922                  WHERE client_id = ?1 AND workspace_id = ?2 AND session_id = ?3",
923                params![client_id, workspace_id, session_id],
924            )?;
925            transaction.commit()?;
926            return Ok(());
927        }
928        let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
929        let changed = transaction.execute(
930            "INSERT INTO client_session_state(
931                 client_id, workspace_id, session_id, draft, updated_at
932             )
933             SELECT ?1, ?2, ?3, ?4, ?5
934              WHERE EXISTS(
935                  SELECT 1 FROM session_contexts
936                   WHERE session_id = ?3 AND workspace_id = ?2
937              )
938             ON CONFLICT(client_id, workspace_id, session_id) DO UPDATE SET
939                 draft = excluded.draft,
940                 updated_at = excluded.updated_at",
941            params![client_id, workspace_id, session_id, draft, now],
942        )?;
943        ensure!(
944            changed == 1,
945            "session {session_id:?} is not in workspace {workspace_id:?}"
946        );
947        transaction.commit()?;
948        Ok(())
949    })
950}
951
952/// Forget web-viewer state that has passed its retention.
953///
954/// Only rows whose client id names a phone are considered. A terminal client's
955/// read frontier is not the phone's to expire, and deleting one would lose a
956/// person's place in a conversation they are still reading.
957pub fn prune_phone_client_state(older_than: Duration) -> Result<usize> {
958    let cutoff = (Utc::now() - chrono::Duration::from_std(older_than).unwrap_or_default())
959        .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
960    submit_database_write("prune_phone_client_state", move |connection| {
961        let transaction =
962            connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
963        let drafts = transaction.execute(
964            "DELETE FROM client_session_state
965              WHERE client_id LIKE 'phone:%' AND updated_at < ?1",
966            params![cutoff],
967        )?;
968        let frontiers = transaction.execute(
969            "DELETE FROM client_read_frontiers
970              WHERE client_id LIKE 'phone:%' AND updated_at < ?1",
971            params![cutoff],
972        )?;
973        transaction.commit()?;
974        Ok(drafts + frontiers)
975    })
976}
977
978pub fn persist_read_receipt(
979    client_id: &str,
980    workspace_id: &str,
981    session_id: &str,
982    through: u64,
983) -> Result<u64> {
984    let client_id = client_id.to_owned();
985    let workspace_id = workspace_id.to_owned();
986    let session_id = session_id.to_owned();
987    submit_database_write("persist_read_receipt", move |connection| {
988        persist_read_receipt_with(connection, &client_id, &workspace_id, &session_id, through)
989    })
990}
991
992fn persist_read_receipt_with(
993    connection: &mut Connection,
994    client_id: &str,
995    workspace_id: &str,
996    session_id: &str,
997    through: u64,
998) -> Result<u64> {
999    ensure!(!client_id.trim().is_empty(), "client id is empty");
1000    let transaction =
1001        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1002    let applied = transaction
1003        .query_row(
1004            "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
1005            [session_id],
1006            |row| row.get::<_, u64>(0),
1007        )
1008        .optional()?
1009        .with_context(|| format!("unknown session {session_id}"))?;
1010    ensure!(
1011        through <= applied,
1012        "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
1013    );
1014    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1015    let changed = transaction.execute(
1016        "INSERT INTO client_read_frontiers(
1017             client_id, workspace_id, session_id, through_event_ordinal, updated_at
1018         )
1019         SELECT ?1, ?2, ?3, ?4, ?5
1020          WHERE EXISTS(
1021              SELECT 1 FROM session_contexts
1022               WHERE session_id = ?3 AND workspace_id = ?2
1023          )
1024         ON CONFLICT(client_id, workspace_id, session_id) DO UPDATE SET
1025             through_event_ordinal = max(
1026                 client_read_frontiers.through_event_ordinal,
1027                 excluded.through_event_ordinal
1028             ),
1029             updated_at = excluded.updated_at",
1030        params![client_id, workspace_id, session_id, through, now],
1031    )?;
1032    ensure!(
1033        changed == 1,
1034        "session {session_id:?} is not in workspace {workspace_id:?}"
1035    );
1036    let changed = transaction.execute(
1037        "UPDATE sessions
1038         SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
1039         WHERE session_id = ?1",
1040        params![session_id, through],
1041    )?;
1042    ensure!(changed == 1, "unknown session {session_id}");
1043    let frontier = transaction.query_row(
1044        "SELECT through_event_ordinal
1045           FROM client_read_frontiers
1046          WHERE client_id = ?1 AND workspace_id = ?2 AND session_id = ?3",
1047        params![client_id, workspace_id, session_id],
1048        |row| row.get(0),
1049    )?;
1050    transaction.commit()?;
1051    Ok(frontier)
1052}
1053
1054fn advance_client_read_frontier_at(
1055    path: &Path,
1056    client_id: &str,
1057    workspace_id: &str,
1058    session_id: &str,
1059    through: u64,
1060) -> Result<u64> {
1061    ensure!(!client_id.trim().is_empty(), "client id is empty");
1062    let connection = open(path)?;
1063    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1064    let changed = connection.execute(
1065        "INSERT INTO client_read_frontiers(
1066             client_id, workspace_id, session_id, through_event_ordinal, updated_at
1067         )
1068         SELECT ?1, ?2, ?3, ?4, ?5
1069          WHERE EXISTS(
1070              SELECT 1 FROM session_contexts
1071               WHERE session_id = ?3 AND workspace_id = ?2
1072          )
1073         ON CONFLICT(client_id, workspace_id, session_id) DO UPDATE SET
1074             through_event_ordinal = max(
1075                 client_read_frontiers.through_event_ordinal,
1076                 excluded.through_event_ordinal
1077             ),
1078             updated_at = excluded.updated_at",
1079        params![client_id, workspace_id, session_id, through, now],
1080    )?;
1081    ensure!(
1082        changed == 1,
1083        "session {session_id:?} is not in workspace {workspace_id:?}"
1084    );
1085    client_read_frontier_at(path, client_id, workspace_id, session_id)
1086}
1087
1088/// Preserve unsent input for explicit recovery and retire its unchanged legacy
1089/// seed together. Empty input still retires the seed: clearing is an edit.
1090pub fn save_detached_session_draft(
1091    workspace_id: &str,
1092    session_id: &str,
1093    source: &str,
1094    owner_pid: u32,
1095    draft: DetachedSessionDraft,
1096) -> Result<Option<String>> {
1097    let workspace_id = workspace_id.to_owned();
1098    let session_id = session_id.to_owned();
1099    let source = source.to_owned();
1100    submit_database_write("save_detached_session_draft", move |connection| {
1101        save_detached_session_draft_in(
1102            connection,
1103            &workspace_id,
1104            &session_id,
1105            &source,
1106            owner_pid,
1107            &draft,
1108        )
1109    })
1110}
1111
1112fn save_detached_session_draft_in(
1113    connection: &mut Connection,
1114    workspace_id: &str,
1115    session_id: &str,
1116    source: &str,
1117    owner_pid: u32,
1118    draft: &DetachedSessionDraft,
1119) -> Result<Option<String>> {
1120    let transaction =
1121        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1122    if let Some(inherited) = &draft.inherited_input {
1123        transaction.execute(
1124            "UPDATE sessions SET draft_input = ''
1125              WHERE session_id = ?1 AND draft_input = ?2
1126                AND EXISTS (SELECT 1 FROM session_contexts
1127                             WHERE session_id = ?1 AND workspace_id = ?3)",
1128            params![session_id, inherited, workspace_id],
1129        )?;
1130    }
1131    let id = insert_detached_draft(
1132        &transaction,
1133        workspace_id,
1134        Some(session_id),
1135        source,
1136        Some(owner_pid),
1137        &draft.text,
1138    )?;
1139    transaction.commit()?;
1140    Ok(id)
1141}
1142
1143pub fn save_detached_draft(
1144    workspace_id: &str,
1145    session_id: Option<&str>,
1146    source: &str,
1147    owner_pid: Option<u32>,
1148    text: &str,
1149) -> Result<Option<String>> {
1150    let workspace_id = workspace_id.to_owned();
1151    let session_id = session_id.map(str::to_owned);
1152    let source = source.to_owned();
1153    let text = text.to_owned();
1154    submit_database_write("save_detached_draft", move |_| {
1155        save_detached_draft_at(
1156            &database_path(),
1157            &workspace_id,
1158            session_id.as_deref(),
1159            &source,
1160            owner_pid,
1161            &text,
1162        )
1163    })
1164}
1165
1166fn save_detached_draft_at(
1167    path: &Path,
1168    workspace_id: &str,
1169    session_id: Option<&str>,
1170    source: &str,
1171    owner_pid: Option<u32>,
1172    text: &str,
1173) -> Result<Option<String>> {
1174    if text.is_empty() {
1175        return Ok(None);
1176    }
1177    let connection = open(path)?;
1178    insert_detached_draft(
1179        &connection,
1180        workspace_id,
1181        session_id,
1182        source,
1183        owner_pid,
1184        text,
1185    )
1186}
1187
1188fn insert_detached_draft(
1189    connection: &Connection,
1190    workspace_id: &str,
1191    session_id: Option<&str>,
1192    source: &str,
1193    owner_pid: Option<u32>,
1194    text: &str,
1195) -> Result<Option<String>> {
1196    if text.is_empty() {
1197        return Ok(None);
1198    }
1199    ensure!(!source.trim().is_empty(), "draft source is empty");
1200    let id = new_workspace_id()?;
1201    let saved_at = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1202    connection.execute(
1203        "INSERT INTO detached_drafts(
1204             draft_id, workspace_id, session_id, source, owner_pid, saved_at, text
1205         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1206        params![
1207            id,
1208            workspace_id,
1209            session_id,
1210            source,
1211            owner_pid,
1212            saved_at,
1213            text
1214        ],
1215    )?;
1216    Ok(Some(id))
1217}
1218
1219pub fn list_detached_drafts(workspace_id: &str) -> Result<Vec<DetachedDraft>> {
1220    list_detached_drafts_at(&database_path(), workspace_id)
1221}
1222
1223fn list_detached_drafts_at(path: &Path, workspace_id: &str) -> Result<Vec<DetachedDraft>> {
1224    let connection = open_reader(path)?;
1225    let mut statement = connection.prepare(
1226        "SELECT draft_id, workspace_id, session_id, source, owner_pid, saved_at, text,
1227                recovered_at
1228           FROM detached_drafts
1229          WHERE workspace_id = ?1 AND recovered_at IS NULL
1230          ORDER BY saved_at DESC, draft_id DESC",
1231    )?;
1232    let rows = statement.query_map([workspace_id], |row| {
1233        Ok(DetachedDraft {
1234            id: row.get(0)?,
1235            workspace_id: row.get(1)?,
1236            session_id: row.get(2)?,
1237            source: row.get(3)?,
1238            owner_pid: row.get(4)?,
1239            saved_at: row.get(5)?,
1240            text: row.get(6)?,
1241            recovered_at: row.get(7)?,
1242        })
1243    })?;
1244    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
1245}
1246
1247pub fn mark_draft_recovered(draft_id: &str) -> Result<()> {
1248    let draft_id = draft_id.to_owned();
1249    submit_database_write("mark_draft_recovered", move |_| {
1250        mark_draft_recovered_at(&database_path(), &draft_id)
1251    })
1252}
1253
1254fn mark_draft_recovered_at(path: &Path, draft_id: &str) -> Result<()> {
1255    let connection = open(path)?;
1256    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1257    let changed = connection.execute(
1258        "UPDATE detached_drafts SET recovered_at = ?2
1259          WHERE draft_id = ?1 AND recovered_at IS NULL",
1260        params![draft_id, now],
1261    )?;
1262    ensure!(
1263        changed == 1,
1264        "unknown or already recovered draft {draft_id:?}"
1265    );
1266    Ok(())
1267}
1268
1269/// Explicitly restore a detached draft into its session composer. This is the
1270/// only operation that merges client-local draft state back into the legacy
1271/// session field, and the transaction marks the source draft recovered at the
1272/// same durable boundary.
1273pub fn recover_detached_draft(draft_id: &str) -> Result<String> {
1274    let draft_id = draft_id.to_owned();
1275    submit_database_write("recover_detached_draft", move |_| {
1276        recover_detached_draft_at(&database_path(), &draft_id)
1277    })
1278}
1279
1280fn recover_detached_draft_at(path: &Path, draft_id: &str) -> Result<String> {
1281    let mut connection = open(path)?;
1282    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1283    let (session_id, text): (Option<String>, String) = tx
1284        .query_row(
1285            "SELECT session_id, text FROM detached_drafts
1286              WHERE draft_id = ?1 AND recovered_at IS NULL",
1287            [draft_id],
1288            |row| Ok((row.get(0)?, row.get(1)?)),
1289        )
1290        .with_context(|| format!("find recoverable draft {draft_id:?}"))?;
1291    let session_id = session_id.context("draft is not associated with a session")?;
1292    let changed = tx.execute(
1293        "UPDATE sessions SET draft_input = ?2 WHERE session_id = ?1",
1294        params![session_id, text],
1295    )?;
1296    ensure!(changed == 1, "draft session no longer exists");
1297    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1298    tx.execute(
1299        "UPDATE detached_drafts SET recovered_at = ?2 WHERE draft_id = ?1",
1300        params![draft_id, now],
1301    )?;
1302    tx.commit()?;
1303    Ok(session_id)
1304}
1305
1306pub fn load_state_from(path: &Path) -> Result<State> {
1307    let connection = open_reader(path)?;
1308    let mut state = State::default();
1309    let mut statement = connection.prepare(
1310        "SELECT s.session_id, s.title, s.harness_kind, s.last_profile, c.bundle_id,
1311                s.target_template_id, s.state, s.native_session_id, s.acp_session_title,
1312                s.session_title_override, c.created_at, s.updated_at,
1313                s.viewed_through_event_ordinal, s.last_error, s.resource_allocation,
1314                s.last_checkpoint_error, s.project_directory, s.managed_worktree,
1315                s.draft_input, s.container_cpus, s.container_memory, s.archived
1316                , c.workspace_id, s.create_managed_worktree, s.mjolnir_subagents
1317         FROM sessions s JOIN session_contexts c USING(session_id)
1318         ORDER BY s.session_id",
1319    )?;
1320    let rows = statement.query_map([], |row| {
1321        // A harness Mjolnir no longer supports can still own rows an earlier
1322        // release wrote. Skip such a session with a warning rather than
1323        // failing the whole listing and hiding every other session with it.
1324        let harness_text: String = row.get(2)?;
1325        let Ok(harness_kind) = harness_text.parse() else {
1326            let session_id: String = row.get(0)?;
1327            tracing::warn!(
1328                session_id,
1329                harness = %harness_text,
1330                "session harness is no longer supported; the session is not listed"
1331            );
1332            return Ok(None);
1333        };
1334        Ok(Some(SessionRecord {
1335            harness_kind,
1336            create_managed_worktree: row.get(23)?,
1337            mjolnir_subagents: row.get(24)?,
1338            workspace_id: row.get(22)?,
1339            archived: row.get(21)?,
1340            container_cpus: row.get(19)?,
1341            container_memory: row.get(20)?,
1342            id: row.get(0)?,
1343            title: row.get(1)?,
1344            last_profile: row.get(3)?,
1345            bundle_id: row.get(4)?,
1346            project_directory: row.get_ref(16)?.blob_or_null()?.map(blob_to_path),
1347            managed_worktree: row
1348                .get::<_, Option<String>>(17)?
1349                .map(|json| serde_json::from_str::<ManagedWorktree>(&json))
1350                .transpose()
1351                .map_err(|error| {
1352                    rusqlite::Error::FromSqlConversionFailure(
1353                        17,
1354                        rusqlite::types::Type::Text,
1355                        Box::new(error),
1356                    )
1357                })?,
1358            target_template_id: row.get(5)?,
1359            resource_allocation: row
1360                .get::<_, Option<String>>(14)?
1361                .map(|json| serde_json::from_str::<SessionResourceAllocation>(&json))
1362                .transpose()
1363                .map_err(|error| {
1364                    rusqlite::Error::FromSqlConversionFailure(
1365                        14,
1366                        rusqlite::types::Type::Text,
1367                        Box::new(error),
1368                    )
1369                })?,
1370            additional_mounts: Vec::new(),
1371            state: parse_session_state(&row.get::<_, String>(6)?),
1372            target: None,
1373            native_session_id: row.get(7)?,
1374            acp_session_title: row
1375                .get::<_, Option<String>>(8)?
1376                .as_deref()
1377                .and_then(mj_core::state::normalize_session_title),
1378            session_title_override: row.get(9)?,
1379            created_at: row.get(10)?,
1380            updated_at: row.get(11)?,
1381            viewed_through_event_ordinal: row.get::<_, u64>(12)?,
1382            draft_input: row.get(18)?,
1383            last_error: row.get(13)?,
1384            last_checkpoint_error: row.get(15)?,
1385            checkpoint: None,
1386        }))
1387    })?;
1388    for row in rows {
1389        if let Some(session) = row? {
1390            state.sessions.insert(session.id.clone(), session);
1391        }
1392    }
1393    let mut statement = connection.prepare(
1394        "SELECT child_session_id, record_json FROM subagent_sessions ORDER BY child_session_id",
1395    )?;
1396    let rows = statement.query_map([], |row| {
1397        let child_id = row.get::<_, String>(0)?;
1398        let json = row.get::<_, String>(1)?;
1399        let record = serde_json::from_str::<SubagentRecord>(&json).map_err(|error| {
1400            rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
1401        })?;
1402        Ok((child_id, record))
1403    })?;
1404    for row in rows {
1405        let (child_id, record) = row?;
1406        state.subagents.insert(child_id, record);
1407    }
1408    load_targets(&connection, &mut state)?;
1409    load_mounts(&connection, &mut state)?;
1410    load_checkpoints(&connection, &mut state)?;
1411    let mut statement =
1412        connection.prepare("SELECT host, source FROM mount_history ORDER BY host, ordinal")?;
1413    let rows = statement.query_map([], |row| {
1414        Ok((
1415            row.get::<_, String>(0)?,
1416            blob_to_path(row.get_ref(1)?.as_blob()?),
1417        ))
1418    })?;
1419    for row in rows {
1420        let (host, source) = row?;
1421        state.mount_history.entry(host).or_default().push(source);
1422    }
1423    let mut statement = connection
1424        .prepare("SELECT host, cpus, memory_bytes FROM host_container_sizes ORDER BY host")?;
1425    let rows = statement.query_map([], |row| {
1426        Ok((
1427            row.get::<_, String>(0)?,
1428            HostContainerSize {
1429                cpus: row.get::<_, i64>(1)? as u64,
1430                memory_bytes: row.get::<_, i64>(2)? as u64,
1431            },
1432        ))
1433    })?;
1434    for row in rows {
1435        let (host, size) = row?;
1436        state.container_sizes.insert(host, size);
1437    }
1438    state.validate()?;
1439    Ok(state)
1440}
1441
1442pub fn save_state(state: &State) -> Result<()> {
1443    let state = state.clone();
1444    submit_database_write("save_state", move |_| {
1445        save_state_to(&database_path(), &state)
1446    })
1447}
1448
1449/// Persist one operational session without rewriting unrelated controller
1450/// state. Dashboard lifecycle jobs use this path so independent jobs can
1451/// commit concurrently without restoring stale copies of other sessions.
1452pub fn save_session(session: &SessionRecord) -> Result<()> {
1453    let session = session.clone();
1454    submit_database_write("save_session", move |_| {
1455        save_session_to(&database_path(), &session)
1456    })
1457}
1458
1459/// Persist a borrowed-target child and its parent relationship atomically.
1460pub fn save_subagent_session(
1461    session: &SessionRecord,
1462    subagent: &mj_core::subagent::SubagentRecord,
1463) -> Result<()> {
1464    let session = session.clone();
1465    let subagent = subagent.clone();
1466    submit_database_write("save_subagent_session", move |_| {
1467        let mut connection = open(&database_path())?;
1468        let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1469        insert_session(&tx, &session)?;
1470        tx.execute(
1471            "INSERT INTO subagent_sessions(
1472                 child_session_id, parent_session_id, request_key, record_json
1473             ) VALUES (?1, ?2, ?3, ?4)",
1474            params![
1475                subagent.child_session_id,
1476                subagent.parent_session_id,
1477                subagent.request_key,
1478                serde_json::to_string(&subagent)?,
1479            ],
1480        )?;
1481        tx.commit()?;
1482        Ok(())
1483    })
1484}
1485
1486/// Record the child turn whose completion notice the parent already has.
1487pub fn mark_subagent_turn_noticed(child_session_id: &str, turn: u64) -> Result<()> {
1488    let child_session_id = child_session_id.to_owned();
1489    submit_database_write("mark_subagent_turn_noticed", move |_| {
1490        let mut relation = load_subagent(&child_session_id)?
1491            .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
1492        relation.noticed_turn = Some(turn);
1493        let json = serde_json::to_string(&relation)?;
1494        let connection = open(&database_path())?;
1495        connection.execute(
1496            "UPDATE subagent_sessions SET record_json = ?2 WHERE child_session_id = ?1",
1497            params![child_session_id, json],
1498        )?;
1499        Ok(())
1500    })
1501}
1502
1503pub fn load_subagent(child_session_id: &str) -> Result<Option<mj_core::subagent::SubagentRecord>> {
1504    let connection = open_reader(&database_path())?;
1505    connection
1506        .query_row(
1507            "SELECT record_json FROM subagent_sessions WHERE child_session_id = ?1",
1508            [child_session_id],
1509            |row| row.get::<_, String>(0),
1510        )
1511        .optional()?
1512        .map(|json| serde_json::from_str(&json).context("decode sub-agent record"))
1513        .transpose()
1514}
1515
1516pub fn list_subagents(parent_session_id: &str) -> Result<Vec<mj_core::subagent::SubagentRecord>> {
1517    let connection = open_reader(&database_path())?;
1518    let mut statement = connection.prepare(
1519        "SELECT record_json FROM subagent_sessions
1520         WHERE parent_session_id = ?1 ORDER BY rowid",
1521    )?;
1522    statement
1523        .query_map([parent_session_id], |row| row.get::<_, String>(0))?
1524        .map(|row| serde_json::from_str(&row?).context("decode sub-agent record"))
1525        .collect()
1526}
1527
1528pub fn lookup_subagent_request(
1529    parent_session_id: &str,
1530    request_key: &str,
1531) -> Result<Option<mj_core::subagent::SubagentRecord>> {
1532    let connection = open_reader(&database_path())?;
1533    connection
1534        .query_row(
1535            "SELECT record_json FROM subagent_sessions
1536             WHERE parent_session_id = ?1 AND request_key = ?2",
1537            params![parent_session_id, request_key],
1538            |row| row.get::<_, String>(0),
1539        )
1540        .optional()?
1541        .map(|json| serde_json::from_str(&json).context("decode sub-agent record"))
1542        .transpose()
1543}
1544
1545/// Persist a session and the container size it most recently launched on its
1546/// host in one transaction.
1547pub fn save_session_with_container_size(
1548    session: &SessionRecord,
1549    host: &str,
1550    size: HostContainerSize,
1551) -> Result<()> {
1552    let session = session.clone();
1553    let host = host.to_owned();
1554    submit_database_write("save_session_with_container_size", move |_| {
1555        save_session_with_container_size_to(&database_path(), &session, Some((&host, size)))
1556    })
1557}
1558
1559/// Update only the fields a lifecycle transition owns on a session that
1560/// already exists. Everything else — display titles, checkpoints, container
1561/// settings, and attached directories — stays with its own writer.
1562pub fn save_lifecycle_session(session: &SessionRecord) -> Result<()> {
1563    let session = session.clone();
1564    submit_database_write("save_lifecycle_session", move |_| {
1565        save_lifecycle_session_to(&database_path(), &session)
1566    })
1567}
1568
1569/// Install a lifecycle transition together with the checkpoint it just
1570/// verified and the harness session id that produced it.
1571pub fn save_checkpointed_session(session: &SessionRecord) -> Result<()> {
1572    let session = session.clone();
1573    submit_database_write("save_checkpointed_session", move |_| {
1574        save_checkpointed_session_to(&database_path(), &session)
1575    })
1576}
1577
1578/// Recover lifecycle rows stranded by a process exit during checkpoint
1579/// creation. This must be called once by the top-level controller process
1580/// while it owns the controller-store guard, not by per-operation reloads.
1581pub fn recover_interrupted_checkpointing_sessions(updated_at: &str) -> Result<usize> {
1582    let updated_at = updated_at.to_owned();
1583    submit_database_write("recover_interrupted_checkpointing_sessions", move |_| {
1584        recover_interrupted_checkpointing_sessions_to(&database_path(), &updated_at)
1585    })
1586}
1587
1588/// Change only the user-owned display name. This avoids writing a stale
1589/// SessionRecord over independently committed checkpoint or relay metadata.
1590pub fn set_session_title_override(session_id: &str, title: &str, updated_at: &str) -> Result<()> {
1591    let session_id = session_id.to_owned();
1592    let title = title.to_owned();
1593    let updated_at = updated_at.to_owned();
1594    submit_database_write("set_session_title_override", move |_| {
1595        set_session_title_override_to(&database_path(), &session_id, &title, &updated_at)
1596    })
1597}
1598
1599/// Rewrite a configured profile id in every persisted session in one SQLite
1600/// transaction. Configuration is stored separately, so the controller owns
1601/// coordinating this update with the matching config-map rename.
1602pub fn rename_profile_references(old_id: &str, new_id: &str) -> Result<usize> {
1603    rename_session_reference("last_profile", old_id, new_id)
1604}
1605
1606/// Rewrite a configured target id in every persisted session in one SQLite
1607/// transaction.
1608pub fn rename_target_references(old_id: &str, new_id: &str) -> Result<usize> {
1609    rename_session_reference("target_template_id", old_id, new_id)
1610}
1611
1612fn rename_session_reference(column: &'static str, old_id: &str, new_id: &str) -> Result<usize> {
1613    ensure!(
1614        matches!(column, "last_profile" | "target_template_id"),
1615        "unsupported session reference column"
1616    );
1617    let old_id = old_id.to_owned();
1618    let new_id = new_id.to_owned();
1619    submit_database_write("rename_session_reference", move |_| {
1620        rename_session_reference_at(&database_path(), column, &old_id, &new_id)
1621    })
1622}
1623
1624fn rename_session_reference_at(
1625    path: &Path,
1626    column: &str,
1627    old_id: &str,
1628    new_id: &str,
1629) -> Result<usize> {
1630    let mut connection = open(path)?;
1631    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1632    let changed = tx.execute(
1633        &format!("UPDATE sessions SET {column} = ?2 WHERE {column} = ?1"),
1634        params![old_id, new_id],
1635    )?;
1636    tx.commit()?;
1637    Ok(changed)
1638}
1639
1640/// Change only whether the resume dialog hides this session. Archiving is a
1641/// display choice, so it has its own writer and never rewrites lifecycle,
1642/// checkpoint, or title columns another task owns.
1643pub fn set_session_archived(session_id: &str, archived: bool) -> Result<()> {
1644    let session_id = session_id.to_owned();
1645    submit_database_write("set_session_archived", move |_| {
1646        set_session_archived_to(&database_path(), &session_id, archived)
1647    })
1648}
1649
1650/// Record that the managed target of an otherwise live session is definitively
1651/// gone. A verified checkpoint keeps the session recoverable as an error on the
1652/// dashboard; without one, the session is lost. The state predicate keeps a
1653/// late poll result from overwriting a concurrent lifecycle transition.
1654pub fn mark_session_target_missing(
1655    session_id: &str,
1656    detail: &str,
1657    updated_at: &str,
1658) -> Result<Option<SessionState>> {
1659    let session_id = session_id.to_owned();
1660    let detail = detail.to_owned();
1661    let updated_at = updated_at.to_owned();
1662    submit_database_write("mark_session_target_missing", move |_| {
1663        mark_session_target_missing_to(&database_path(), &session_id, &detail, &updated_at)
1664    })
1665}
1666
1667fn mark_session_target_missing_to(
1668    path: &Path,
1669    session_id: &str,
1670    detail: &str,
1671    updated_at: &str,
1672) -> Result<Option<SessionState>> {
1673    mark_session_target_missing_if_current_to(path, session_id, detail, updated_at, None)
1674}
1675
1676/// Record a definitive worker failure only while the observed session record
1677/// is still current. A delayed background write must not invalidate a resume.
1678pub fn mark_session_target_missing_if_current(
1679    session_id: &str,
1680    detail: &str,
1681    updated_at: &str,
1682    observed_updated_at: &str,
1683) -> Result<Option<SessionState>> {
1684    let session_id = session_id.to_owned();
1685    let detail = detail.to_owned();
1686    let updated_at = updated_at.to_owned();
1687    let observed_updated_at = observed_updated_at.to_owned();
1688    submit_database_write("mark_session_target_missing_if_current", move |_| {
1689        mark_session_target_missing_if_current_to(
1690            &database_path(),
1691            &session_id,
1692            &detail,
1693            &updated_at,
1694            Some(&observed_updated_at),
1695        )
1696    })
1697}
1698
1699fn mark_session_target_missing_if_current_to(
1700    path: &Path,
1701    session_id: &str,
1702    detail: &str,
1703    updated_at: &str,
1704    observed_updated_at: Option<&str>,
1705) -> Result<Option<SessionState>> {
1706    let mut connection = open(path)?;
1707    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1708    let changed = tx.execute(
1709        "UPDATE sessions
1710         SET state = CASE
1711                 WHEN EXISTS(
1712                     SELECT 1 FROM session_checkpoints
1713                     WHERE session_checkpoints.session_id = sessions.session_id
1714                 ) THEN 'error'
1715                 ELSE 'lost'
1716             END,
1717             last_error = ?2,
1718             updated_at = ?3
1719         WHERE session_id = ?1
1720           AND (?4 IS NULL OR updated_at = ?4)
1721           AND state IN ('provisioning', 'running', 'disconnected', 'error')",
1722        params![session_id, detail, updated_at, observed_updated_at],
1723    )?;
1724    ensure!(changed <= 1, "updated {changed} sessions for {session_id}");
1725    let state = if changed == 1 {
1726        let stored: String = tx.query_row(
1727            "SELECT state FROM sessions WHERE session_id = ?1",
1728            [session_id],
1729            |row| row.get(0),
1730        )?;
1731        Some(parse_session_state(&stored))
1732    } else {
1733        None
1734    };
1735    tx.commit()?;
1736    Ok(state)
1737}
1738
1739fn set_session_archived_to(path: &Path, session_id: &str, archived: bool) -> Result<()> {
1740    let connection = open(path)?;
1741    let changed = connection.execute(
1742        "UPDATE sessions SET archived = ?2 WHERE session_id = ?1",
1743        params![session_id, archived],
1744    )?;
1745    if changed != 1 {
1746        bail!("unknown session {session_id}");
1747    }
1748    Ok(())
1749}
1750
1751/// Native sessions the resume dialog hides. Hel never writes into a harness
1752/// home, so the hidden set lives here instead of in the harness's own store.
1753pub fn hidden_native_sessions() -> Result<BTreeSet<(mj_core::config::HarnessKind, String)>> {
1754    hidden_native_sessions_from(&database_path())
1755}
1756
1757fn hidden_native_sessions_from(
1758    path: &Path,
1759) -> Result<BTreeSet<(mj_core::config::HarnessKind, String)>> {
1760    let connection = open_reader(path)?;
1761    let mut statement =
1762        connection.prepare("SELECT harness_kind, native_session_id FROM hidden_native_sessions")?;
1763    let rows = statement.query_map([], |row| {
1764        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1765    })?;
1766    let mut hidden = BTreeSet::new();
1767    for row in rows {
1768        let (harness, native_session_id) = row?;
1769        // Rows for a harness this release no longer supports are ignored, not
1770        // fatal; they simply hide nothing.
1771        match harness.parse::<mj_core::config::HarnessKind>() {
1772            Ok(harness) => {
1773                hidden.insert((harness, native_session_id));
1774            }
1775            Err(_) => tracing::warn!(
1776                harness = %harness,
1777                "ignoring a hidden native session for a harness that is no longer supported"
1778            ),
1779        }
1780    }
1781    Ok(hidden)
1782}
1783
1784/// Hide or reveal one native session in the resume dialog.
1785pub fn set_native_session_hidden(
1786    harness: mj_core::config::HarnessKind,
1787    native_session_id: &str,
1788    hidden: bool,
1789) -> Result<()> {
1790    let native_session_id = native_session_id.to_owned();
1791    submit_database_write("set_native_session_hidden", move |_| {
1792        set_native_session_hidden_to(&database_path(), harness, &native_session_id, hidden)
1793    })
1794}
1795
1796fn set_native_session_hidden_to(
1797    path: &Path,
1798    harness: mj_core::config::HarnessKind,
1799    native_session_id: &str,
1800    hidden: bool,
1801) -> Result<()> {
1802    if native_session_id.trim().is_empty() {
1803        bail!("native session id is empty");
1804    }
1805    let connection = open(path)?;
1806    if hidden {
1807        connection.execute(
1808            "INSERT INTO hidden_native_sessions(harness_kind, native_session_id, hidden_at)
1809             VALUES (?1, ?2, ?3)
1810             ON CONFLICT(harness_kind, native_session_id) DO NOTHING",
1811            params![harness.id(), native_session_id, Utc::now().to_rfc3339()],
1812        )?;
1813    } else {
1814        connection.execute(
1815            "DELETE FROM hidden_native_sessions
1816             WHERE harness_kind = ?1 AND native_session_id = ?2",
1817            params![harness.id(), native_session_id],
1818        )?;
1819    }
1820    Ok(())
1821}
1822
1823/// Change only the per-session container provisioning inputs: the size
1824/// overrides and the attached directories. Everything else the session row
1825/// owns is left to its own writer.
1826pub fn set_session_container_settings(
1827    session_id: &str,
1828    cpus: Option<&str>,
1829    memory: Option<&str>,
1830    mounts: &[AdditionalMount],
1831    updated_at: &str,
1832) -> Result<()> {
1833    let session_id = session_id.to_owned();
1834    let cpus = cpus.map(str::to_owned);
1835    let memory = memory.map(str::to_owned);
1836    let mounts = mounts.to_vec();
1837    let updated_at = updated_at.to_owned();
1838    submit_database_write("set_session_container_settings", move |_| {
1839        set_session_container_settings_to(
1840            &database_path(),
1841            &session_id,
1842            cpus.as_deref(),
1843            memory.as_deref(),
1844            &mounts,
1845            &updated_at,
1846        )
1847    })
1848}
1849
1850fn set_session_container_settings_to(
1851    path: &Path,
1852    session_id: &str,
1853    cpus: Option<&str>,
1854    memory: Option<&str>,
1855    mounts: &[AdditionalMount],
1856    updated_at: &str,
1857) -> Result<()> {
1858    if updated_at.trim().is_empty() {
1859        bail!("session update timestamp is empty");
1860    }
1861    crate::targets::validate_additional_mounts(mounts)?;
1862    let mut connection = open(path)?;
1863    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1864    let changed = tx.execute(
1865        "UPDATE sessions
1866         SET container_cpus = ?2, container_memory = ?3, updated_at = ?4
1867         WHERE session_id = ?1",
1868        params![session_id, cpus, memory, updated_at],
1869    )?;
1870    if changed != 1 {
1871        bail!("unknown session {session_id}");
1872    }
1873    tx.execute(
1874        "DELETE FROM session_mounts WHERE session_id = ?1",
1875        [session_id],
1876    )?;
1877    for (ordinal, mount) in mounts.iter().enumerate() {
1878        tx.execute(
1879            "INSERT INTO session_mounts(session_id, ordinal, source, destination, read_only)
1880             VALUES (?1, ?2, ?3, ?4, ?5)",
1881            params![
1882                session_id,
1883                ordinal as i64,
1884                path_to_blob(&mount.source),
1885                path_to_blob(&mount.destination),
1886                mount.read_only
1887            ],
1888        )?;
1889    }
1890    tx.commit()?;
1891    Ok(())
1892}
1893
1894fn set_session_title_override_to(
1895    path: &Path,
1896    session_id: &str,
1897    title: &str,
1898    updated_at: &str,
1899) -> Result<()> {
1900    if title.trim().is_empty() {
1901        bail!("session title is empty");
1902    }
1903    if updated_at.trim().is_empty() {
1904        bail!("session update timestamp is empty");
1905    }
1906    let connection = open(path)?;
1907    let changed = connection.execute(
1908        "UPDATE sessions
1909         SET session_title_override = ?2, updated_at = ?3
1910         WHERE session_id = ?1",
1911        params![session_id, title, updated_at],
1912    )?;
1913    if changed != 1 {
1914        bail!("unknown session {session_id}");
1915    }
1916    Ok(())
1917}
1918
1919/// Persist the latest ACP-provided title without replacing unrelated session
1920/// fields that may have changed in another supervised controller task.
1921pub fn set_session_acp_title(session_id: &str, title: Option<&str>) -> Result<()> {
1922    let session_id = session_id.to_owned();
1923    let title = title.map(str::to_owned);
1924    submit_database_write("set_session_acp_title", move |_| {
1925        set_session_acp_title_to(&database_path(), &session_id, title.as_deref())
1926    })
1927}
1928
1929fn set_session_acp_title_to(path: &Path, session_id: &str, title: Option<&str>) -> Result<()> {
1930    if title.is_some_and(|title| title.trim().is_empty()) {
1931        bail!("ACP session title is empty");
1932    }
1933    let title = title.and_then(mj_core::state::normalize_session_title);
1934    let connection = open(path)?;
1935    let changed = connection.execute(
1936        "UPDATE sessions SET acp_session_title = ?2 WHERE session_id = ?1",
1937        params![session_id, title],
1938    )?;
1939    if changed != 1 {
1940        bail!("unknown session {session_id}");
1941    }
1942    Ok(())
1943}
1944
1945/// Commit the successful handshake for a newly provisioned worker without
1946/// replacing checkpoint or display metadata owned by other controller tasks.
1947pub fn mark_session_worker_connected(
1948    session_id: &str,
1949    native_session_id: Option<&str>,
1950    updated_at: &str,
1951) -> Result<()> {
1952    let session_id = session_id.to_owned();
1953    let native_session_id = native_session_id.map(str::to_owned);
1954    let updated_at = updated_at.to_owned();
1955    submit_database_write("mark_session_worker_connected", move |_| {
1956        mark_session_worker_connected_to(
1957            &database_path(),
1958            &session_id,
1959            native_session_id.as_deref(),
1960            &updated_at,
1961        )
1962    })
1963}
1964
1965/// Point a session at a native session its worker opened on its own. Only that
1966/// column moves: the session's lifecycle state belongs to whatever operation is
1967/// running.
1968pub fn adopt_native_session_id(session_id: &str, native_session_id: &str) -> Result<()> {
1969    let session_id = session_id.to_owned();
1970    let native_session_id = native_session_id.to_owned();
1971    submit_database_write("adopt_native_session_id", move |_| {
1972        adopt_native_session_id_to(&database_path(), &session_id, &native_session_id)
1973    })
1974}
1975
1976fn adopt_native_session_id_to(
1977    path: &Path,
1978    session_id: &str,
1979    native_session_id: &str,
1980) -> Result<()> {
1981    let connection = open(path)?;
1982    let changed = connection.execute(
1983        "UPDATE sessions SET native_session_id = ?2 WHERE session_id = ?1",
1984        params![session_id, native_session_id],
1985    )?;
1986    if changed != 1 {
1987        bail!("unknown session {session_id}");
1988    }
1989    Ok(())
1990}
1991
1992fn mark_session_worker_connected_to(
1993    path: &Path,
1994    session_id: &str,
1995    native_session_id: Option<&str>,
1996    updated_at: &str,
1997) -> Result<()> {
1998    if updated_at.trim().is_empty() {
1999        bail!("worker connection timestamp is empty");
2000    }
2001    let connection = open(path)?;
2002    let changed = connection.execute(
2003        "UPDATE sessions
2004         SET state = 'running',
2005             native_session_id = coalesce(?2, native_session_id),
2006             updated_at = ?3,
2007             last_error = NULL
2008         WHERE session_id = ?1",
2009        params![session_id, native_session_id, updated_at],
2010    )?;
2011    if changed != 1 {
2012        bail!("unknown session {session_id}");
2013    }
2014    Ok(())
2015}
2016
2017fn recover_interrupted_checkpointing_sessions_to(path: &Path, updated_at: &str) -> Result<usize> {
2018    if updated_at.trim().is_empty() {
2019        bail!("checkpoint recovery timestamp is empty");
2020    }
2021    let connection = open(path)?;
2022    connection
2023        .execute(
2024            "UPDATE sessions
2025             SET state = 'running', updated_at = ?1, last_checkpoint_error = ?2
2026             WHERE state = 'checkpointing'",
2027            params![
2028                updated_at,
2029                "checkpointing was interrupted by a controller restart; the target was left running"
2030            ],
2031        )
2032        .context("recover interrupted checkpointing sessions")
2033}
2034
2035fn save_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
2036    save_session_with_container_size_to(path, session, None)
2037}
2038
2039fn save_session_with_container_size_to(
2040    path: &Path,
2041    session: &SessionRecord,
2042    container_size: Option<(&str, HostContainerSize)>,
2043) -> Result<()> {
2044    validate_session_record(session)?;
2045
2046    let mut connection = open(path)?;
2047    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2048    if let Some(existing_bundle) = tx
2049        .query_row(
2050            "SELECT bundle_id FROM session_contexts WHERE session_id = ?1",
2051            [session.id.as_str()],
2052            |row| row.get::<_, String>(0),
2053        )
2054        .optional()?
2055        && existing_bundle != session.bundle_id
2056    {
2057        bail!(
2058            "session {} was already associated with bundle {}, not {}",
2059            session.id,
2060            existing_bundle,
2061            session.bundle_id
2062        );
2063    }
2064    let mut session = session.clone();
2065    let moving: bool = tx.query_row(
2066        "SELECT EXISTS(SELECT 1 FROM session_moves WHERE session_id=?1
2067         AND json_extract(operation_json, '$.phase') IN ('preparing','closing_source','resuming_destination','starting_queue'))",
2068        [&session.id], |row| row.get(0),
2069    )?;
2070    if moving {
2071        // A Move may provision for minutes while clients keep editing drafts
2072        // and titles. Merge these independently owned fields in this same
2073        // transaction rather than restoring the lifecycle's earlier copy.
2074        let (draft, title, acp_title, viewed, archived) = tx.query_row(
2075            "SELECT draft_input, session_title_override, acp_session_title, viewed_through_event_ordinal, archived
2076             FROM sessions WHERE session_id=?1", [&session.id], |row| Ok((
2077                row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<String>>(2)?,
2078                row.get::<_, u64>(3)?, row.get::<_, bool>(4)?,
2079            )),
2080        )?;
2081        session.draft_input = draft;
2082        session.session_title_override = title;
2083        session.acp_session_title = acp_title;
2084        session.viewed_through_event_ordinal = viewed;
2085        session.archived = archived;
2086    }
2087    insert_session(&tx, &session)?;
2088    if let Some((host, size)) = container_size {
2089        write_host_container_size(&tx, host, size)?;
2090    }
2091    tx.commit()?;
2092    Ok(())
2093}
2094
2095fn save_lifecycle_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
2096    validate_session_record(session)?;
2097
2098    let mut connection = open(path)?;
2099    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2100    update_lifecycle_fields(&tx, session)?;
2101    tx.commit()?;
2102    Ok(())
2103}
2104
2105fn save_checkpointed_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
2106    validate_session_record(session)?;
2107
2108    let mut connection = open(path)?;
2109    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2110    update_lifecycle_fields(&tx, session)?;
2111    tx.execute(
2112        "UPDATE sessions SET native_session_id = ?2 WHERE session_id = ?1",
2113        params![session.id, session.native_session_id],
2114    )?;
2115    replace_checkpoint(&tx, session)?;
2116    tx.commit()?;
2117    Ok(())
2118}
2119
2120fn validate_session_record(session: &SessionRecord) -> Result<()> {
2121    let mut validation = State::default();
2122    validation
2123        .sessions
2124        .insert(session.id.clone(), session.clone());
2125    validation.validate()
2126}
2127
2128/// Remove one operational session while retaining its relational history
2129/// context and prompt history.
2130pub fn delete_session(session_id: &str) -> Result<()> {
2131    let session_id = session_id.to_owned();
2132    submit_database_write("delete_session", move |_| {
2133        delete_session_from(&database_path(), &session_id)
2134    })
2135}
2136
2137fn delete_session_from(path: &Path, session_id: &str) -> Result<()> {
2138    let connection = open(path)?;
2139    connection.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
2140    Ok(())
2141}
2142
2143/// Load a session's whole projection, transcript and all.
2144///
2145/// Crate-private on purpose. The cost of this call is everything that has ever
2146/// happened in the conversation, and the callers that made that a visible
2147/// problem — the runtime poll and the resume reply — were both outside this
2148/// crate. What they wanted was [`load_materialized_projection_tail`]; what
2149/// they reached for was this, because it was public and its name did not say
2150/// otherwise. The remaining caller owns a live projection and genuinely needs
2151/// all of it.
2152pub fn load_materialized_session(session_id: &str) -> Result<Option<MaterializedSession>> {
2153    load_materialized_session_from(&database_path(), session_id)
2154}
2155
2156/// Load only the projection fields needed by dashboard session summaries.
2157/// Transcript bodies for tools, plans, thoughts, and old messages stay in
2158/// SQLite, which keeps dashboard startup independent of transcript size.
2159pub fn load_materialized_session_summary(
2160    session_id: &str,
2161) -> Result<Option<MaterializedSessionSummary>> {
2162    load_materialized_session_summary_from(&database_path(), session_id)
2163}
2164
2165fn load_materialized_session_summary_from(
2166    path: &Path,
2167    session_id: &str,
2168) -> Result<Option<MaterializedSessionSummary>> {
2169    let connection = open_reader(path)?;
2170    let row = connection
2171        .query_row(
2172            "SELECT applied_event_ordinal, last_activity_at_ms, execution_state,
2173                    running_started_at_ms, session_title
2174             FROM materialized_sessions WHERE session_id = ?1",
2175            [session_id],
2176            |row| {
2177                Ok((
2178                    row.get::<_, u64>(0)?,
2179                    row.get::<_, Option<i64>>(1)?,
2180                    row.get::<_, String>(2)?,
2181                    row.get::<_, Option<i64>>(3)?,
2182                    row.get::<_, Option<String>>(4)?,
2183                ))
2184            },
2185        )
2186        .optional()?;
2187    let Some((
2188        applied_event_ordinal,
2189        last_activity_at_ms,
2190        execution,
2191        running_started_at_ms,
2192        session_title,
2193    )) = row
2194    else {
2195        return Ok(None);
2196    };
2197
2198    let last_user_message = last_materialized_user_message(&connection, session_id)?;
2199    let last_agent_message = last_materialized_agent_message(&connection, session_id)?;
2200    let last_agent_message_follows_last_user =
2201        last_agent_message
2202            .as_ref()
2203            .is_some_and(|(agent_position, _)| {
2204                last_user_message
2205                    .as_ref()
2206                    .is_none_or(|(user_position, _)| agent_position > user_position)
2207            });
2208    let mut ordinal_statement = connection.prepare(
2209        "SELECT latest_content_event_ordinal
2210         FROM materialized_transcript_items
2211         WHERE session_id = ?1
2212           AND latest_content_event_ordinal IS NOT NULL
2213           AND EXISTS (
2214               SELECT 1 FROM json_each(
2215                   CASE
2216                       WHEN latest_content_event_ordinal IS NOT NULL
2217                           AND json_valid(body_json)
2218                       THEN body_json
2219                       ELSE '{}'
2220                   END,
2221                   '$.chunks'
2222               ) AS chunk
2223               WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
2224                 AND (
2225                     json_extract(chunk.value, '$.content.type') <> 'text'
2226                     OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
2227                 )
2228           )
2229         ORDER BY position, stable_id",
2230    )?;
2231    let agent_message_latest_content_ordinals = ordinal_statement
2232        .query_map([session_id], |row| row.get::<_, u64>(0))?
2233        .collect::<rusqlite::Result<Vec<_>>>()?;
2234    let restart_pattern = format!("{}*", mj_core::transcript::SESSION_RESTART_ITEM_PREFIX);
2235    let mut restart_statement = connection.prepare(
2236        "SELECT position
2237         FROM materialized_transcript_items
2238         WHERE session_id = ?1 AND stable_id GLOB ?2
2239         ORDER BY position, stable_id",
2240    )?;
2241    let session_restart_event_ordinals = restart_statement
2242        .query_map((session_id, restart_pattern), |row| row.get::<_, u64>(0))?
2243        .collect::<rusqlite::Result<Vec<_>>>()?;
2244
2245    Ok(Some(MaterializedSessionSummary {
2246        session_id: session_id.to_owned(),
2247        applied_event_ordinal,
2248        last_activity_at_ms,
2249        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
2250        session_title,
2251        last_agent_message: last_agent_message.map(|(_, message)| message),
2252        last_user_message: last_user_message.map(|(_, message)| message),
2253        last_agent_message_follows_last_user,
2254        agent_message_latest_content_ordinals,
2255        session_restart_event_ordinals,
2256    }))
2257}
2258
2259/// The oldest visible user message, which is where a session's provisional
2260/// title comes from. It sits at the head of the transcript, so a projection
2261/// loaded as a tail cannot find it by scanning; this reads it directly.
2262fn first_materialized_user_message(
2263    connection: &Connection,
2264    session_id: &str,
2265) -> Result<Option<(u64, String)>> {
2266    materialized_user_message(connection, session_id, true)
2267}
2268
2269fn last_materialized_user_message(
2270    connection: &Connection,
2271    session_id: &str,
2272) -> Result<Option<(u64, String)>> {
2273    materialized_user_message(connection, session_id, false)
2274}
2275
2276fn materialized_user_message(
2277    connection: &Connection,
2278    session_id: &str,
2279    oldest_first: bool,
2280) -> Result<Option<(u64, String)>> {
2281    let mut statement = connection.prepare(if oldest_first {
2282        "SELECT position, body_json
2283         FROM materialized_transcript_items
2284         WHERE session_id = ?1
2285           AND json_extract(
2286               CASE
2287                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2288                   THEN body_json
2289                   ELSE '{}'
2290               END,
2291               '$.kind'
2292           ) = 'user'
2293         ORDER BY position, stable_id"
2294    } else {
2295        "SELECT position, body_json
2296         FROM materialized_transcript_items
2297         WHERE session_id = ?1
2298           AND json_extract(
2299               CASE
2300                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2301                   THEN body_json
2302                   ELSE '{}'
2303               END,
2304               '$.kind'
2305           ) = 'user'
2306         ORDER BY position DESC, stable_id DESC"
2307    })?;
2308    let rows = statement.query_map([session_id], |row| {
2309        Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
2310    })?;
2311    for row in rows {
2312        let (position, body_json) = row?;
2313        let body: TranscriptBody = serde_json::from_str(&body_json)
2314            .with_context(|| format!("parse materialized user message for session {session_id}"))?;
2315        let TranscriptBody::User { content } = body else {
2316            continue;
2317        };
2318        let text = mj_core::transcript::materialized_content_text(&content);
2319        if !text.trim().is_empty() {
2320            return Ok(Some((position, text)));
2321        }
2322    }
2323    Ok(None)
2324}
2325
2326/// Where the newest turn began: a user message, or the marker for a turn the
2327/// harness started on its own. This is the recovery boundary, so it reads a
2328/// position only and never has to decode a transcript body.
2329fn last_materialized_turn_start(connection: &Connection, session_id: &str) -> Result<Option<u64>> {
2330    Ok(connection
2331        .query_row(
2332            "SELECT position
2333             FROM materialized_transcript_items
2334             WHERE session_id = ?1
2335               AND (
2336                   stable_id GLOB ?2
2337                   OR json_extract(
2338                       CASE
2339                           WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2340                           THEN body_json
2341                           ELSE '{}'
2342                       END,
2343                       '$.kind'
2344                   ) = 'user'
2345               )
2346             ORDER BY position DESC, stable_id DESC
2347             LIMIT 1",
2348            params![
2349                session_id,
2350                format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX)
2351            ],
2352            |row| row.get::<_, u64>(0),
2353        )
2354        .optional()?)
2355}
2356
2357fn last_materialized_agent_message(
2358    connection: &Connection,
2359    session_id: &str,
2360) -> Result<Option<(u64, String)>> {
2361    last_materialized_agent_message_in(connection, session_id, 0)
2362}
2363
2364/// The newest nonempty agent message strictly after `after_position`, flattened
2365/// to text. Restricting by position is how one turn's final message is read.
2366fn last_materialized_agent_message_after(
2367    connection: &Connection,
2368    session_id: &str,
2369    after_position: u64,
2370) -> Result<Option<String>> {
2371    Ok(
2372        last_materialized_agent_message_in(connection, session_id, after_position)?
2373            .map(|(_, text)| text),
2374    )
2375}
2376
2377fn last_materialized_agent_message_in(
2378    connection: &Connection,
2379    session_id: &str,
2380    after_position: u64,
2381) -> Result<Option<(u64, String)>> {
2382    let row = connection
2383        .query_row(
2384            "SELECT position, body_json
2385             FROM materialized_transcript_items
2386             WHERE session_id = ?1
2387               AND position > ?2
2388               AND latest_content_event_ordinal IS NOT NULL
2389               AND EXISTS (
2390                   SELECT 1 FROM json_each(
2391                       CASE
2392                           WHEN latest_content_event_ordinal IS NOT NULL
2393                               AND json_valid(body_json)
2394                           THEN body_json
2395                           ELSE '{}'
2396                       END,
2397                       '$.chunks'
2398                   ) AS chunk
2399                   WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
2400                     AND (
2401                         json_extract(chunk.value, '$.content.type') <> 'text'
2402                         OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
2403                     )
2404               )
2405             ORDER BY position DESC, stable_id DESC
2406             LIMIT 1",
2407            params![session_id, after_position],
2408            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
2409        )
2410        .optional()?;
2411    let Some((position, body_json)) = row else {
2412        return Ok(None);
2413    };
2414    let body: TranscriptBody = serde_json::from_str(&body_json)
2415        .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
2416    let TranscriptBody::Agent { chunks, .. } = body else {
2417        return Ok(None);
2418    };
2419    let text = mj_core::transcript::materialized_chunks_text(&chunks);
2420    Ok((!text.trim().is_empty()).then_some((position, text)))
2421}
2422
2423/// Execution state, the running turn, and the last finished turn's outcome.
2424pub type MaterializedTurnState = (
2425    MaterializedExecutionState,
2426    Option<MaterializedTurn>,
2427    Option<MaterializedTurnOutcome>,
2428);
2429
2430/// Where a session stands turn by turn: what is running now, and how the last
2431/// finished prompt ended. Returns `None` when the session has no projection
2432/// row. The API's wait loop reads this for sessions whose actor is gone.
2433pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
2434    load_materialized_turn_outcome_from(&database_path(), session_id)
2435}
2436
2437fn load_materialized_turn_outcome_from(
2438    path: &Path,
2439    session_id: &str,
2440) -> Result<Option<MaterializedTurnState>> {
2441    let connection = open_reader(path)?;
2442    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
2443        return Ok(None);
2444    };
2445    Ok(Some((
2446        fields.execution,
2447        fields.active_turn,
2448        fields.last_turn_outcome,
2449    )))
2450}
2451
2452/// Summarize the turn that began at `turn_start_position`.
2453pub fn load_materialized_turn_summary(
2454    session_id: &str,
2455    turn_start_position: u64,
2456) -> Result<TurnSummary> {
2457    load_materialized_turn_summary_from(&database_path(), session_id, turn_start_position)
2458}
2459
2460fn load_materialized_turn_summary_from(
2461    path: &Path,
2462    session_id: &str,
2463    turn_start_position: u64,
2464) -> Result<TurnSummary> {
2465    let connection = open_reader(path)?;
2466    let turn_number = connection.query_row(
2467        "SELECT COUNT(*)
2468         FROM materialized_transcript_items
2469         WHERE session_id = ?1
2470           AND position <= ?3
2471           AND (
2472               stable_id GLOB ?2
2473               OR json_extract(
2474                   CASE
2475                       WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2476                       THEN body_json
2477                       ELSE '{}'
2478                   END,
2479                   '$.kind'
2480               ) = 'user'
2481           )",
2482        params![
2483            session_id,
2484            format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
2485            turn_start_position
2486        ],
2487        |row| row.get::<_, u64>(0),
2488    )?;
2489    let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
2490        "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
2491         FROM materialized_transcript_items
2492         WHERE session_id = ?1 AND position >= ?2",
2493        params![session_id, turn_start_position],
2494        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
2495    )?;
2496    let final_message =
2497        last_materialized_agent_message_after(&connection, session_id, turn_start_position)?;
2498    Ok(TurnSummary {
2499        turn_number,
2500        turn_started_at_ms,
2501        last_changed_at_ms,
2502        final_message,
2503    })
2504}
2505
2506/// Read the transcript items whose sequence is above `after_seq`, oldest
2507/// first. Returns `None` when the session has no projection row.
2508///
2509/// The sequence is `COALESCE(latest_content_event_ordinal, position)`: an
2510/// agent message is rewritten while it streams, so paging by position would
2511/// hand a caller the message as it was first created and never send the
2512/// finished text. Paging by this sequence sends the item again exactly when it
2513/// changed, and a caller that keeps the highest sequence it saw resumes from
2514/// there whether the session is live or long stopped.
2515pub fn load_materialized_transcript_after(
2516    session_id: &str,
2517    after_seq: u64,
2518    limit: usize,
2519) -> Result<Option<TranscriptPage>> {
2520    load_materialized_transcript_after_from(&database_path(), session_id, after_seq, limit)
2521}
2522
2523fn load_materialized_transcript_after_from(
2524    path: &Path,
2525    session_id: &str,
2526    after_seq: u64,
2527    limit: usize,
2528) -> Result<Option<TranscriptPage>> {
2529    load_materialized_transcript_filtered_from(path, session_id, after_seq, limit, None)
2530}
2531
2532pub fn load_materialized_transcript_filtered(
2533    session_id: &str,
2534    after_seq: u64,
2535    limit: usize,
2536    role: Option<mj_core::transcript::TranscriptRole>,
2537) -> Result<Option<TranscriptPage>> {
2538    load_materialized_transcript_filtered_from(&database_path(), session_id, after_seq, limit, role)
2539}
2540
2541fn load_materialized_transcript_filtered_from(
2542    path: &Path,
2543    session_id: &str,
2544    after_seq: u64,
2545    limit: usize,
2546    role: Option<mj_core::transcript::TranscriptRole>,
2547) -> Result<Option<TranscriptPage>> {
2548    let mut reader = open_reader(path)?;
2549    let connection = reader.transaction()?;
2550    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
2551        return Ok(None);
2552    };
2553    let role = role.map(|r| r.storage_kind());
2554    let mut statement = connection.prepare(
2555        "WITH matches AS (
2556             SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
2557             FROM materialized_transcript_items WHERE session_id = ?1
2558             AND COALESCE(latest_content_event_ordinal, position) > ?2
2559             AND (?4 IS NULL OR json_extract(body_json, '$.kind') = ?4)
2560         ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
2561         SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
2562                last_changed_at_ms, body_json
2563         FROM matches WHERE seq <= (SELECT seq FROM boundary)
2564         ORDER BY seq, stable_id",
2565    )?;
2566    let rows = statement
2567        .query_map(
2568            params![session_id, after_seq, limit.clamp(1, 1000) as i64, role],
2569            |row| {
2570                Ok((
2571                    row.get::<_, String>(0)?,
2572                    row.get::<_, u64>(1)?,
2573                    row.get::<_, Option<u64>>(2)?,
2574                    row.get::<_, i64>(3)?,
2575                    row.get::<_, i64>(4)?,
2576                    row.get::<_, String>(5)?,
2577                ))
2578            },
2579        )?
2580        .collect::<rusqlite::Result<Vec<_>>>()?;
2581    let items = rows
2582        .into_iter()
2583        .map(
2584            |(
2585                stable_id,
2586                position,
2587                latest_content_event_ordinal,
2588                created_at_ms,
2589                last_changed_at_ms,
2590                body_json,
2591            )| {
2592                Ok(Arc::new(TranscriptItem {
2593                    stable_id,
2594                    position,
2595                    latest_content_event_ordinal,
2596                    created_at_ms,
2597                    last_changed_at_ms,
2598                    body: serde_json::from_str(&body_json).with_context(|| {
2599                        format!("parse materialized transcript body for session {session_id}")
2600                    })?,
2601                }))
2602            },
2603        )
2604        .collect::<Result<Vec<_>>>()?;
2605    let latest_seq = connection.query_row(
2606        "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
2607         FROM materialized_transcript_items
2608         WHERE session_id = ?1",
2609        [session_id],
2610        |row| row.get::<_, u64>(0),
2611    )?;
2612    let last_seq = items.last().map_or(after_seq, |item| item.seq());
2613    let more: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM materialized_transcript_items WHERE session_id = ?1 AND COALESCE(latest_content_event_ordinal, position) > ?2 AND (?3 IS NULL OR json_extract(body_json, '$.kind') = ?3))", params![session_id, last_seq, role], |r| r.get(0))?;
2614    Ok(Some(TranscriptPage {
2615        next_after_seq: if more {
2616            last_seq
2617        } else {
2618            latest_seq.max(after_seq)
2619        },
2620        items,
2621        latest_seq,
2622        execution: fields.execution,
2623    }))
2624}
2625
2626/// Read the newest `limit` transcript items for a session, oldest first.
2627///
2628/// A conversation view seeds itself from the tail and discards everything
2629/// before it — `ChatState::from_materialized_tail` keeps `TAIL_SEED_ITEMS`
2630/// and drops the rest — so reading the whole transcript to show the end of it
2631/// is work proportional to history for a result that never was. On a real
2632/// session that meant reading 28,066 rows to render 256.
2633///
2634/// The `materialized_transcript_position` index covers the ordering, so this
2635/// costs the rows it returns rather than the rows that exist.
2636pub fn load_materialized_transcript_tail(
2637    session_id: &str,
2638    limit: usize,
2639) -> Result<Vec<Arc<TranscriptItem>>> {
2640    load_materialized_transcript_tail_from(&database_path(), session_id, limit)
2641}
2642
2643fn load_materialized_transcript_tail_from(
2644    path: &Path,
2645    session_id: &str,
2646    limit: usize,
2647) -> Result<Vec<Arc<TranscriptItem>>> {
2648    read_materialized_transcript(&open_reader(path)?, session_id, Some(limit))
2649}
2650
2651/// How many transcript rows one retention pass rewrites.
2652///
2653/// The daemon is the single database writer, so a pass that rewrote every row
2654/// of a long session would stall every other write behind it. A capped pass
2655/// leaves the rest for the next checkpoint, which is the next time any of it
2656/// becomes redundant anyway.
2657const RETENTION_BATCH_ITEMS: usize = 4_096;
2658
2659/// Rows below this are already small enough that rewriting them would cost
2660/// more than it reclaims.
2661const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
2662
2663/// Drop tool output that a verified checkpoint already holds.
2664///
2665/// The projection only ever grew: the only deletes were a per-item remove, a
2666/// whole-session wipe, and the `sessions` cascade. One measured session reached
2667/// 28,066 items and 635 MiB, of which 561 MB was tool-call content.
2668///
2669/// A checkpoint archive carries the complete transcript up to its event
2670/// frontier, and one checkpoint per session is retained, so every item at or
2671/// below `event_frontier` is durably recorded elsewhere. What stays here is
2672/// what the transcript still shows: which tool ran, on what, with what result,
2673/// and each edit's diffstat. See
2674/// [`mj_transcript::transcript::compact_tool_call_for_retention`].
2675pub fn compact_materialized_transcript_through(
2676    session_id: &str,
2677    event_frontier: u64,
2678) -> Result<TranscriptRetention> {
2679    let session_id = session_id.to_owned();
2680    submit_database_write("compact_materialized_transcript", move |_| {
2681        compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
2682    })
2683}
2684
2685fn compact_materialized_transcript_in(
2686    path: &Path,
2687    session_id: &str,
2688    event_frontier: u64,
2689) -> Result<TranscriptRetention> {
2690    let mut connection = open(path)?;
2691    let candidates = {
2692        let mut statement = connection.prepare(
2693            "SELECT stable_id, body_json
2694             FROM materialized_transcript_items
2695             WHERE session_id = ?1
2696               AND position <= ?2
2697               AND length(body_json) > ?3
2698               AND json_extract(
2699                   CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
2700                   '$.kind'
2701               ) = 'tool'
2702             ORDER BY position, stable_id
2703             LIMIT ?4",
2704        )?;
2705        statement
2706            .query_map(
2707                params![
2708                    session_id,
2709                    event_frontier,
2710                    RETENTION_BODY_FLOOR_BYTES as i64,
2711                    RETENTION_BATCH_ITEMS as i64 + 1
2712                ],
2713                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2714            )?
2715            .collect::<rusqlite::Result<Vec<_>>>()?
2716    };
2717    let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
2718    let mut retention = TranscriptRetention {
2719        remaining,
2720        ..TranscriptRetention::default()
2721    };
2722    let transaction =
2723        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2724    for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
2725        let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
2726            Ok(body) => body,
2727            // A row this cannot read is a row it must not rewrite.
2728            Err(error) => {
2729                tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
2730                continue;
2731            }
2732        };
2733        if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
2734            continue;
2735        }
2736        let compacted = serde_json::to_string(&body)
2737            .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
2738        if compacted.len() >= body_json.len() {
2739            continue;
2740        }
2741        transaction.execute(
2742            "UPDATE materialized_transcript_items SET body_json = ?3
2743             WHERE session_id = ?1 AND stable_id = ?2",
2744            params![session_id, stable_id, compacted],
2745        )?;
2746        retention.items += 1;
2747        retention.bytes += body_json.len() - compacted.len();
2748    }
2749    transaction.commit()?;
2750    Ok(retention)
2751}
2752
2753/// How many transcript items a polled projection carries.
2754///
2755/// Every viewer of a polled projection is bounded already: the conversation
2756/// pane keeps `chat::TAIL_SEED_ITEMS` (256) entries, and the browser
2757/// transcript keeps 1,000 rendered lines. This is set above both, since an
2758/// entry renders to at least one line, so the window is the whole of what any
2759/// of them would show.
2760pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
2761
2762/// Load a projection carrying only the end of its transcript.
2763///
2764/// The steady-state poll reloads a session's projection every time anything
2765/// about it moves. Loading the whole transcript to do that is work
2766/// proportional to everything that has ever happened in the conversation —
2767/// 635 MiB and 28,066 items on one measured session — for a view that shows
2768/// the last few hundred entries. This reads the window instead, plus the two
2769/// facts that live outside it, each with one indexed query. See
2770/// [`ProjectionWindow`].
2771pub fn load_materialized_projection_tail(
2772    session_id: &str,
2773    transcript_limit: usize,
2774) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
2775    load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
2776}
2777
2778fn load_materialized_projection_tail_from(
2779    path: &Path,
2780    session_id: &str,
2781    transcript_limit: usize,
2782) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
2783    let connection = open_reader(path)?;
2784    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
2785        return Ok(None);
2786    };
2787    let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
2788    let total_items = connection.query_row(
2789        "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
2790        [session_id],
2791        |row| row.get::<_, usize>(0),
2792    )?;
2793    let window = ProjectionWindow {
2794        omitted_items: total_items.saturating_sub(transcript.len()),
2795        provisional_title: first_materialized_user_message(&connection, session_id)?
2796            .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
2797        latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
2798    };
2799    let materialized = MaterializedSession {
2800        session_id: session_id.to_owned(),
2801        applied_event_ordinal: fields.applied_event_ordinal,
2802        applied_event_digest: fields.applied_event_digest,
2803        last_activity_at_ms: fields.last_activity_at_ms,
2804        execution: fields.execution,
2805        session_title: fields.session_title,
2806        configuration: fields.configuration,
2807        transcript,
2808        queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
2809        pending_elicitations: fields.pending_elicitations,
2810        active_turn: fields.active_turn,
2811        last_turn_outcome: fields.last_turn_outcome,
2812    };
2813    materialized.validate()?;
2814    Ok(Some((materialized, window)))
2815}
2816
2817/// Read only the projection's event frontier. Deciding whether a stored
2818/// projection already matches an archive costs one row this way, instead of
2819/// deserializing every transcript item to compare two integers.
2820pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
2821    materialized_event_frontier_from(&database_path(), session_id)
2822}
2823
2824fn materialized_event_frontier_from(
2825    path: &Path,
2826    session_id: &str,
2827) -> Result<Option<(u64, String)>> {
2828    Ok(open_reader(path)?
2829        .query_row(
2830            "SELECT applied_event_ordinal, applied_event_digest
2831             FROM materialized_sessions WHERE session_id = ?1",
2832            [session_id],
2833            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
2834        )
2835        .optional()?)
2836}
2837
2838/// Replace a session's durable prompt queue without touching its transcript or
2839/// event frontier. Resume uses this when it keeps the stored projection but
2840/// still has to drop the queue the archive carried.
2841pub fn replace_materialized_queued_prompts(
2842    session_id: &str,
2843    queued_prompts: &[MaterializedQueuedPrompt],
2844) -> Result<()> {
2845    let session_id = session_id.to_owned();
2846    let queued_prompts = queued_prompts.to_vec();
2847    submit_database_write("replace_materialized_queued_prompts", move |_| {
2848        replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
2849    })
2850}
2851
2852fn replace_materialized_queued_prompts_in(
2853    path: &Path,
2854    session_id: &str,
2855    queued_prompts: &[MaterializedQueuedPrompt],
2856) -> Result<()> {
2857    let mut connection = open(path)?;
2858    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2859    if !session_exists(&tx, session_id)? {
2860        bail!("unknown session {session_id}");
2861    }
2862    replace_materialized_queue(&tx, session_id, queued_prompts)?;
2863    tx.commit()?;
2864    Ok(())
2865}
2866
2867/// Load only the durable prompt queues without deserializing transcript rows.
2868/// Dashboard startup uses this path so work is proportional to queued prompts,
2869/// not to the complete retained conversation history.
2870pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
2871{
2872    load_materialized_queued_prompts_from(&database_path())
2873}
2874
2875fn load_materialized_queued_prompts_from(
2876    path: &Path,
2877) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
2878    let connection = open_reader(path)?;
2879    let mut statement = connection.prepare(
2880        "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
2881         FROM materialized_queued_prompts
2882         ORDER BY session_id, ordinal",
2883    )?;
2884    let rows = statement.query_map([], |row| {
2885        Ok((
2886            row.get::<_, String>(0)?,
2887            row.get::<_, String>(1)?,
2888            row.get::<_, String>(2)?,
2889            row.get::<_, String>(3)?,
2890            row.get::<_, i64>(4)?,
2891            row.get::<_, Option<u64>>(5)?,
2892        ))
2893    })?;
2894    let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
2895    for row in rows {
2896        let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
2897            row?;
2898        let content = serde_json::from_str(&content_json).with_context(|| {
2899            format!("parse materialized queued prompt for session {session_id}")
2900        })?;
2901        let kind = serde_json::from_str(&kind_json).with_context(|| {
2902            format!("parse materialized queue entry kind for session {session_id}")
2903        })?;
2904        queues
2905            .entry(session_id)
2906            .or_default()
2907            .push(MaterializedQueuedPrompt {
2908                command_id,
2909                kind,
2910                content,
2911                queued_at_ms,
2912                accepted_ordinal,
2913            });
2914    }
2915    Ok(queues)
2916}
2917
2918fn load_materialized_session_from(
2919    path: &Path,
2920    session_id: &str,
2921) -> Result<Option<MaterializedSession>> {
2922    let connection = open_reader(path)?;
2923    load_materialized_session_with(&connection, session_id)
2924}
2925
2926fn load_materialized_session_with(
2927    connection: &Connection,
2928    session_id: &str,
2929) -> Result<Option<MaterializedSession>> {
2930    let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
2931        return Ok(None);
2932    };
2933    let materialized = MaterializedSession {
2934        session_id: session_id.to_owned(),
2935        applied_event_ordinal: fields.applied_event_ordinal,
2936        applied_event_digest: fields.applied_event_digest,
2937        last_activity_at_ms: fields.last_activity_at_ms,
2938        execution: fields.execution,
2939        session_title: fields.session_title,
2940        configuration: fields.configuration,
2941        transcript: read_materialized_transcript(connection, session_id, None)?,
2942        queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
2943        pending_elicitations: fields.pending_elicitations,
2944        active_turn: fields.active_turn,
2945        last_turn_outcome: fields.last_turn_outcome,
2946    };
2947    materialized.validate()?;
2948    Ok(Some(materialized))
2949}
2950
2951/// Everything a projection holds apart from its transcript and its queue.
2952struct MaterializedSessionFields {
2953    applied_event_ordinal: u64,
2954    applied_event_digest: String,
2955    last_activity_at_ms: Option<i64>,
2956    execution: MaterializedExecutionState,
2957    session_title: Option<String>,
2958    configuration: BTreeMap<String, serde_json::Value>,
2959    pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
2960    active_turn: Option<MaterializedTurn>,
2961    last_turn_outcome: Option<MaterializedTurnOutcome>,
2962}
2963
2964fn read_materialized_session_fields(
2965    connection: &Connection,
2966    session_id: &str,
2967) -> Result<Option<MaterializedSessionFields>> {
2968    let row = connection
2969        .query_row(
2970            "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
2971                    execution_state, running_started_at_ms, session_title, configuration_json,
2972                    pending_elicitations_json, active_turn_json, last_turn_outcome_json
2973             FROM materialized_sessions WHERE session_id = ?1",
2974            [session_id],
2975            |row| {
2976                Ok((
2977                    row.get::<_, u64>(0)?,
2978                    row.get::<_, String>(1)?,
2979                    row.get::<_, Option<i64>>(2)?,
2980                    row.get::<_, String>(3)?,
2981                    row.get::<_, Option<i64>>(4)?,
2982                    row.get::<_, Option<String>>(5)?,
2983                    row.get::<_, String>(6)?,
2984                    row.get::<_, String>(7)?,
2985                    row.get::<_, Option<String>>(8)?,
2986                    row.get::<_, Option<String>>(9)?,
2987                ))
2988            },
2989        )
2990        .optional()?;
2991    let Some((
2992        applied_event_ordinal,
2993        applied_event_digest,
2994        last_activity_at_ms,
2995        execution,
2996        running_started_at_ms,
2997        session_title,
2998        configuration_json,
2999        pending_elicitations_json,
3000        active_turn_json,
3001        last_turn_outcome_json,
3002    )) = row
3003    else {
3004        return Ok(None);
3005    };
3006    Ok(Some(MaterializedSessionFields {
3007        applied_event_ordinal,
3008        applied_event_digest,
3009        last_activity_at_ms,
3010        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
3011        session_title,
3012        configuration: serde_json::from_str(&configuration_json).with_context(|| {
3013            format!("parse materialized configuration for session {session_id}")
3014        })?,
3015        pending_elicitations: serde_json::from_str(&pending_elicitations_json)
3016            .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
3017        active_turn: active_turn_json
3018            .as_deref()
3019            .map(serde_json::from_str)
3020            .transpose()
3021            .with_context(|| format!("parse active turn for session {session_id}"))?,
3022        last_turn_outcome: last_turn_outcome_json
3023            .as_deref()
3024            .map(serde_json::from_str)
3025            .transpose()
3026            .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
3027    }))
3028}
3029
3030/// Read a session's transcript, oldest first. `limit` reads only that many
3031/// items from the end, walking the `materialized_transcript_position` index
3032/// backwards so the read costs the rows it returns.
3033fn read_materialized_transcript(
3034    connection: &Connection,
3035    session_id: &str,
3036    limit: Option<usize>,
3037) -> Result<Vec<Arc<TranscriptItem>>> {
3038    let mut statement = connection.prepare(match limit {
3039        Some(_) => {
3040            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
3041                    last_changed_at_ms, body_json
3042             FROM materialized_transcript_items
3043             WHERE session_id = ?1
3044             ORDER BY position DESC, stable_id DESC
3045             LIMIT ?2"
3046        }
3047        None => {
3048            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
3049                    last_changed_at_ms, body_json
3050             FROM materialized_transcript_items
3051             WHERE session_id = ?1
3052             ORDER BY position, stable_id"
3053        }
3054    })?;
3055    let read = |row: &rusqlite::Row<'_>| {
3056        Ok((
3057            row.get::<_, String>(0)?,
3058            row.get::<_, u64>(1)?,
3059            row.get::<_, Option<u64>>(2)?,
3060            row.get::<_, i64>(3)?,
3061            row.get::<_, i64>(4)?,
3062            row.get::<_, String>(5)?,
3063        ))
3064    };
3065    let rows = match limit {
3066        Some(limit) => statement
3067            .query_map(params![session_id, limit as i64], read)?
3068            .collect::<rusqlite::Result<Vec<_>>>()?,
3069        None => statement
3070            .query_map([session_id], read)?
3071            .collect::<rusqlite::Result<Vec<_>>>()?,
3072    };
3073    let mut transcript = rows
3074        .into_iter()
3075        .map(
3076            |(
3077                stable_id,
3078                position,
3079                latest_content_event_ordinal,
3080                created_at_ms,
3081                last_changed_at_ms,
3082                body_json,
3083            )| {
3084                Ok(Arc::new(TranscriptItem {
3085                    stable_id,
3086                    position,
3087                    latest_content_event_ordinal,
3088                    created_at_ms,
3089                    last_changed_at_ms,
3090                    body: serde_json::from_str(&body_json).with_context(|| {
3091                        format!("parse materialized transcript body for session {session_id}")
3092                    })?,
3093                }))
3094            },
3095        )
3096        .collect::<Result<Vec<_>>>()?;
3097    if limit.is_some() {
3098        // The bounded query walks the index backwards to bound what it reads;
3099        // every caller wants the transcript in the order it was written.
3100        transcript.reverse();
3101    }
3102    Ok(transcript)
3103}
3104
3105fn read_materialized_queued_prompts(
3106    connection: &Connection,
3107    session_id: &str,
3108) -> Result<Vec<MaterializedQueuedPrompt>> {
3109    let mut statement = connection.prepare(
3110        "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
3111         FROM materialized_queued_prompts
3112         WHERE session_id = ?1
3113         ORDER BY ordinal",
3114    )?;
3115    let rows = statement
3116        .query_map([session_id], |row| {
3117            Ok((
3118                row.get::<_, String>(0)?,
3119                row.get::<_, String>(1)?,
3120                row.get::<_, String>(2)?,
3121                row.get::<_, i64>(3)?,
3122                row.get::<_, Option<u64>>(4)?,
3123            ))
3124        })?
3125        .collect::<rusqlite::Result<Vec<_>>>()?;
3126    rows.into_iter()
3127        .map(
3128            |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
3129                Ok(MaterializedQueuedPrompt {
3130                    command_id,
3131                    kind: serde_json::from_str(&kind_json).with_context(|| {
3132                        format!("parse materialized queue entry kind for session {session_id}")
3133                    })?,
3134                    content: serde_json::from_str(&content_json).with_context(|| {
3135                        format!("parse materialized queued prompt for session {session_id}")
3136                    })?,
3137                    queued_at_ms,
3138                    accepted_ordinal,
3139                })
3140            },
3141        )
3142        .collect()
3143}
3144
3145/// Replace a complete projection, primarily when seeding a restored
3146/// checkpoint. Operational `SessionRecord` metadata and read receipts are not
3147/// modified.
3148pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
3149    let materialized = materialized.clone();
3150    submit_database_write("save_materialized_session", move |_| {
3151        save_materialized_session_to(&database_path(), &materialized)
3152    })
3153}
3154
3155fn save_materialized_session_to(path: &Path, materialized: &MaterializedSession) -> Result<()> {
3156    materialized.validate()?;
3157    let mut connection = open(path)?;
3158    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3159    if !session_exists(&tx, &materialized.session_id)? {
3160        bail!("unknown session {}", materialized.session_id);
3161    }
3162    write_materialized_session(&tx, materialized)?;
3163    tx.commit()?;
3164    Ok(())
3165}
3166
3167/// One relay page being applied inside a single write transaction. The relay
3168/// retains everything past the last acknowledgement, so a page that fails
3169/// part-way rolls back to the previous durable frontier and is simply
3170/// redelivered. Only a committed page may be acknowledged.
3171pub struct ProjectionPage<'a> {
3172    session_id: &'a str,
3173    transaction: Transaction<'a>,
3174    applied_ordinal: u64,
3175    applied_digest: String,
3176    dirty: bool,
3177    pending: MaterializedSessionMutation,
3178    pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
3179    pending_turns: Vec<MaterializedTurnOutcome>,
3180    pending_events: Vec<(i64, ApiEventData)>,
3181}
3182
3183struct PendingTranscriptMutation {
3184    final_mutation: TranscriptMutation,
3185    remove_before_upsert: bool,
3186}
3187
3188impl ProjectionPage<'_> {
3189    /// Apply the projection effects of the next relay event to the open page.
3190    /// The event must continue the chain the page has reached so far, which is
3191    /// the persisted frontier plus every event already applied to this page.
3192    pub fn apply(
3193        &mut self,
3194        event_ordinal: u64,
3195        previous_event_digest: &str,
3196        event_digest: &str,
3197        mutation: &MaterializedSessionMutation,
3198    ) -> Result<ProjectionApplyOutcome> {
3199        if event_ordinal == 0 {
3200            bail!("relay event ordinal must be positive");
3201        }
3202        // A v2 event carries no chain link (empty previous digest). Its
3203        // continuity to the projection frontier is proven by ordinal
3204        // contiguity plus the attach cursor the controller validated against
3205        // the worker, not by an in-record back-reference; divergence is caught
3206        // there, before any event is applied.
3207        let chained = !previous_event_digest.is_empty();
3208        if chained {
3209            validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
3210        }
3211        validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
3212        let session_id = self.session_id;
3213        let applied = self.applied_ordinal;
3214        if event_ordinal < applied {
3215            return Ok(ProjectionApplyOutcome::AlreadyApplied);
3216        }
3217        if event_ordinal == applied {
3218            if event_digest != self.applied_digest {
3219                bail!(
3220                    "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
3221                    self.applied_digest
3222                );
3223            }
3224            return Ok(ProjectionApplyOutcome::AlreadyApplied);
3225        }
3226        let expected = applied
3227            .checked_add(1)
3228            .context("materialized event ordinal overflow")?;
3229        if event_ordinal != expected {
3230            bail!(
3231                "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
3232            );
3233        }
3234        if chained && previous_event_digest != self.applied_digest {
3235            bail!(
3236                "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
3237                self.applied_digest
3238            );
3239        }
3240
3241        if let Some(activity_at_ms) = mutation.last_activity_at_ms {
3242            self.pending.last_activity_at_ms = Some(
3243                self.pending
3244                    .last_activity_at_ms
3245                    .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
3246            );
3247        }
3248        if let Some(execution) = mutation.execution {
3249            self.pending.execution = Some(execution);
3250        }
3251        if let Some(title) = &mutation.session_title {
3252            if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
3253                bail!("materialized session title cannot be empty");
3254            }
3255            self.pending.session_title = Some(title.clone());
3256        }
3257        if let Some(configuration) = &mutation.configuration {
3258            self.pending.configuration = Some(configuration.clone());
3259        }
3260        for item_mutation in &mutation.transcript {
3261            match item_mutation {
3262                TranscriptMutation::Upsert(item) => {
3263                    item.validate(event_ordinal)?;
3264                    let stable_id = item.stable_id.clone();
3265                    let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
3266                        PendingTranscriptMutation {
3267                            final_mutation: TranscriptMutation::Upsert(item.clone()),
3268                            remove_before_upsert: false,
3269                        }
3270                    });
3271                    entry.remove_before_upsert |=
3272                        matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
3273                    entry.final_mutation = TranscriptMutation::Upsert(item.clone());
3274                }
3275                TranscriptMutation::Remove { stable_id } => {
3276                    if stable_id.trim().is_empty() {
3277                        bail!("cannot remove a transcript item with an empty stable id");
3278                    }
3279                    let removed = TranscriptMutation::Remove {
3280                        stable_id: stable_id.clone(),
3281                    };
3282                    self.pending_transcript
3283                        .entry(stable_id.clone())
3284                        .and_modify(|entry| entry.final_mutation = removed.clone())
3285                        .or_insert(PendingTranscriptMutation {
3286                            final_mutation: removed,
3287                            remove_before_upsert: false,
3288                        });
3289                }
3290            }
3291        }
3292        if let Some(queued_prompts) = &mutation.queued_prompts {
3293            self.pending.queued_prompts = Some(queued_prompts.clone());
3294        }
3295        if let Some(pending_elicitations) = &mutation.pending_elicitations {
3296            self.pending.pending_elicitations = Some(pending_elicitations.clone());
3297        }
3298        self.pending
3299            .config_results
3300            .extend(mutation.config_results.clone());
3301        if let Some(active_turn) = &mutation.active_turn {
3302            self.pending.active_turn = Some(active_turn.clone());
3303        }
3304        if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
3305            self.pending_turns.push(last_turn_outcome.clone());
3306            self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
3307        }
3308        if let Some(cost) = &mutation.provider_cost {
3309            self.pending.provider_cost = Some(cost.clone());
3310        }
3311        self.pending_events.extend(
3312            mutation
3313                .api_events
3314                .iter()
3315                .cloned()
3316                .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
3317        );
3318        self.applied_ordinal = event_ordinal;
3319        event_digest.clone_into(&mut self.applied_digest);
3320        self.dirty = true;
3321        Ok(ProjectionApplyOutcome::Applied)
3322    }
3323
3324    /// Persist the coalesced final state of this page. Intermediate event
3325    /// frontiers are useful only for chain validation: a page commits or rolls
3326    /// back as a unit, so writing them individually adds no recovery value.
3327    fn flush(&mut self) -> Result<()> {
3328        if !self.dirty {
3329            return Ok(());
3330        }
3331        let tx = &self.transaction;
3332        let session_id = self.session_id;
3333        if let Some(execution) = self.pending.execution {
3334            let (state, started_at_ms) = materialized_execution_columns(execution);
3335            tx.execute(
3336                "UPDATE materialized_sessions
3337                 SET execution_state = ?2, running_started_at_ms = ?3
3338                 WHERE session_id = ?1",
3339                params![session_id, state, started_at_ms],
3340            )?;
3341        }
3342        if let Some(title) = &self.pending.session_title {
3343            tx.execute(
3344                "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
3345                params![session_id, title],
3346            )?;
3347        }
3348        if let Some(configuration) = &self.pending.configuration {
3349            tx.execute(
3350                "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
3351                params![session_id, serde_json::to_string(configuration)?],
3352            )?;
3353        }
3354        for pending in self.pending_transcript.values() {
3355            match &pending.final_mutation {
3356                TranscriptMutation::Upsert(item) => {
3357                    // A remove followed by an upsert deliberately starts a new
3358                    // item identity. Preserve that boundary even though other
3359                    // repeated updates are coalesced to one write.
3360                    if pending.remove_before_upsert {
3361                        tx.execute(
3362                            "DELETE FROM materialized_transcript_items
3363                             WHERE session_id = ?1 AND stable_id = ?2",
3364                            params![session_id, item.stable_id],
3365                        )?;
3366                    }
3367                    upsert_transcript_item(tx, session_id, item)?;
3368                }
3369                TranscriptMutation::Remove { stable_id } => {
3370                    tx.execute(
3371                        "DELETE FROM materialized_transcript_items
3372                         WHERE session_id = ?1 AND stable_id = ?2",
3373                        params![session_id, stable_id],
3374                    )?;
3375                }
3376            }
3377        }
3378        if let Some(queued_prompts) = &self.pending.queued_prompts {
3379            replace_materialized_queue(tx, session_id, queued_prompts)?;
3380        }
3381        if let Some(pending_elicitations) = &self.pending.pending_elicitations {
3382            tx.execute(
3383                "UPDATE materialized_sessions
3384                 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
3385                params![session_id, serde_json::to_string(pending_elicitations)?],
3386            )?;
3387        }
3388        for (recorded_at_ms, event) in &self.pending_events {
3389            events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
3390        }
3391        for turn in &self.pending_turns {
3392            tx.execute("INSERT OR REPLACE INTO session_turn_usage(session_id, command_id, completed_ordinal, turn_start_position, body) VALUES (?1, ?2, ?3, ?4, ?5)", params![session_id, turn.command_id, turn.completed_ordinal, turn.turn_start_position, serde_json::to_string(turn)?])?;
3393        }
3394        if let Some(cost) = &self.pending.provider_cost {
3395            tx.execute(
3396                "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
3397                params![session_id, serde_json::to_string(cost)?],
3398            )?;
3399        }
3400        for (command_id, error) in &self.pending.config_results {
3401            tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
3402        }
3403        if let Some(active_turn) = &self.pending.active_turn {
3404            tx.execute(
3405                "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
3406                params![
3407                    session_id,
3408                    active_turn
3409                        .as_ref()
3410                        .map(serde_json::to_string)
3411                        .transpose()?
3412                ],
3413            )?;
3414        }
3415        if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
3416            tx.execute(
3417                "UPDATE materialized_sessions
3418                 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
3419                params![session_id, serde_json::to_string(last_turn_outcome)?],
3420            )?;
3421        }
3422        tx.execute(
3423            "UPDATE materialized_sessions
3424             SET last_activity_at_ms = CASE
3425                     WHEN ?2 IS NULL THEN last_activity_at_ms
3426                     WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
3427                     ELSE last_activity_at_ms
3428                 END,
3429                 applied_event_ordinal = ?3,
3430                 applied_event_digest = ?4
3431             WHERE session_id = ?1",
3432            params![
3433                session_id,
3434                self.pending.last_activity_at_ms,
3435                self.applied_ordinal,
3436                self.applied_digest,
3437            ],
3438        )?;
3439        Ok(())
3440    }
3441}
3442
3443/// Apply one relay page in a single transaction. `fill` feeds the page's
3444/// events through [`ProjectionPage::apply`]; the projection changes and the
3445/// event frontier commit together only when `fill` succeeds, so callers may
3446/// acknowledge the page's last ordinal to the relay after this returns.
3447pub fn apply_projection_page<T>(
3448    session_id: &str,
3449    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
3450) -> Result<T>
3451where
3452    T: Send + 'static,
3453{
3454    let session_id = session_id.to_owned();
3455    submit_database_write("apply_projection_page", move |connection| {
3456        apply_projection_page_with(connection, &session_id, fill)
3457    })
3458}
3459
3460#[cfg(test)]
3461fn apply_projection_page_to<T>(
3462    path: &Path,
3463    session_id: &str,
3464    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
3465) -> Result<T> {
3466    let mut connection = open(path)?;
3467    apply_projection_page_with(&mut connection, session_id, fill)
3468}
3469
3470fn apply_projection_page_with<T>(
3471    connection: &mut Connection,
3472    session_id: &str,
3473    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
3474) -> Result<T> {
3475    let transaction =
3476        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3477    let (applied_ordinal, applied_digest) = transaction
3478        .query_row(
3479            "SELECT applied_event_ordinal, applied_event_digest
3480             FROM materialized_sessions WHERE session_id = ?1",
3481            [session_id],
3482            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
3483        )
3484        .optional()?
3485        .with_context(|| format!("unknown session {session_id}"))?;
3486    validate_relay_event_frontier(
3487        applied_ordinal,
3488        &applied_digest,
3489        "persisted relay event frontier",
3490    )?;
3491    let mut page = ProjectionPage {
3492        session_id,
3493        transaction,
3494        applied_ordinal,
3495        applied_digest,
3496        dirty: false,
3497        pending: MaterializedSessionMutation::default(),
3498        pending_transcript: BTreeMap::new(),
3499        pending_turns: Vec::new(),
3500        pending_events: Vec::new(),
3501    };
3502    // Dropping the page on failure rolls the whole transaction back, leaving
3503    // the projection at the frontier the relay last saw acknowledged.
3504    let filled = fill(&mut page)?;
3505    page.flush()?;
3506    page.transaction.commit()?;
3507    Ok(filled)
3508}
3509
3510/// Apply exactly one relay event, as a page of one.
3511pub fn apply_projection_event(
3512    session_id: &str,
3513    event_ordinal: u64,
3514    previous_event_digest: &str,
3515    event_digest: &str,
3516    mutation: &MaterializedSessionMutation,
3517) -> Result<ProjectionApplyOutcome> {
3518    let session_id = session_id.to_owned();
3519    let previous_event_digest = previous_event_digest.to_owned();
3520    let event_digest = event_digest.to_owned();
3521    let mutation = mutation.clone();
3522    submit_database_write("apply_projection_event", move |connection| {
3523        apply_projection_page_with(connection, &session_id, |page| {
3524            page.apply(
3525                event_ordinal,
3526                &previous_event_digest,
3527                &event_digest,
3528                &mutation,
3529            )
3530        })
3531    })
3532}
3533
3534#[cfg(test)]
3535fn apply_projection_event_to(
3536    path: &Path,
3537    session_id: &str,
3538    event_ordinal: u64,
3539    previous_event_digest: &str,
3540    event_digest: &str,
3541    mutation: &MaterializedSessionMutation,
3542) -> Result<ProjectionApplyOutcome> {
3543    apply_projection_page_to(path, session_id, |page| {
3544        page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
3545    })
3546}
3547
3548/// Advance the persisted detach/read receipt monotonically. A receipt cannot
3549/// acknowledge an event the controller projection has not durably applied.
3550pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
3551    let session_id = session_id.to_owned();
3552    submit_database_write("advance_viewed_through_event_ordinal", move |_| {
3553        advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
3554    })
3555}
3556
3557fn advance_viewed_through_event_ordinal_to(
3558    path: &Path,
3559    session_id: &str,
3560    through: u64,
3561) -> Result<u64> {
3562    let mut connection = open(path)?;
3563    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3564    let applied = tx
3565        .query_row(
3566            "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
3567            [session_id],
3568            |row| row.get::<_, u64>(0),
3569        )
3570        .optional()?
3571        .with_context(|| format!("unknown session {session_id}"))?;
3572    if through > applied {
3573        bail!(
3574            "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
3575        );
3576    }
3577    tx.execute(
3578        "UPDATE sessions
3579         SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
3580         WHERE session_id = ?1",
3581        params![session_id, through],
3582    )?;
3583    let receipt = tx.query_row(
3584        "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
3585        [session_id],
3586        |row| row.get::<_, u64>(0),
3587    )?;
3588    tx.commit()?;
3589    Ok(receipt)
3590}
3591
3592/// Overwrite the unsent chat input carried across a detach. Unlike the read
3593/// receipt this is not monotonic: a draft can shrink, and an empty string
3594/// clears it.
3595pub fn set_session_draft_input(session_id: &str, draft: &str) -> Result<()> {
3596    let session_id = session_id.to_owned();
3597    let draft = draft.to_owned();
3598    submit_database_write("set_session_draft_input", move |_| {
3599        set_session_draft_input_at(&database_path(), &session_id, &draft)
3600    })
3601}
3602
3603fn set_session_draft_input_at(path: &Path, session_id: &str, draft: &str) -> Result<()> {
3604    let mut connection = open(path)?;
3605    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3606    let updated = tx.execute(
3607        "UPDATE sessions SET draft_input = ?2 WHERE session_id = ?1",
3608        params![session_id, draft],
3609    )?;
3610    ensure!(updated == 1, "unknown session {session_id}");
3611    tx.commit()?;
3612    Ok(())
3613}
3614
3615/// Retire a submitted shared draft without erasing a newer client's edit.
3616pub fn clear_session_draft_input_if_matches(session_id: &str, expected: &str) -> Result<()> {
3617    let session_id = session_id.to_owned();
3618    let expected = expected.to_owned();
3619    submit_database_write("clear_session_draft_input_if_matches", move |connection| {
3620        connection.execute(
3621            "UPDATE sessions SET draft_input = '' WHERE session_id = ?1 AND draft_input = ?2",
3622            params![session_id, expected],
3623        )?;
3624        Ok(())
3625    })
3626}
3627
3628/// Atomically apply the controller's MRU policy for newly used mount sources.
3629pub fn remember_mount_sources(host: &str, mounts: &[AdditionalMount]) -> Result<()> {
3630    if mounts.is_empty() {
3631        return Ok(());
3632    }
3633    let host = host.to_owned();
3634    let sources = mounts
3635        .iter()
3636        .map(|mount| mount.source.clone())
3637        .collect::<Vec<_>>();
3638    submit_database_write("remember_mount_sources", move |_| {
3639        remember_sources(&database_path(), &host, sources)
3640    })
3641}
3642
3643/// Replace one host's remembered mount sources with exactly this list, so the
3644/// dashboard can forget a directory the user no longer wants suggested.
3645/// What each workspace last chose for a second opinion.
3646///
3647/// The selection is remembered so a repeat review does not ask again, and it
3648/// is workspace scoped because a reviewer that suits one project rarely suits
3649/// the next. Values are validated against what the harness advertises now
3650/// before they are used, so a retired profile is harmless here.
3651pub fn reviewer_defaults() -> Result<mj_core::second_opinion::ReviewerDefaults> {
3652    reviewer_defaults_in(&database_path())
3653}
3654
3655fn reviewer_defaults_in(path: &Path) -> Result<mj_core::second_opinion::ReviewerDefaults> {
3656    let connection = open_reader(path)?;
3657    let mut statement = connection.prepare(
3658        "SELECT workspace_id, profile_id, model, effort FROM second_opinion_defaults
3659         ORDER BY workspace_id, profile_id, model",
3660    )?;
3661    let mut defaults = mj_core::second_opinion::ReviewerDefaults::default();
3662    let mut rows = statement.query([])?;
3663    while let Some(row) = rows.next()? {
3664        let workspace_id: String = row.get(0)?;
3665        let profile_id: String = row.get(1)?;
3666        let model: String = row.get(2)?;
3667        let effort: String = row.get(3)?;
3668        defaults.restore(&workspace_id, &profile_id, &model, &effort);
3669    }
3670    Ok(defaults)
3671}
3672
3673/// Record one confirmed selection.
3674pub fn remember_reviewer_selection(
3675    workspace_id: &str,
3676    selection: &mj_core::second_opinion::ReviewerSelection,
3677) -> Result<()> {
3678    let workspace_id = workspace_id.to_owned();
3679    let selection = selection.clone();
3680    submit_database_write("remember_reviewer_selection", move |_| {
3681        remember_reviewer_selection_in(&database_path(), &workspace_id, &selection)
3682    })
3683}
3684
3685fn remember_reviewer_selection_in(
3686    path: &Path,
3687    workspace_id: &str,
3688    selection: &mj_core::second_opinion::ReviewerSelection,
3689) -> Result<()> {
3690    ensure!(
3691        !workspace_id.trim().is_empty(),
3692        "second-opinion defaults need a workspace"
3693    );
3694    let mut connection = open(path)?;
3695    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3696    let (profile_id, model, effort) = selection.stored_values();
3697    // One profile is the workspace's reviewer at a time, so the rows for the
3698    // others stop being the remembered choice rather than accumulating.
3699    tx.execute(
3700        "DELETE FROM second_opinion_defaults WHERE workspace_id = ?1 AND profile_id <> ?2",
3701        params![workspace_id, profile_id],
3702    )?;
3703    tx.execute(
3704        "INSERT INTO second_opinion_defaults(workspace_id, profile_id, model, effort)
3705         VALUES (?1, ?2, ?3, ?4)
3706         ON CONFLICT(workspace_id, profile_id, model) DO UPDATE SET effort = excluded.effort",
3707        params![workspace_id, profile_id, model, effort],
3708    )?;
3709    tx.commit()?;
3710    Ok(())
3711}
3712
3713/// The open review for `session_id`, if the session has one.
3714pub fn active_review(session_id: &str) -> Result<Option<StoredReview>> {
3715    active_review_in(&database_path(), session_id)
3716}
3717
3718fn active_review_in(path: &Path, session_id: &str) -> Result<Option<StoredReview>> {
3719    let connection = open_reader(path)?;
3720    let row = connection
3721        .query_row(
3722            "SELECT workflow, generation, context_baseline, native_lost, reviewer_transcript
3723             FROM second_opinion_reviews WHERE session_id = ?1",
3724            [session_id],
3725            |row| {
3726                Ok((
3727                    row.get::<_, String>(0)?,
3728                    row.get::<_, i64>(1)?,
3729                    row.get::<_, i64>(2)?,
3730                    row.get::<_, i64>(3)?,
3731                    row.get::<_, String>(4)?,
3732                ))
3733            },
3734        )
3735        .optional()?;
3736    let Some((workflow, generation, baseline, native_lost, transcript)) = row else {
3737        return Ok(None);
3738    };
3739    Ok(Some(StoredReview {
3740        workflow: serde_json::from_str(&workflow).context("parse the stored review workflow")?,
3741        generation: u64::try_from(generation).unwrap_or_default(),
3742        context_baseline: u64::try_from(baseline).unwrap_or_default(),
3743        native_lost: native_lost != 0,
3744        reviewer_transcript: serde_json::from_str(&transcript)
3745            .context("parse the stored reviewer transcript")?,
3746    }))
3747}
3748
3749/// Records the open review, replacing any earlier one for this session.
3750pub fn save_active_review(session_id: &str, review: &StoredReview) -> Result<()> {
3751    let session_id = session_id.to_owned();
3752    let review = review.clone();
3753    submit_database_write("save_active_review", move |_| {
3754        save_active_review_in(&database_path(), &session_id, &review)
3755    })
3756}
3757
3758fn save_active_review_in(path: &Path, session_id: &str, review: &StoredReview) -> Result<()> {
3759    let connection = open(path)?;
3760    connection.execute(
3761        "INSERT INTO second_opinion_reviews(
3762             session_id, workflow, generation, context_baseline, native_lost,
3763             reviewer_transcript
3764         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3765         ON CONFLICT(session_id) DO UPDATE SET
3766             workflow = excluded.workflow,
3767             generation = excluded.generation,
3768             context_baseline = excluded.context_baseline,
3769             native_lost = excluded.native_lost,
3770             reviewer_transcript = excluded.reviewer_transcript",
3771        params![
3772            session_id,
3773            serde_json::to_string(&review.workflow)?,
3774            i64::try_from(review.generation).unwrap_or(i64::MAX),
3775            i64::try_from(review.context_baseline).unwrap_or(i64::MAX),
3776            i64::from(review.native_lost),
3777            serde_json::to_string(&review.reviewer_transcript)?,
3778        ],
3779    )?;
3780    Ok(())
3781}
3782
3783/// Forgets the open review once it has finished.
3784pub fn clear_active_review(session_id: &str) -> Result<()> {
3785    let session_id = session_id.to_owned();
3786    submit_database_write("clear_active_review", move |_| {
3787        clear_active_review_in(&database_path(), &session_id)
3788    })
3789}
3790
3791fn clear_active_review_in(path: &Path, session_id: &str) -> Result<()> {
3792    let connection = open(path)?;
3793    connection.execute(
3794        "DELETE FROM second_opinion_reviews WHERE session_id = ?1",
3795        [session_id],
3796    )?;
3797    Ok(())
3798}
3799
3800/// How far `session_id` has been reviewed, or a fresh state when it has never
3801/// been reviewed.
3802pub fn turn_review_state(session_id: &str) -> Result<TurnReviewState> {
3803    turn_review_state_in(&database_path(), session_id)
3804}
3805
3806fn turn_review_state_in(path: &Path, session_id: &str) -> Result<TurnReviewState> {
3807    let connection = open_reader(path)?;
3808    let row = connection
3809        .query_row(
3810            "SELECT baselines, reviewed_through_ordinal, prior_review, active,
3811                    pending_forward
3812             FROM turn_review_state WHERE session_id = ?1",
3813            [session_id],
3814            |row| {
3815                Ok((
3816                    row.get::<_, String>(0)?,
3817                    row.get::<_, i64>(1)?,
3818                    row.get::<_, Option<String>>(2)?,
3819                    row.get::<_, Option<String>>(3)?,
3820                    row.get::<_, Option<String>>(4)?,
3821                ))
3822            },
3823        )
3824        .optional()?;
3825    let Some((baselines, ordinal, prior, active, pending_forward)) = row else {
3826        return Ok(TurnReviewState::default());
3827    };
3828    Ok(TurnReviewState {
3829        baselines: serde_json::from_str(&baselines).context("parse the stored review baselines")?,
3830        reviewed_through_ordinal: u64::try_from(ordinal).unwrap_or_default(),
3831        prior_review: prior
3832            .map(|prior| serde_json::from_str(&prior))
3833            .transpose()
3834            .context("parse the stored prior review")?,
3835        active,
3836        pending_forward: pending_forward
3837            .map(|pending| serde_json::from_str(&pending))
3838            .transpose()
3839            .context("parse the stored pending review handoff")?,
3840    })
3841}
3842
3843/// Records how far a session has been reviewed.
3844pub fn save_turn_review_state(session_id: &str, state: &TurnReviewState) -> Result<()> {
3845    let session_id = session_id.to_owned();
3846    let state = state.clone();
3847    submit_database_write("save_turn_review_state", move |_| {
3848        save_turn_review_state_in(&database_path(), &session_id, &state)
3849    })
3850}
3851
3852fn save_turn_review_state_in(path: &Path, session_id: &str, state: &TurnReviewState) -> Result<()> {
3853    let connection = open(path)?;
3854    connection.execute(
3855        "INSERT INTO turn_review_state(
3856             session_id, baselines, reviewed_through_ordinal, prior_review, active,
3857             pending_forward
3858         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3859         ON CONFLICT(session_id) DO UPDATE SET
3860             baselines = excluded.baselines,
3861             reviewed_through_ordinal = excluded.reviewed_through_ordinal,
3862             prior_review = excluded.prior_review,
3863             active = excluded.active,
3864             pending_forward = excluded.pending_forward",
3865        params![
3866            session_id,
3867            serde_json::to_string(&state.baselines)?,
3868            i64::try_from(state.reviewed_through_ordinal).unwrap_or(i64::MAX),
3869            state
3870                .prior_review
3871                .as_ref()
3872                .map(serde_json::to_string)
3873                .transpose()?,
3874            state.active,
3875            state
3876                .pending_forward
3877                .as_ref()
3878                .map(serde_json::to_string)
3879                .transpose()?,
3880        ],
3881    )?;
3882    Ok(())
3883}
3884
3885/// Clears every session's in-flight review flag.
3886///
3887/// A review that was running when the daemon stopped is not resumed: the
3888/// baseline never advanced, so the next review covers the same change, and
3889/// half a multi-agent fan-out is not worth rebuilding. A pending corrective
3890/// handoff is returned as well so the host can retry its exact command id.
3891/// Baselines are deliberately left alone, which is what makes interruption
3892/// lossless. Returns the sessions whose review or handoff was interrupted.
3893pub fn clear_interrupted_turn_reviews() -> Result<Vec<String>> {
3894    submit_database_write("clear_interrupted_turn_reviews", move |_| {
3895        clear_interrupted_turn_reviews_in(&database_path())
3896    })
3897}
3898
3899fn clear_interrupted_turn_reviews_in(path: &Path) -> Result<Vec<String>> {
3900    let connection = open(path)?;
3901    let interrupted = {
3902        let mut statement = connection.prepare(
3903            "SELECT session_id FROM turn_review_state
3904                 WHERE active IS NOT NULL OR pending_forward IS NOT NULL",
3905        )?;
3906        let mut rows = statement.query([])?;
3907        let mut interrupted = Vec::new();
3908        while let Some(row) = rows.next()? {
3909            interrupted.push(row.get::<_, String>(0)?);
3910        }
3911        interrupted
3912    };
3913    connection.execute(
3914        "UPDATE turn_review_state SET active = NULL WHERE active IS NOT NULL",
3915        [],
3916    )?;
3917    Ok(interrupted)
3918}
3919
3920/// Marks this session's reviewer conversation as no longer continuable, and
3921/// reports the generation a future review must start under.
3922///
3923/// Losing the target takes the reviewer's native session with it. The
3924/// materialized transcript is kept for reference, but the next review is a new
3925/// conversation, so it runs under a new generation.
3926pub fn lose_reviewer_continuity(session_id: &str) -> Result<u64> {
3927    let session_id = session_id.to_owned();
3928    submit_database_write("lose_reviewer_continuity", move |_| {
3929        lose_reviewer_continuity_in(&database_path(), &session_id)
3930    })
3931}
3932
3933fn lose_reviewer_continuity_in(path: &Path, session_id: &str) -> Result<u64> {
3934    let Some(mut review) = active_review_in(path, session_id)? else {
3935        return Ok(0);
3936    };
3937    if review.native_lost {
3938        return Ok(review.generation);
3939    }
3940    review.native_lost = true;
3941    review.generation = review.generation.saturating_add(1);
3942    save_active_review_in(path, session_id, &review)?;
3943    Ok(review.generation)
3944}
3945
3946pub fn replace_mount_history(host: &str, sources: &[PathBuf]) -> Result<()> {
3947    let host = host.to_owned();
3948    let sources = sources.to_vec();
3949    submit_database_write("replace_mount_history", move |_| {
3950        replace_mount_history_in(&database_path(), &host, &sources)
3951    })
3952}
3953
3954fn replace_mount_history_in(path: &Path, host: &str, sources: &[PathBuf]) -> Result<()> {
3955    let mut connection = open(path)?;
3956    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3957    write_mount_history(&tx, host, sources)?;
3958    tx.commit()?;
3959    Ok(())
3960}
3961
3962fn write_mount_history(tx: &Transaction<'_>, host: &str, sources: &[PathBuf]) -> Result<()> {
3963    tx.execute("DELETE FROM mount_history WHERE host = ?1", [host])?;
3964    let mut written = Vec::new();
3965    for source in sources.iter().take(20) {
3966        if written.contains(source) {
3967            continue;
3968        }
3969        tx.execute(
3970            "INSERT INTO mount_history(host, source, ordinal) VALUES (?1, ?2, ?3)",
3971            params![host, path_to_blob(source), written.len() as i64],
3972        )?;
3973        written.push(source.clone());
3974    }
3975    Ok(())
3976}
3977
3978fn write_host_container_size(
3979    tx: &Transaction<'_>,
3980    host: &str,
3981    size: HostContainerSize,
3982) -> Result<()> {
3983    ensure!(!host.trim().is_empty(), "container size host is empty");
3984    let cpus = i64::try_from(size.cpus).context("container CPU count exceeds SQLite range")?;
3985    let memory =
3986        i64::try_from(size.memory_bytes).context("container memory exceeds SQLite range")?;
3987    ensure!(
3988        cpus > 0 && memory > 0,
3989        "container size values must be positive"
3990    );
3991    tx.execute(
3992        "INSERT INTO host_container_sizes(host, cpus, memory_bytes)
3993         VALUES (?1, ?2, ?3)
3994         ON CONFLICT(host) DO UPDATE SET cpus = excluded.cpus, memory_bytes = excluded.memory_bytes",
3995        params![host, cpus, memory],
3996    )?;
3997    Ok(())
3998}
3999
4000pub fn remember_project_directory(host: &str, directory: &Path) -> Result<()> {
4001    let host = format!("project:{host}");
4002    let directory = directory.to_path_buf();
4003    submit_database_write("remember_project_directory", move |_| {
4004        remember_sources(&database_path(), &host, std::iter::once(directory))
4005    })
4006}
4007
4008fn remember_sources(
4009    path: &Path,
4010    host: &str,
4011    new_sources: impl IntoIterator<Item = PathBuf>,
4012) -> Result<()> {
4013    let mut connection = open(path)?;
4014    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4015    let mut sources = {
4016        let mut statement =
4017            tx.prepare("SELECT source FROM mount_history WHERE host = ?1 ORDER BY ordinal")?;
4018        statement
4019            .query_map([host], |row| Ok(blob_to_path(row.get_ref(0)?.as_blob()?)))?
4020            .collect::<rusqlite::Result<Vec<_>>>()?
4021    };
4022    let additions = new_sources.into_iter().collect::<Vec<_>>();
4023    for source in additions.iter().rev() {
4024        sources.retain(|existing| existing != source);
4025        sources.insert(0, source.clone());
4026    }
4027    sources.truncate(20);
4028    write_mount_history(&tx, host, &sources)?;
4029    tx.commit()?;
4030    Ok(())
4031}
4032
4033pub fn record_recovery_success(
4034    session_id: &str,
4035    native_session_id: &str,
4036    checkpoint: &CheckpointMetadata,
4037) -> Result<()> {
4038    let session_id = session_id.to_owned();
4039    let native_session_id = native_session_id.to_owned();
4040    let checkpoint = checkpoint.clone();
4041    submit_database_write("record_recovery_success", move |_| {
4042        record_recovery_success_to(
4043            &database_path(),
4044            &session_id,
4045            &native_session_id,
4046            &checkpoint,
4047        )
4048    })
4049}
4050
4051fn record_recovery_success_to(
4052    path: &Path,
4053    session_id: &str,
4054    native_session_id: &str,
4055    checkpoint: &CheckpointMetadata,
4056) -> Result<()> {
4057    let mut connection = open(path)?;
4058    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4059    let changed = tx.execute(
4060        "UPDATE sessions
4061         SET native_session_id = ?2, last_checkpoint_error = NULL
4062         WHERE session_id = ?1",
4063        params![session_id, native_session_id],
4064    )?;
4065    if changed != 1 {
4066        bail!("unknown session {session_id}");
4067    }
4068    tx.execute(
4069        "INSERT INTO session_checkpoints(
4070             session_id, archive_path, sha256, created_at, event_frontier
4071         ) VALUES (?1,?2,?3,?4,?5)
4072         ON CONFLICT(session_id) DO UPDATE SET
4073             archive_path = excluded.archive_path,
4074             sha256 = excluded.sha256,
4075             created_at = excluded.created_at,
4076             event_frontier = excluded.event_frontier",
4077        params![
4078            session_id,
4079            path_to_blob(&checkpoint.archive_path),
4080            checkpoint.sha256,
4081            checkpoint.created_at,
4082            checkpoint.event_frontier,
4083        ],
4084    )?;
4085    tx.commit()?;
4086    Ok(())
4087}
4088
4089pub fn record_recovery_failure(session_id: &str, detail: &str) -> Result<()> {
4090    let session_id = session_id.to_owned();
4091    let detail = detail.to_owned();
4092    submit_database_write("record_recovery_failure", move |_| {
4093        record_recovery_failure_to(&database_path(), &session_id, &detail)
4094    })
4095}
4096
4097fn record_recovery_failure_to(path: &Path, session_id: &str, detail: &str) -> Result<()> {
4098    let connection = open(path)?;
4099    let changed = connection.execute(
4100        "UPDATE sessions SET last_checkpoint_error = ?2 WHERE session_id = ?1",
4101        params![session_id, detail],
4102    )?;
4103    if changed != 1 {
4104        bail!("unknown session {session_id}");
4105    }
4106    Ok(())
4107}
4108
4109pub fn save_state_to(path: &Path, state: &State) -> Result<()> {
4110    state.validate()?;
4111    let mut connection = open(path)?;
4112    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4113    let existing_contexts = existing_contexts(&tx)?;
4114    let existing_sessions = {
4115        let mut statement = tx.prepare("SELECT session_id FROM sessions")?;
4116        statement
4117            .query_map([], |row| row.get::<_, String>(0))?
4118            .collect::<rusqlite::Result<Vec<_>>>()?
4119    };
4120    tx.execute(
4121        "DELETE FROM subagent_sessions
4122         WHERE child_session_id NOT IN (SELECT session_id FROM sessions)
4123            OR parent_session_id NOT IN (SELECT session_id FROM sessions)",
4124        [],
4125    )?;
4126    let existing_subagents = {
4127        let mut statement =
4128            tx.prepare("SELECT child_session_id, parent_session_id FROM subagent_sessions")?;
4129        statement
4130            .query_map([], |row| {
4131                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4132            })?
4133            .collect::<rusqlite::Result<Vec<_>>>()?
4134    };
4135    for (child_id, parent_id) in existing_subagents {
4136        if !state.subagents.contains_key(&child_id)
4137            || !state.sessions.contains_key(&child_id)
4138            || !state.sessions.contains_key(&parent_id)
4139        {
4140            tx.execute(
4141                "DELETE FROM subagent_sessions WHERE child_session_id = ?1",
4142                [child_id],
4143            )?;
4144        }
4145    }
4146    for session_id in existing_sessions {
4147        if !state.sessions.contains_key(&session_id) {
4148            tx.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
4149        }
4150    }
4151    tx.execute("DELETE FROM mount_history", [])?;
4152    tx.execute("DELETE FROM host_container_sizes", [])?;
4153    for session in state.sessions.values() {
4154        if let Some((existing_bundle, existing_workspace)) = existing_contexts.get(&session.id) {
4155            ensure!(
4156                existing_bundle == &session.bundle_id,
4157                "session {} was already associated with bundle {}, not {}",
4158                session.id,
4159                existing_bundle,
4160                session.bundle_id
4161            );
4162            ensure!(
4163                existing_workspace == &session.workspace_id,
4164                "session {} was already associated with workspace {}, not {}",
4165                session.id,
4166                existing_workspace,
4167                session.workspace_id
4168            );
4169        }
4170        insert_session(&tx, session)?;
4171    }
4172    for subagent in state.subagents.values() {
4173        let record_json = serde_json::to_string(subagent)?;
4174        tx.execute(
4175            "INSERT INTO subagent_sessions(
4176                 child_session_id, parent_session_id, request_key, record_json
4177             ) VALUES (?1, ?2, ?3, ?4)
4178             ON CONFLICT(child_session_id) DO UPDATE SET
4179                 parent_session_id = excluded.parent_session_id,
4180                 request_key = excluded.request_key,
4181                 record_json = excluded.record_json",
4182            params![
4183                subagent.child_session_id,
4184                subagent.parent_session_id,
4185                subagent.request_key,
4186                record_json
4187            ],
4188        )?;
4189    }
4190    for (host, sources) in &state.mount_history {
4191        for (ordinal, source) in sources.iter().enumerate() {
4192            tx.execute(
4193                "INSERT INTO mount_history(host, source, ordinal) VALUES (?1, ?2, ?3)",
4194                params![host, path_to_blob(source), ordinal as i64],
4195            )?;
4196        }
4197    }
4198    for (host, size) in &state.container_sizes {
4199        write_host_container_size(&tx, host, *size)?;
4200    }
4201    tx.commit()?;
4202    Ok(())
4203}
4204
4205fn existing_contexts(tx: &Transaction<'_>) -> Result<BTreeMap<String, (String, String)>> {
4206    let mut statement =
4207        tx.prepare("SELECT session_id, bundle_id, workspace_id FROM session_contexts")?;
4208    let rows = statement.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?))))?;
4209    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
4210}
4211
4212fn session_exists(tx: &Transaction<'_>, session_id: &str) -> Result<bool> {
4213    Ok(tx
4214        .query_row(
4215            "SELECT 1 FROM sessions WHERE session_id = ?1",
4216            [session_id],
4217            |_| Ok(()),
4218        )
4219        .optional()?
4220        .is_some())
4221}
4222
4223fn write_materialized_session(
4224    tx: &Transaction<'_>,
4225    materialized: &MaterializedSession,
4226) -> Result<()> {
4227    let (execution, running_started_at_ms) = materialized_execution_columns(materialized.execution);
4228    tx.execute(
4229        "INSERT INTO materialized_sessions(
4230             session_id, applied_event_ordinal, applied_event_digest, execution_state,
4231             running_started_at_ms, session_title, configuration_json, last_activity_at_ms,
4232             pending_elicitations_json, active_turn_json, last_turn_outcome_json
4233         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
4234         ON CONFLICT(session_id) DO UPDATE SET
4235             applied_event_ordinal = excluded.applied_event_ordinal,
4236             applied_event_digest = excluded.applied_event_digest,
4237             execution_state = excluded.execution_state,
4238             running_started_at_ms = excluded.running_started_at_ms,
4239             session_title = excluded.session_title,
4240             configuration_json = excluded.configuration_json,
4241             last_activity_at_ms = excluded.last_activity_at_ms,
4242             pending_elicitations_json = excluded.pending_elicitations_json,
4243             active_turn_json = excluded.active_turn_json,
4244             last_turn_outcome_json = excluded.last_turn_outcome_json",
4245        params![
4246            materialized.session_id,
4247            materialized.applied_event_ordinal,
4248            materialized.applied_event_digest,
4249            execution,
4250            running_started_at_ms,
4251            materialized.session_title,
4252            serde_json::to_string(&materialized.configuration)?,
4253            materialized.last_activity_at_ms,
4254            serde_json::to_string(&materialized.pending_elicitations)?,
4255            materialized
4256                .active_turn
4257                .as_ref()
4258                .map(serde_json::to_string)
4259                .transpose()?,
4260            materialized
4261                .last_turn_outcome
4262                .as_ref()
4263                .map(serde_json::to_string)
4264                .transpose()?,
4265        ],
4266    )?;
4267    tx.execute(
4268        "DELETE FROM materialized_transcript_items WHERE session_id = ?1",
4269        [materialized.session_id.as_str()],
4270    )?;
4271    for item in &materialized.transcript {
4272        upsert_transcript_item(tx, &materialized.session_id, item)?;
4273    }
4274    replace_materialized_queue(tx, &materialized.session_id, &materialized.queued_prompts)?;
4275    Ok(())
4276}
4277
4278fn upsert_transcript_item(
4279    tx: &Transaction<'_>,
4280    session_id: &str,
4281    item: &TranscriptItem,
4282) -> Result<()> {
4283    let existing = tx
4284        .query_row(
4285            "SELECT position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms
4286             FROM materialized_transcript_items
4287             WHERE session_id = ?1 AND stable_id = ?2",
4288            params![session_id, item.stable_id],
4289            |row| {
4290                Ok((
4291                    row.get::<_, u64>(0)?,
4292                    row.get::<_, Option<u64>>(1)?,
4293                    row.get::<_, i64>(2)?,
4294                    row.get::<_, i64>(3)?,
4295                ))
4296            },
4297        )
4298        .optional()?;
4299    if let Some((position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms)) =
4300        existing
4301    {
4302        if position != item.position || created_at_ms != item.created_at_ms {
4303            return Err(ProjectionIntegrityError(format!(
4304                "transcript item {:?} changed immutable identity fields",
4305                item.stable_id
4306            ))
4307            .into());
4308        }
4309        if item.last_changed_at_ms < last_changed_at_ms {
4310            return Err(ProjectionIntegrityError(format!(
4311                "transcript item {:?} moved its changed timestamp backwards",
4312                item.stable_id
4313            ))
4314            .into());
4315        }
4316        if latest_content_event_ordinal.is_some_and(|existing| {
4317            item.latest_content_event_ordinal
4318                .is_none_or(|next| next < existing)
4319        }) {
4320            return Err(ProjectionIntegrityError(format!(
4321                "transcript item {:?} moved its latest content ordinal backwards",
4322                item.stable_id
4323            ))
4324            .into());
4325        }
4326        tx.execute(
4327            "UPDATE materialized_transcript_items
4328             SET latest_content_event_ordinal = ?3, last_changed_at_ms = ?4, body_json = ?5
4329             WHERE session_id = ?1 AND stable_id = ?2",
4330            params![
4331                session_id,
4332                item.stable_id,
4333                item.latest_content_event_ordinal,
4334                item.last_changed_at_ms,
4335                serde_json::to_string(&item.body)?,
4336            ],
4337        )?;
4338    } else {
4339        tx.execute(
4340            "INSERT INTO materialized_transcript_items(
4341                 session_id, stable_id, position, latest_content_event_ordinal,
4342                 created_at_ms, last_changed_at_ms, body_json
4343             ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
4344            params![
4345                session_id,
4346                item.stable_id,
4347                item.position,
4348                item.latest_content_event_ordinal,
4349                item.created_at_ms,
4350                item.last_changed_at_ms,
4351                serde_json::to_string(&item.body)?,
4352            ],
4353        )?;
4354    }
4355    Ok(())
4356}
4357
4358fn replace_materialized_queue(
4359    tx: &Transaction<'_>,
4360    session_id: &str,
4361    queued_prompts: &[MaterializedQueuedPrompt],
4362) -> Result<()> {
4363    let mut command_ids = BTreeSet::new();
4364    for prompt in queued_prompts {
4365        if prompt.command_id.trim().is_empty() {
4366            bail!("materialized prompt queue has an empty command id");
4367        }
4368        if !command_ids.insert(prompt.command_id.as_str()) {
4369            bail!(
4370                "materialized prompt queue contains duplicate command {:?}",
4371                prompt.command_id
4372            );
4373        }
4374    }
4375    tx.execute(
4376        "DELETE FROM materialized_queued_prompts WHERE session_id = ?1",
4377        [session_id],
4378    )?;
4379    for (ordinal, prompt) in queued_prompts.iter().enumerate() {
4380        tx.execute(
4381            "INSERT INTO materialized_queued_prompts(
4382                 session_id, ordinal, command_id, kind_json, content_json, queued_at_ms,
4383                 accepted_ordinal
4384             ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
4385            params![
4386                session_id,
4387                ordinal as i64,
4388                prompt.command_id,
4389                serde_json::to_string(&prompt.kind)?,
4390                serde_json::to_string(&prompt.content)?,
4391                prompt.queued_at_ms,
4392                prompt.accepted_ordinal,
4393            ],
4394        )?;
4395    }
4396    Ok(())
4397}
4398
4399fn materialized_execution_columns(
4400    execution: MaterializedExecutionState,
4401) -> (&'static str, Option<i64>) {
4402    match execution {
4403        MaterializedExecutionState::Idle => ("idle", None),
4404        MaterializedExecutionState::Running { started_at_ms } => ("running", Some(started_at_ms)),
4405        MaterializedExecutionState::Closing => ("closing", None),
4406        MaterializedExecutionState::Closed => ("closed", None),
4407    }
4408}
4409
4410fn parse_materialized_execution(
4411    execution: &str,
4412    running_started_at_ms: Option<i64>,
4413) -> Result<MaterializedExecutionState> {
4414    match (execution, running_started_at_ms) {
4415        ("idle", None) => Ok(MaterializedExecutionState::Idle),
4416        ("running", Some(started_at_ms)) => {
4417            Ok(MaterializedExecutionState::Running { started_at_ms })
4418        }
4419        ("closing", None) => Ok(MaterializedExecutionState::Closing),
4420        ("closed", None) => Ok(MaterializedExecutionState::Closed),
4421        _ => bail!("invalid materialized execution state {execution:?}"),
4422    }
4423}
4424
4425/// Write every field of a session, including the ones other writers own.
4426/// Only a flow that authors the whole record — creation, import, resume, or
4427/// orphan adoption — may use this.
4428fn insert_session(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4429    tx.execute(
4430        "INSERT INTO session_contexts(session_id, bundle_id, created_at, workspace_id)
4431         VALUES (?1, ?2, ?3, ?4)
4432         ON CONFLICT(session_id) DO NOTHING",
4433        params![
4434            session.id,
4435            session.bundle_id,
4436            session.created_at,
4437            session.workspace_id
4438        ],
4439    )?;
4440    let (stored_bundle, stored_workspace): (String, String) = tx.query_row(
4441        "SELECT bundle_id, workspace_id FROM session_contexts WHERE session_id = ?1",
4442        [session.id.as_str()],
4443        |row| Ok((row.get(0)?, row.get(1)?)),
4444    )?;
4445    ensure!(
4446        stored_bundle == session.bundle_id,
4447        "session {} belongs to bundle {}, not {}",
4448        session.id,
4449        stored_bundle,
4450        session.bundle_id
4451    );
4452    ensure!(
4453        stored_workspace == session.workspace_id,
4454        "session {} belongs to workspace {}, not {}",
4455        session.id,
4456        stored_workspace,
4457        session.workspace_id
4458    );
4459    tx.execute(
4460        "INSERT INTO sessions(
4461             session_id, title, harness_kind, last_profile, target_template_id, state,
4462             native_session_id, acp_session_title, session_title_override, updated_at,
4463             viewed_through_event_ordinal, last_error, resource_allocation,
4464             last_checkpoint_error, project_directory, managed_worktree,
4465             container_cpus, container_memory, archived, draft_input, create_managed_worktree,
4466             mjolnir_subagents
4467         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22)
4468         ON CONFLICT(session_id) DO UPDATE SET
4469             title = excluded.title,
4470             harness_kind = excluded.harness_kind,
4471             last_profile = excluded.last_profile,
4472             target_template_id = excluded.target_template_id,
4473             state = excluded.state,
4474             native_session_id = excluded.native_session_id,
4475             acp_session_title = excluded.acp_session_title,
4476             session_title_override = excluded.session_title_override,
4477             updated_at = excluded.updated_at,
4478             viewed_through_event_ordinal = max(
4479                 sessions.viewed_through_event_ordinal,
4480                 excluded.viewed_through_event_ordinal
4481             ),
4482             last_error = excluded.last_error,
4483             resource_allocation = excluded.resource_allocation,
4484             last_checkpoint_error = excluded.last_checkpoint_error,
4485             project_directory = excluded.project_directory,
4486             managed_worktree = excluded.managed_worktree,
4487             container_cpus = excluded.container_cpus,
4488             container_memory = excluded.container_memory,
4489             archived = excluded.archived,
4490             create_managed_worktree = excluded.create_managed_worktree,
4491             mjolnir_subagents = excluded.mjolnir_subagents",
4492        params![
4493            session.id,
4494            session.title,
4495            session.harness_kind.id(),
4496            session.last_profile,
4497            session.target_template_id,
4498            session_state_name(session.state),
4499            session.native_session_id,
4500            session.acp_session_title,
4501            session.session_title_override,
4502            session.updated_at,
4503            session.viewed_through_event_ordinal,
4504            session.last_error,
4505            session
4506                .resource_allocation
4507                .as_ref()
4508                .map(serde_json::to_string)
4509                .transpose()?,
4510            session.last_checkpoint_error,
4511            session
4512                .project_directory
4513                .as_ref()
4514                .map(|path| path_to_blob(path)),
4515            session
4516                .managed_worktree
4517                .as_ref()
4518                .map(serde_json::to_string)
4519                .transpose()?,
4520            session.container_cpus,
4521            session.container_memory,
4522            session.archived,
4523            session.draft_input,
4524            session.create_managed_worktree,
4525            session.mjolnir_subagents,
4526        ],
4527    )?;
4528    tx.execute(
4529        "INSERT INTO materialized_sessions(session_id) VALUES (?1)
4530         ON CONFLICT(session_id) DO NOTHING",
4531        [session.id.as_str()],
4532    )?;
4533    replace_targets(tx, session)?;
4534    tx.execute(
4535        "DELETE FROM session_mounts WHERE session_id = ?1",
4536        [session.id.as_str()],
4537    )?;
4538    for (ordinal, mount) in session.additional_mounts.iter().enumerate() {
4539        tx.execute(
4540            "INSERT INTO session_mounts(session_id, ordinal, source, destination, read_only)
4541             VALUES (?1, ?2, ?3, ?4, ?5)",
4542            params![
4543                session.id,
4544                ordinal as i64,
4545                path_to_blob(&mount.source),
4546                path_to_blob(&mount.destination),
4547                mount.read_only
4548            ],
4549        )?;
4550    }
4551    replace_checkpoint(tx, session)?;
4552    Ok(())
4553}
4554
4555/// Update the columns a lifecycle transition owns, plus the target locator
4556/// that provisioning and teardown maintain with them. The row must exist:
4557/// a transition never resurrects a session another writer deleted.
4558fn update_lifecycle_fields(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4559    let changed = tx.execute(
4560        // The detach ordinal only ever moves forward, so a transition that
4561        // started before a detach receipt cannot rewind it.
4562        "UPDATE sessions
4563         SET title = ?2,
4564             harness_kind = ?3,
4565             last_profile = ?4,
4566             target_template_id = ?5,
4567             state = ?6,
4568             updated_at = ?7,
4569             viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?8),
4570             last_error = ?9,
4571             resource_allocation = ?10,
4572             last_checkpoint_error = ?11,
4573             project_directory = ?12,
4574             managed_worktree = ?13
4575         WHERE session_id = ?1",
4576        params![
4577            session.id,
4578            session.title,
4579            session.harness_kind.id(),
4580            session.last_profile,
4581            session.target_template_id,
4582            session_state_name(session.state),
4583            session.updated_at,
4584            session.viewed_through_event_ordinal,
4585            session.last_error,
4586            session
4587                .resource_allocation
4588                .as_ref()
4589                .map(serde_json::to_string)
4590                .transpose()?,
4591            session.last_checkpoint_error,
4592            session
4593                .project_directory
4594                .as_ref()
4595                .map(|path| path_to_blob(path)),
4596            session
4597                .managed_worktree
4598                .as_ref()
4599                .map(serde_json::to_string)
4600                .transpose()?,
4601        ],
4602    )?;
4603    if changed != 1 {
4604        bail!("unknown session {}", session.id);
4605    }
4606    replace_targets(tx, session)
4607}
4608
4609fn replace_targets(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4610    tx.execute(
4611        "DELETE FROM session_targets WHERE session_id = ?1",
4612        [session.id.as_str()],
4613    )?;
4614    if let Some(target) = &session.target {
4615        insert_target(tx, &session.id, target)?;
4616    }
4617    Ok(())
4618}
4619
4620fn replace_checkpoint(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4621    tx.execute(
4622        "DELETE FROM session_checkpoints WHERE session_id = ?1",
4623        [session.id.as_str()],
4624    )?;
4625    if let Some(checkpoint) = &session.checkpoint {
4626        tx.execute(
4627            "INSERT INTO session_checkpoints(session_id, archive_path, sha256, created_at, event_frontier)
4628             VALUES (?1, ?2, ?3, ?4, ?5)",
4629            params![
4630                session.id,
4631                path_to_blob(&checkpoint.archive_path),
4632                checkpoint.sha256,
4633                checkpoint.created_at,
4634                checkpoint.event_frontier,
4635            ],
4636        )?;
4637    }
4638    Ok(())
4639}
4640
4641fn insert_target(tx: &Transaction<'_>, session_id: &str, target: &TargetLocator) -> Result<()> {
4642    let (kind, host, resource, address, workspace, worker_id, workspace_storage) = match target {
4643        TargetLocator::LocalBare { worker_root } => (
4644            "local-bare",
4645            None,
4646            None,
4647            None,
4648            Some(path_to_blob(worker_root)),
4649            None,
4650            None,
4651        ),
4652        TargetLocator::LocalPodman {
4653            container_id,
4654            workspace_storage,
4655        } => (
4656            "local-podman",
4657            None,
4658            Some(container_id.as_str()),
4659            None,
4660            None,
4661            None,
4662            Some(serde_json::to_string(workspace_storage)?),
4663        ),
4664        TargetLocator::LocalDocker { container_id } => (
4665            "local-docker",
4666            None,
4667            Some(container_id.as_str()),
4668            None,
4669            None,
4670            None,
4671            None,
4672        ),
4673        TargetLocator::SshDocker { host, container_id } => (
4674            "ssh-docker",
4675            Some(host.as_str()),
4676            Some(container_id.as_str()),
4677            None,
4678            None,
4679            None,
4680            None,
4681        ),
4682        TargetLocator::AppleContainer { container_id } => (
4683            "apple-container",
4684            None,
4685            Some(container_id.as_str()),
4686            None,
4687            None,
4688            None,
4689            None,
4690        ),
4691        TargetLocator::AwsEc2 {
4692            instance_id,
4693            address,
4694        } => (
4695            "aws-ec2",
4696            None,
4697            Some(instance_id.as_str()),
4698            address.as_deref(),
4699            None,
4700            None,
4701            None,
4702        ),
4703        TargetLocator::SshBare {
4704            host,
4705            workspace,
4706            worker_id,
4707        } => (
4708            "ssh-bare",
4709            Some(host.as_str()),
4710            None,
4711            None,
4712            Some(path_to_blob(workspace)),
4713            worker_id.as_deref(),
4714            None,
4715        ),
4716        TargetLocator::SshPodman {
4717            host,
4718            container_id,
4719            workspace_storage,
4720        } => (
4721            "ssh-podman",
4722            Some(host.as_str()),
4723            Some(container_id.as_str()),
4724            None,
4725            None,
4726            None,
4727            Some(serde_json::to_string(workspace_storage)?),
4728        ),
4729    };
4730    tx.execute(
4731        "INSERT INTO session_targets(session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage)
4732         VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
4733        params![session_id, kind, host, resource, address, workspace, worker_id, workspace_storage],
4734    )?;
4735    Ok(())
4736}
4737
4738fn load_targets(connection: &Connection, state: &mut State) -> Result<()> {
4739    let mut statement = connection.prepare(
4740        "SELECT session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage
4741         FROM session_targets",
4742    )?;
4743    let rows = statement.query_map([], |row| {
4744        let session_id: String = row.get(0)?;
4745        let kind: String = row.get(1)?;
4746        let host: Option<String> = row.get(2)?;
4747        let resource: Option<String> = row.get(3)?;
4748        let address: Option<String> = row.get(4)?;
4749        let workspace = row.get_ref(5)?.blob_or_null()?.map(blob_to_path);
4750        let worker_id: Option<String> = row.get(6)?;
4751        let workspace_storage = row
4752            .get::<_, Option<String>>(7)?
4753            .map(|serialized| {
4754                serde_json::from_str(&serialized).map_err(|error| {
4755                    rusqlite::Error::FromSqlConversionFailure(7, Type::Text, Box::new(error))
4756                })
4757            })
4758            .transpose()?
4759            .unwrap_or_default();
4760        let target = match kind.as_str() {
4761            "local-bare" => TargetLocator::LocalBare {
4762                worker_root: workspace.unwrap(),
4763            },
4764            "local-podman" => TargetLocator::LocalPodman {
4765                container_id: resource.unwrap(),
4766                workspace_storage,
4767            },
4768            "local-docker" => TargetLocator::LocalDocker {
4769                container_id: resource.unwrap(),
4770            },
4771            "apple-container" => TargetLocator::AppleContainer {
4772                container_id: resource.unwrap(),
4773            },
4774            "aws-ec2" => TargetLocator::AwsEc2 {
4775                instance_id: resource.unwrap(),
4776                address,
4777            },
4778            "ssh-bare" => TargetLocator::SshBare {
4779                host: host.unwrap(),
4780                workspace: workspace.unwrap(),
4781                worker_id,
4782            },
4783            "ssh-docker" => TargetLocator::SshDocker {
4784                host: host.unwrap(),
4785                container_id: resource.unwrap(),
4786            },
4787            "ssh-podman" => TargetLocator::SshPodman {
4788                host: host.unwrap(),
4789                container_id: resource.unwrap(),
4790                workspace_storage,
4791            },
4792            _ => unreachable!("target kind constrained by schema"),
4793        };
4794        Ok((session_id, target))
4795    })?;
4796    for row in rows {
4797        let (session_id, target) = row?;
4798        // A session skipped for an unsupported harness has no entry to attach
4799        // its target, mounts, or checkpoint to.
4800        if let Some(session) = state.sessions.get_mut(&session_id) {
4801            session.target = Some(target);
4802        }
4803    }
4804    Ok(())
4805}
4806
4807fn load_mounts(connection: &Connection, state: &mut State) -> Result<()> {
4808    let mut statement = connection.prepare(
4809        "SELECT session_id, source, destination, read_only
4810         FROM session_mounts ORDER BY session_id, ordinal",
4811    )?;
4812    let rows = statement.query_map([], |row| {
4813        Ok((
4814            row.get::<_, String>(0)?,
4815            AdditionalMount {
4816                source: blob_to_path(row.get_ref(1)?.as_blob()?),
4817                destination: blob_to_path(row.get_ref(2)?.as_blob()?),
4818                read_only: row.get(3)?,
4819            },
4820        ))
4821    })?;
4822    for row in rows {
4823        let (session_id, mount) = row?;
4824        if let Some(session) = state.sessions.get_mut(&session_id) {
4825            session.additional_mounts.push(mount);
4826        }
4827    }
4828    Ok(())
4829}
4830
4831fn load_checkpoints(connection: &Connection, state: &mut State) -> Result<()> {
4832    let mut statement = connection.prepare(
4833        "SELECT session_id, archive_path, sha256, created_at, event_frontier FROM session_checkpoints",
4834    )?;
4835    let rows = statement.query_map([], |row| {
4836        Ok((
4837            row.get::<_, String>(0)?,
4838            CheckpointMetadata {
4839                archive_path: blob_to_path(row.get_ref(1)?.as_blob()?),
4840                sha256: row.get(2)?,
4841                created_at: row.get(3)?,
4842                event_frontier: row.get(4)?,
4843            },
4844        ))
4845    })?;
4846    for row in rows {
4847        let (session_id, checkpoint) = row?;
4848        if let Some(session) = state.sessions.get_mut(&session_id) {
4849            session.checkpoint = Some(checkpoint);
4850        }
4851    }
4852    Ok(())
4853}
4854
4855/// Re-associate a session with another project bundle.
4856///
4857/// A session's bundle is otherwise fixed, because prompt history is grouped by
4858/// it. Resume calls this when it converts a session between its raw and bundle
4859/// representations: the project is the same, so its history follows it, and
4860/// only the name Hel files it under changes.
4861pub fn rebind_session_bundle(session_id: &str, bundle_id: &str) -> Result<()> {
4862    let session_id = session_id.to_owned();
4863    let bundle_id = bundle_id.to_owned();
4864    submit_database_write("rebind_session_bundle", move |_| {
4865        rebind_session_bundle_to(&database_path(), &session_id, &bundle_id)
4866    })
4867}
4868
4869fn rebind_session_bundle_to(path: &Path, session_id: &str, bundle_id: &str) -> Result<()> {
4870    let mut connection = open(path)?;
4871    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4872    let changed = tx.execute(
4873        "UPDATE session_contexts SET bundle_id = ?2 WHERE session_id = ?1",
4874        params![session_id, bundle_id],
4875    )?;
4876    if changed == 0 {
4877        tx.execute(
4878            "INSERT INTO session_contexts(session_id, bundle_id, created_at) VALUES (?1, ?2, ?3)",
4879            params![session_id, bundle_id, Utc::now().to_rfc3339()],
4880        )?;
4881    }
4882    tx.commit()?;
4883    Ok(())
4884}
4885
4886pub fn record_prompt(
4887    session_id: &str,
4888    bundle_id: &str,
4889    event_ordinal: u64,
4890    submitted_at: Option<&str>,
4891    text: &str,
4892) -> Result<()> {
4893    let session_id = session_id.to_owned();
4894    let bundle_id = bundle_id.to_owned();
4895    let submitted_at = submitted_at.map(str::to_owned);
4896    let text = text.to_owned();
4897    submit_database_write("record_prompt", move |_| {
4898        record_prompt_to(
4899            &database_path(),
4900            &session_id,
4901            &bundle_id,
4902            event_ordinal,
4903            submitted_at.as_deref(),
4904            &text,
4905        )
4906    })
4907}
4908
4909fn record_prompt_to(
4910    path: &Path,
4911    session_id: &str,
4912    bundle_id: &str,
4913    event_ordinal: u64,
4914    submitted_at: Option<&str>,
4915    text: &str,
4916) -> Result<()> {
4917    if text.trim().is_empty() {
4918        return Ok(());
4919    }
4920    let mut connection = open(path)?;
4921    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4922    tx.execute(
4923        "INSERT INTO session_contexts(session_id, bundle_id, created_at) VALUES (?1, ?2, ?3)
4924         ON CONFLICT(session_id) DO NOTHING",
4925        params![session_id, bundle_id, submitted_at.unwrap_or("unknown")],
4926    )?;
4927    let actual_bundle: String = tx.query_row(
4928        "SELECT bundle_id FROM session_contexts WHERE session_id = ?1",
4929        [session_id],
4930        |row| row.get(0),
4931    )?;
4932    if actual_bundle != bundle_id {
4933        bail!("session {session_id} belongs to bundle {actual_bundle}, not {bundle_id}");
4934    }
4935    tx.execute(
4936        "INSERT INTO prompt_history(session_id, event_ordinal, submitted_at, text)
4937         VALUES (?1, ?2, ?3, ?4)
4938         ON CONFLICT(session_id, event_ordinal) DO NOTHING",
4939        params![
4940            session_id,
4941            event_ordinal,
4942            submitted_at
4943                .map(str::to_owned)
4944                .unwrap_or_else(|| Utc::now().to_rfc3339()),
4945            text,
4946        ],
4947    )?;
4948    tx.commit()?;
4949    Ok(())
4950}
4951
4952pub fn search_prompts(
4953    session_id: &str,
4954    bundle_id: &str,
4955    scope: HistoryScope,
4956    query: &str,
4957) -> Result<Vec<PromptHistoryEntry>> {
4958    search_prompts_from(&database_path(), session_id, bundle_id, scope, query)
4959}
4960
4961/// Search prompt history, stopping at `limit` matches.
4962///
4963/// `search_prompts` pages through the whole table and stops only when a page
4964/// comes back short, which is fine for a terminal running it against a local
4965/// database and is not something an HTTP route may reach.
4966pub fn search_prompts_bounded(
4967    session_id: &str,
4968    bundle_id: &str,
4969    scope: HistoryScope,
4970    query: &str,
4971    limit: usize,
4972) -> Result<BoundedPromptHistory> {
4973    search_prompts_bounded_from(&database_path(), session_id, bundle_id, scope, query, limit)
4974}
4975
4976fn search_prompts_bounded_from(
4977    path: &Path,
4978    session_id: &str,
4979    bundle_id: &str,
4980    scope: HistoryScope,
4981    query: &str,
4982    limit: usize,
4983) -> Result<BoundedPromptHistory> {
4984    const PAGE_SIZE: usize = 256;
4985    /// How many rows the search may read before giving up on finding more.
4986    /// A query that matches nothing must not walk an unbounded history.
4987    const MAX_ROWS_SCANNED: usize = 4_096;
4988
4989    let connection = open_reader(path)?;
4990    let query = query.to_lowercase();
4991    let mut seen = std::collections::HashSet::new();
4992    let mut matches = Vec::new();
4993    let mut before = i64::MAX;
4994    let mut scanned = 0;
4995    let mut truncated = false;
4996    loop {
4997        let page = match scope {
4998            HistoryScope::Project => query_history_page(
4999                &connection,
5000                "SELECT h.history_id, h.session_id, h.text
5001                 FROM prompt_history h JOIN session_contexts c USING(session_id)
5002                 WHERE c.bundle_id = ?1 AND h.history_id < ?2
5003                 ORDER BY h.history_id DESC LIMIT ?3",
5004                params![bundle_id, before, PAGE_SIZE as i64],
5005            )?,
5006            HistoryScope::Session => query_history_page(
5007                &connection,
5008                "SELECT history_id, session_id, text FROM prompt_history
5009                 WHERE session_id = ?1 AND history_id < ?2
5010                 ORDER BY history_id DESC LIMIT ?3",
5011                params![session_id, before, PAGE_SIZE as i64],
5012            )?,
5013            HistoryScope::All => query_history_page(
5014                &connection,
5015                "SELECT history_id, session_id, text FROM prompt_history
5016                 WHERE history_id < ?1 ORDER BY history_id DESC LIMIT ?2",
5017                params![before, PAGE_SIZE as i64],
5018            )?,
5019        };
5020        let page_len = page.len();
5021        for entry in page {
5022            before = entry.id;
5023            scanned += 1;
5024            if entry.text.to_lowercase().contains(&query) && seen.insert(entry.text.clone()) {
5025                if matches.len() == limit {
5026                    truncated = true;
5027                    break;
5028                }
5029                matches.push(entry);
5030            }
5031        }
5032        if truncated || page_len < PAGE_SIZE {
5033            break;
5034        }
5035        if scanned >= MAX_ROWS_SCANNED {
5036            truncated = true;
5037            break;
5038        }
5039    }
5040    Ok(BoundedPromptHistory {
5041        entries: matches,
5042        truncated,
5043    })
5044}
5045
5046fn search_prompts_from(
5047    path: &Path,
5048    session_id: &str,
5049    bundle_id: &str,
5050    scope: HistoryScope,
5051    query: &str,
5052) -> Result<Vec<PromptHistoryEntry>> {
5053    const PAGE_SIZE: usize = 256;
5054    let connection = open_reader(path)?;
5055    let query = query.to_lowercase();
5056    let mut seen = std::collections::HashSet::new();
5057    let mut matches = Vec::new();
5058    let mut before = i64::MAX;
5059    loop {
5060        let page = match scope {
5061            HistoryScope::Project => query_history_page(
5062                &connection,
5063                "SELECT h.history_id, h.session_id, h.text
5064                 FROM prompt_history h JOIN session_contexts c USING(session_id)
5065                 WHERE c.bundle_id = ?1 AND h.history_id < ?2
5066                 ORDER BY h.history_id DESC LIMIT ?3",
5067                params![bundle_id, before, PAGE_SIZE as i64],
5068            )?,
5069            HistoryScope::Session => query_history_page(
5070                &connection,
5071                "SELECT history_id, session_id, text FROM prompt_history
5072                 WHERE session_id = ?1 AND history_id < ?2
5073                 ORDER BY history_id DESC LIMIT ?3",
5074                params![session_id, before, PAGE_SIZE as i64],
5075            )?,
5076            HistoryScope::All => query_history_page(
5077                &connection,
5078                "SELECT history_id, session_id, text FROM prompt_history
5079                 WHERE history_id < ?1 ORDER BY history_id DESC LIMIT ?2",
5080                params![before, PAGE_SIZE as i64],
5081            )?,
5082        };
5083        let page_len = page.len();
5084        for entry in page {
5085            before = entry.id;
5086            if entry.text.to_lowercase().contains(&query) && seen.insert(entry.text.clone()) {
5087                matches.push(entry);
5088            }
5089        }
5090        if page_len < PAGE_SIZE {
5091            break;
5092        }
5093    }
5094    Ok(matches)
5095}
5096
5097fn query_history_page(
5098    connection: &Connection,
5099    sql: &str,
5100    parameters: impl rusqlite::Params,
5101) -> Result<Vec<PromptHistoryEntry>> {
5102    let mut statement = connection.prepare_cached(sql)?;
5103    let rows = statement.query_map(parameters, |row| {
5104        Ok(PromptHistoryEntry {
5105            id: row.get(0)?,
5106            session_id: row.get(1)?,
5107            text: row.get(2)?,
5108        })
5109    })?;
5110    rows.collect::<rusqlite::Result<Vec<_>>>()
5111        .map_err(Into::into)
5112}
5113
5114pub fn migrate_legacy_state() -> Result<()> {
5115    let legacy = mj_core::state::state_path();
5116    let database = database_path();
5117    migrate_legacy_state_from(&legacy, &database)
5118}
5119
5120fn migrate_legacy_state_from(legacy: &Path, database: &Path) -> Result<()> {
5121    if !legacy.exists() {
5122        return Ok(());
5123    }
5124    // The database may exist after an interrupted migration. The legacy file
5125    // remains the authority until the import commits and this file is renamed.
5126    let mut state = State::load_json_from(legacy)?;
5127    // Legacy worker sequence numbers are not relay event ordinals. Carrying
5128    // them across the new compatibility floor could mark unseen relay events
5129    // as read.
5130    for session in state.sessions.values_mut() {
5131        session.viewed_through_event_ordinal = 0;
5132    }
5133    save_state_to(database, &state)?;
5134    let migrated = legacy.with_file_name("state.json.migrated-v1");
5135    fs::rename(legacy, &migrated)
5136        .with_context(|| format!("retain migrated Mjolnir state as {}", migrated.display()))?;
5137    Ok(())
5138}
5139
5140fn session_state_name(value: SessionState) -> &'static str {
5141    match value {
5142        SessionState::Provisioning => "provisioning",
5143        SessionState::Running => "running",
5144        SessionState::Disconnected => "disconnected",
5145        SessionState::Checkpointing => "checkpointing",
5146        SessionState::Closing => "closing",
5147        SessionState::Destroying => "destroying",
5148        SessionState::Stopped => "stopped",
5149        SessionState::Lost => "lost",
5150        SessionState::Error => "error",
5151        SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
5152    }
5153}
5154fn parse_session_state(value: &str) -> SessionState {
5155    match value {
5156        "provisioning" => SessionState::Provisioning,
5157        "running" => SessionState::Running,
5158        "disconnected" => SessionState::Disconnected,
5159        "checkpointing" => SessionState::Checkpointing,
5160        "closing" => SessionState::Closing,
5161        "destroying" => SessionState::Destroying,
5162        // Rows written before the verb was renamed still say "archived".
5163        "stopped" | "archived" => SessionState::Stopped,
5164        "lost" => SessionState::Lost,
5165        "error" => SessionState::Error,
5166        "destroyed-with-data-loss" => SessionState::DestroyedWithDataLoss,
5167        _ => unreachable!(),
5168    }
5169}
5170
5171#[cfg(unix)]
5172fn path_to_blob(path: &Path) -> Vec<u8> {
5173    use std::os::unix::ffi::OsStrExt;
5174    path.as_os_str().as_bytes().to_vec()
5175}
5176#[cfg(unix)]
5177fn blob_to_path(bytes: &[u8]) -> PathBuf {
5178    use std::os::unix::ffi::OsStrExt;
5179    PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
5180}
5181#[cfg(windows)]
5182fn path_to_blob(path: &Path) -> Vec<u8> {
5183    use std::os::windows::ffi::OsStrExt;
5184    path.as_os_str()
5185        .encode_wide()
5186        .flat_map(u16::to_le_bytes)
5187        .collect()
5188}
5189#[cfg(windows)]
5190fn blob_to_path(bytes: &[u8]) -> PathBuf {
5191    use std::os::windows::ffi::OsStringExt;
5192    let wide = bytes
5193        .as_chunks::<2>()
5194        .0
5195        .iter()
5196        .map(|b| u16::from_le_bytes([b[0], b[1]]))
5197        .collect::<Vec<_>>();
5198    PathBuf::from(std::ffi::OsString::from_wide(&wide))
5199}
5200
5201trait ValueRefExt<'a> {
5202    fn blob_or_null(self) -> rusqlite::Result<Option<&'a [u8]>>;
5203}
5204impl<'a> ValueRefExt<'a> for rusqlite::types::ValueRef<'a> {
5205    fn blob_or_null(self) -> rusqlite::Result<Option<&'a [u8]>> {
5206        match self {
5207            rusqlite::types::ValueRef::Null => Ok(None),
5208            value => Ok(Some(value.as_blob()?)),
5209        }
5210    }
5211}
5212
5213/// The exact result of a configuration command, once durably projected.
5214pub fn load_config_result(session_id: &str, command_id: &str) -> Result<Option<Option<String>>> {
5215    Ok(open_reader(&database_path())?
5216        .query_row(
5217            "SELECT error FROM api_config_results WHERE session_id = ?1 AND command_id = ?2",
5218            params![session_id, command_id],
5219            |row| row.get(0),
5220        )
5221        .optional()?)
5222}
5223
5224pub fn load_profile_config_cache(
5225    profile: &str,
5226    model: &str,
5227    fingerprint: &str,
5228) -> Result<Option<String>> {
5229    load_profile_config_cache_from(&database_path(), profile, model, fingerprint)
5230}
5231
5232pub(crate) fn load_profile_config_cache_from(
5233    path: &Path,
5234    profile: &str,
5235    model: &str,
5236    fingerprint: &str,
5237) -> Result<Option<String>> {
5238    Ok(open_reader(path)?.query_row(
5239        "SELECT body FROM profile_config_cache WHERE profile = ?1 AND model = ?2 AND fingerprint = ?3 AND observed_at > ?4",
5240        params![profile, model, fingerprint, Utc::now().timestamp() - 86400], |row| row.get(0),
5241    ).optional()?)
5242}
5243
5244pub fn save_profile_config_cache(
5245    profile: String,
5246    model: String,
5247    fingerprint: String,
5248    body: String,
5249) -> Result<()> {
5250    submit_database_write("save profile configuration cache", move |connection| {
5251        save_profile_config_cache_with(connection, &profile, &model, &fingerprint, &body)
5252    })
5253}
5254
5255/// Write one cache row into the store at `path`. The queued writer behind
5256/// [`save_profile_config_cache`] serves the live store; this names a store
5257/// directly so a caller can use an isolated one.
5258#[cfg(test)]
5259pub(crate) fn save_profile_config_cache_at(
5260    path: &Path,
5261    profile: &str,
5262    model: &str,
5263    fingerprint: &str,
5264    body: &str,
5265) -> Result<()> {
5266    save_profile_config_cache_with(&open(path)?, profile, model, fingerprint, body)
5267}
5268
5269fn save_profile_config_cache_with(
5270    connection: &Connection,
5271    profile: &str,
5272    model: &str,
5273    fingerprint: &str,
5274    body: &str,
5275) -> Result<()> {
5276    connection.execute("INSERT OR REPLACE INTO profile_config_cache(profile, model, fingerprint, observed_at, body) VALUES (?1, ?2, ?3, ?4, ?5)", params![profile, model, fingerprint, Utc::now().timestamp(), body])?;
5277    Ok(())
5278}
5279
5280#[cfg(test)]
5281mod tests;