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        Ok(SessionRecord {
1322            create_managed_worktree: row.get(23)?,
1323            mjolnir_subagents: row.get(24)?,
1324            workspace_id: row.get(22)?,
1325            archived: row.get(21)?,
1326            container_cpus: row.get(19)?,
1327            container_memory: row.get(20)?,
1328            id: row.get(0)?,
1329            title: row.get(1)?,
1330            harness_kind: row.get::<_, String>(2)?.parse().map_err(|error| {
1331                rusqlite::Error::FromSqlConversionFailure(
1332                    2,
1333                    rusqlite::types::Type::Text,
1334                    Box::<dyn std::error::Error + Send + Sync>::from(format!("{error:#}")),
1335                )
1336            })?,
1337            last_profile: row.get(3)?,
1338            bundle_id: row.get(4)?,
1339            project_directory: row.get_ref(16)?.blob_or_null()?.map(blob_to_path),
1340            managed_worktree: row
1341                .get::<_, Option<String>>(17)?
1342                .map(|json| serde_json::from_str::<ManagedWorktree>(&json))
1343                .transpose()
1344                .map_err(|error| {
1345                    rusqlite::Error::FromSqlConversionFailure(
1346                        17,
1347                        rusqlite::types::Type::Text,
1348                        Box::new(error),
1349                    )
1350                })?,
1351            target_template_id: row.get(5)?,
1352            resource_allocation: row
1353                .get::<_, Option<String>>(14)?
1354                .map(|json| serde_json::from_str::<SessionResourceAllocation>(&json))
1355                .transpose()
1356                .map_err(|error| {
1357                    rusqlite::Error::FromSqlConversionFailure(
1358                        14,
1359                        rusqlite::types::Type::Text,
1360                        Box::new(error),
1361                    )
1362                })?,
1363            additional_mounts: Vec::new(),
1364            state: parse_session_state(&row.get::<_, String>(6)?),
1365            target: None,
1366            native_session_id: row.get(7)?,
1367            acp_session_title: row
1368                .get::<_, Option<String>>(8)?
1369                .as_deref()
1370                .and_then(mj_core::state::normalize_session_title),
1371            session_title_override: row.get(9)?,
1372            created_at: row.get(10)?,
1373            updated_at: row.get(11)?,
1374            viewed_through_event_ordinal: row.get::<_, u64>(12)?,
1375            draft_input: row.get(18)?,
1376            last_error: row.get(13)?,
1377            last_checkpoint_error: row.get(15)?,
1378            checkpoint: None,
1379        })
1380    })?;
1381    for row in rows {
1382        let session = row?;
1383        state.sessions.insert(session.id.clone(), session);
1384    }
1385    let mut statement = connection.prepare(
1386        "SELECT child_session_id, record_json FROM subagent_sessions ORDER BY child_session_id",
1387    )?;
1388    let rows = statement.query_map([], |row| {
1389        let child_id = row.get::<_, String>(0)?;
1390        let json = row.get::<_, String>(1)?;
1391        let record = serde_json::from_str::<SubagentRecord>(&json).map_err(|error| {
1392            rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
1393        })?;
1394        Ok((child_id, record))
1395    })?;
1396    for row in rows {
1397        let (child_id, record) = row?;
1398        state.subagents.insert(child_id, record);
1399    }
1400    load_targets(&connection, &mut state)?;
1401    load_mounts(&connection, &mut state)?;
1402    load_checkpoints(&connection, &mut state)?;
1403    let mut statement =
1404        connection.prepare("SELECT host, source FROM mount_history ORDER BY host, ordinal")?;
1405    let rows = statement.query_map([], |row| {
1406        Ok((
1407            row.get::<_, String>(0)?,
1408            blob_to_path(row.get_ref(1)?.as_blob()?),
1409        ))
1410    })?;
1411    for row in rows {
1412        let (host, source) = row?;
1413        state.mount_history.entry(host).or_default().push(source);
1414    }
1415    let mut statement = connection
1416        .prepare("SELECT host, cpus, memory_bytes FROM host_container_sizes ORDER BY host")?;
1417    let rows = statement.query_map([], |row| {
1418        Ok((
1419            row.get::<_, String>(0)?,
1420            HostContainerSize {
1421                cpus: row.get::<_, i64>(1)? as u64,
1422                memory_bytes: row.get::<_, i64>(2)? as u64,
1423            },
1424        ))
1425    })?;
1426    for row in rows {
1427        let (host, size) = row?;
1428        state.container_sizes.insert(host, size);
1429    }
1430    state.validate()?;
1431    Ok(state)
1432}
1433
1434pub fn save_state(state: &State) -> Result<()> {
1435    let state = state.clone();
1436    submit_database_write("save_state", move |_| {
1437        save_state_to(&database_path(), &state)
1438    })
1439}
1440
1441/// Persist one operational session without rewriting unrelated controller
1442/// state. Dashboard lifecycle jobs use this path so independent jobs can
1443/// commit concurrently without restoring stale copies of other sessions.
1444pub fn save_session(session: &SessionRecord) -> Result<()> {
1445    let session = session.clone();
1446    submit_database_write("save_session", move |_| {
1447        save_session_to(&database_path(), &session)
1448    })
1449}
1450
1451/// Persist a borrowed-target child and its parent relationship atomically.
1452pub fn save_subagent_session(
1453    session: &SessionRecord,
1454    subagent: &mj_core::subagent::SubagentRecord,
1455) -> Result<()> {
1456    let session = session.clone();
1457    let subagent = subagent.clone();
1458    submit_database_write("save_subagent_session", move |_| {
1459        let mut connection = open(&database_path())?;
1460        let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1461        insert_session(&tx, &session)?;
1462        tx.execute(
1463            "INSERT INTO subagent_sessions(
1464                 child_session_id, parent_session_id, request_key, record_json
1465             ) VALUES (?1, ?2, ?3, ?4)",
1466            params![
1467                subagent.child_session_id,
1468                subagent.parent_session_id,
1469                subagent.request_key,
1470                serde_json::to_string(&subagent)?,
1471            ],
1472        )?;
1473        tx.commit()?;
1474        Ok(())
1475    })
1476}
1477
1478/// Record the child turn already reported to its parent.
1479pub fn mark_subagent_turn_delivered(child_session_id: &str, turn: u64) -> Result<()> {
1480    let child_session_id = child_session_id.to_owned();
1481    submit_database_write("mark_subagent_turn_delivered", move |_| {
1482        let mut relation = load_subagent(&child_session_id)?
1483            .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
1484        relation.delivered_turn = Some(turn);
1485        let json = serde_json::to_string(&relation)?;
1486        let connection = open(&database_path())?;
1487        connection.execute(
1488            "UPDATE subagent_sessions SET record_json = ?2 WHERE child_session_id = ?1",
1489            params![child_session_id, json],
1490        )?;
1491        Ok(())
1492    })
1493}
1494
1495pub fn load_subagent(child_session_id: &str) -> Result<Option<mj_core::subagent::SubagentRecord>> {
1496    let connection = open_reader(&database_path())?;
1497    connection
1498        .query_row(
1499            "SELECT record_json FROM subagent_sessions WHERE child_session_id = ?1",
1500            [child_session_id],
1501            |row| row.get::<_, String>(0),
1502        )
1503        .optional()?
1504        .map(|json| serde_json::from_str(&json).context("decode sub-agent record"))
1505        .transpose()
1506}
1507
1508pub fn list_subagents(parent_session_id: &str) -> Result<Vec<mj_core::subagent::SubagentRecord>> {
1509    let connection = open_reader(&database_path())?;
1510    let mut statement = connection.prepare(
1511        "SELECT record_json FROM subagent_sessions
1512         WHERE parent_session_id = ?1 ORDER BY rowid",
1513    )?;
1514    statement
1515        .query_map([parent_session_id], |row| row.get::<_, String>(0))?
1516        .map(|row| serde_json::from_str(&row?).context("decode sub-agent record"))
1517        .collect()
1518}
1519
1520pub fn lookup_subagent_request(
1521    parent_session_id: &str,
1522    request_key: &str,
1523) -> Result<Option<mj_core::subagent::SubagentRecord>> {
1524    let connection = open_reader(&database_path())?;
1525    connection
1526        .query_row(
1527            "SELECT record_json FROM subagent_sessions
1528             WHERE parent_session_id = ?1 AND request_key = ?2",
1529            params![parent_session_id, request_key],
1530            |row| row.get::<_, String>(0),
1531        )
1532        .optional()?
1533        .map(|json| serde_json::from_str(&json).context("decode sub-agent record"))
1534        .transpose()
1535}
1536
1537/// Persist a session and the container size it most recently launched on its
1538/// host in one transaction.
1539pub fn save_session_with_container_size(
1540    session: &SessionRecord,
1541    host: &str,
1542    size: HostContainerSize,
1543) -> Result<()> {
1544    let session = session.clone();
1545    let host = host.to_owned();
1546    submit_database_write("save_session_with_container_size", move |_| {
1547        save_session_with_container_size_to(&database_path(), &session, Some((&host, size)))
1548    })
1549}
1550
1551/// Update only the fields a lifecycle transition owns on a session that
1552/// already exists. Everything else — display titles, checkpoints, container
1553/// settings, and attached directories — stays with its own writer.
1554pub fn save_lifecycle_session(session: &SessionRecord) -> Result<()> {
1555    let session = session.clone();
1556    submit_database_write("save_lifecycle_session", move |_| {
1557        save_lifecycle_session_to(&database_path(), &session)
1558    })
1559}
1560
1561/// Install a lifecycle transition together with the checkpoint it just
1562/// verified and the harness session id that produced it.
1563pub fn save_checkpointed_session(session: &SessionRecord) -> Result<()> {
1564    let session = session.clone();
1565    submit_database_write("save_checkpointed_session", move |_| {
1566        save_checkpointed_session_to(&database_path(), &session)
1567    })
1568}
1569
1570/// Recover lifecycle rows stranded by a process exit during checkpoint
1571/// creation. This must be called once by the top-level controller process
1572/// while it owns the controller-store guard, not by per-operation reloads.
1573pub fn recover_interrupted_checkpointing_sessions(updated_at: &str) -> Result<usize> {
1574    let updated_at = updated_at.to_owned();
1575    submit_database_write("recover_interrupted_checkpointing_sessions", move |_| {
1576        recover_interrupted_checkpointing_sessions_to(&database_path(), &updated_at)
1577    })
1578}
1579
1580/// Change only the user-owned display name. This avoids writing a stale
1581/// SessionRecord over independently committed checkpoint or relay metadata.
1582pub fn set_session_title_override(session_id: &str, title: &str, updated_at: &str) -> Result<()> {
1583    let session_id = session_id.to_owned();
1584    let title = title.to_owned();
1585    let updated_at = updated_at.to_owned();
1586    submit_database_write("set_session_title_override", move |_| {
1587        set_session_title_override_to(&database_path(), &session_id, &title, &updated_at)
1588    })
1589}
1590
1591/// Rewrite a configured profile id in every persisted session in one SQLite
1592/// transaction. Configuration is stored separately, so the controller owns
1593/// coordinating this update with the matching config-map rename.
1594pub fn rename_profile_references(old_id: &str, new_id: &str) -> Result<usize> {
1595    rename_session_reference("last_profile", old_id, new_id)
1596}
1597
1598/// Rewrite a configured target id in every persisted session in one SQLite
1599/// transaction.
1600pub fn rename_target_references(old_id: &str, new_id: &str) -> Result<usize> {
1601    rename_session_reference("target_template_id", old_id, new_id)
1602}
1603
1604fn rename_session_reference(column: &'static str, old_id: &str, new_id: &str) -> Result<usize> {
1605    ensure!(
1606        matches!(column, "last_profile" | "target_template_id"),
1607        "unsupported session reference column"
1608    );
1609    let old_id = old_id.to_owned();
1610    let new_id = new_id.to_owned();
1611    submit_database_write("rename_session_reference", move |_| {
1612        rename_session_reference_at(&database_path(), column, &old_id, &new_id)
1613    })
1614}
1615
1616fn rename_session_reference_at(
1617    path: &Path,
1618    column: &str,
1619    old_id: &str,
1620    new_id: &str,
1621) -> Result<usize> {
1622    let mut connection = open(path)?;
1623    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1624    let changed = tx.execute(
1625        &format!("UPDATE sessions SET {column} = ?2 WHERE {column} = ?1"),
1626        params![old_id, new_id],
1627    )?;
1628    tx.commit()?;
1629    Ok(changed)
1630}
1631
1632/// Change only whether the resume dialog hides this session. Archiving is a
1633/// display choice, so it has its own writer and never rewrites lifecycle,
1634/// checkpoint, or title columns another task owns.
1635pub fn set_session_archived(session_id: &str, archived: bool) -> Result<()> {
1636    let session_id = session_id.to_owned();
1637    submit_database_write("set_session_archived", move |_| {
1638        set_session_archived_to(&database_path(), &session_id, archived)
1639    })
1640}
1641
1642/// Record that the managed target of an otherwise live session is definitively
1643/// gone. A verified checkpoint keeps the session recoverable as an error on the
1644/// dashboard; without one, the session is lost. The state predicate keeps a
1645/// late poll result from overwriting a concurrent lifecycle transition.
1646pub fn mark_session_target_missing(
1647    session_id: &str,
1648    detail: &str,
1649    updated_at: &str,
1650) -> Result<Option<SessionState>> {
1651    let session_id = session_id.to_owned();
1652    let detail = detail.to_owned();
1653    let updated_at = updated_at.to_owned();
1654    submit_database_write("mark_session_target_missing", move |_| {
1655        mark_session_target_missing_to(&database_path(), &session_id, &detail, &updated_at)
1656    })
1657}
1658
1659fn mark_session_target_missing_to(
1660    path: &Path,
1661    session_id: &str,
1662    detail: &str,
1663    updated_at: &str,
1664) -> Result<Option<SessionState>> {
1665    mark_session_target_missing_if_current_to(path, session_id, detail, updated_at, None)
1666}
1667
1668/// Record a definitive worker failure only while the observed session record
1669/// is still current. A delayed background write must not invalidate a resume.
1670pub fn mark_session_target_missing_if_current(
1671    session_id: &str,
1672    detail: &str,
1673    updated_at: &str,
1674    observed_updated_at: &str,
1675) -> Result<Option<SessionState>> {
1676    let session_id = session_id.to_owned();
1677    let detail = detail.to_owned();
1678    let updated_at = updated_at.to_owned();
1679    let observed_updated_at = observed_updated_at.to_owned();
1680    submit_database_write("mark_session_target_missing_if_current", move |_| {
1681        mark_session_target_missing_if_current_to(
1682            &database_path(),
1683            &session_id,
1684            &detail,
1685            &updated_at,
1686            Some(&observed_updated_at),
1687        )
1688    })
1689}
1690
1691fn mark_session_target_missing_if_current_to(
1692    path: &Path,
1693    session_id: &str,
1694    detail: &str,
1695    updated_at: &str,
1696    observed_updated_at: Option<&str>,
1697) -> Result<Option<SessionState>> {
1698    let mut connection = open(path)?;
1699    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1700    let changed = tx.execute(
1701        "UPDATE sessions
1702         SET state = CASE
1703                 WHEN EXISTS(
1704                     SELECT 1 FROM session_checkpoints
1705                     WHERE session_checkpoints.session_id = sessions.session_id
1706                 ) THEN 'error'
1707                 ELSE 'lost'
1708             END,
1709             last_error = ?2,
1710             updated_at = ?3
1711         WHERE session_id = ?1
1712           AND (?4 IS NULL OR updated_at = ?4)
1713           AND state IN ('provisioning', 'running', 'disconnected', 'error')",
1714        params![session_id, detail, updated_at, observed_updated_at],
1715    )?;
1716    ensure!(changed <= 1, "updated {changed} sessions for {session_id}");
1717    let state = if changed == 1 {
1718        let stored: String = tx.query_row(
1719            "SELECT state FROM sessions WHERE session_id = ?1",
1720            [session_id],
1721            |row| row.get(0),
1722        )?;
1723        Some(parse_session_state(&stored))
1724    } else {
1725        None
1726    };
1727    tx.commit()?;
1728    Ok(state)
1729}
1730
1731fn set_session_archived_to(path: &Path, session_id: &str, archived: bool) -> Result<()> {
1732    let connection = open(path)?;
1733    let changed = connection.execute(
1734        "UPDATE sessions SET archived = ?2 WHERE session_id = ?1",
1735        params![session_id, archived],
1736    )?;
1737    if changed != 1 {
1738        bail!("unknown session {session_id}");
1739    }
1740    Ok(())
1741}
1742
1743/// Native sessions the resume dialog hides. Hel never writes into a harness
1744/// home, so the hidden set lives here instead of in the harness's own store.
1745pub fn hidden_native_sessions() -> Result<BTreeSet<(mj_core::config::HarnessKind, String)>> {
1746    hidden_native_sessions_from(&database_path())
1747}
1748
1749fn hidden_native_sessions_from(
1750    path: &Path,
1751) -> Result<BTreeSet<(mj_core::config::HarnessKind, String)>> {
1752    let connection = open_reader(path)?;
1753    let mut statement =
1754        connection.prepare("SELECT harness_kind, native_session_id FROM hidden_native_sessions")?;
1755    let rows = statement.query_map([], |row| {
1756        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1757    })?;
1758    let mut hidden = BTreeSet::new();
1759    for row in rows {
1760        let (harness, native_session_id) = row?;
1761        let harness = harness
1762            .parse::<mj_core::config::HarnessKind>()
1763            .with_context(|| format!("unknown harness {harness:?} in the hidden session set"))?;
1764        hidden.insert((harness, native_session_id));
1765    }
1766    Ok(hidden)
1767}
1768
1769/// Hide or reveal one native session in the resume dialog.
1770pub fn set_native_session_hidden(
1771    harness: mj_core::config::HarnessKind,
1772    native_session_id: &str,
1773    hidden: bool,
1774) -> Result<()> {
1775    let native_session_id = native_session_id.to_owned();
1776    submit_database_write("set_native_session_hidden", move |_| {
1777        set_native_session_hidden_to(&database_path(), harness, &native_session_id, hidden)
1778    })
1779}
1780
1781fn set_native_session_hidden_to(
1782    path: &Path,
1783    harness: mj_core::config::HarnessKind,
1784    native_session_id: &str,
1785    hidden: bool,
1786) -> Result<()> {
1787    if native_session_id.trim().is_empty() {
1788        bail!("native session id is empty");
1789    }
1790    let connection = open(path)?;
1791    if hidden {
1792        connection.execute(
1793            "INSERT INTO hidden_native_sessions(harness_kind, native_session_id, hidden_at)
1794             VALUES (?1, ?2, ?3)
1795             ON CONFLICT(harness_kind, native_session_id) DO NOTHING",
1796            params![harness.id(), native_session_id, Utc::now().to_rfc3339()],
1797        )?;
1798    } else {
1799        connection.execute(
1800            "DELETE FROM hidden_native_sessions
1801             WHERE harness_kind = ?1 AND native_session_id = ?2",
1802            params![harness.id(), native_session_id],
1803        )?;
1804    }
1805    Ok(())
1806}
1807
1808/// Change only the per-session container provisioning inputs: the size
1809/// overrides and the attached directories. Everything else the session row
1810/// owns is left to its own writer.
1811pub fn set_session_container_settings(
1812    session_id: &str,
1813    cpus: Option<&str>,
1814    memory: Option<&str>,
1815    mounts: &[AdditionalMount],
1816    updated_at: &str,
1817) -> Result<()> {
1818    let session_id = session_id.to_owned();
1819    let cpus = cpus.map(str::to_owned);
1820    let memory = memory.map(str::to_owned);
1821    let mounts = mounts.to_vec();
1822    let updated_at = updated_at.to_owned();
1823    submit_database_write("set_session_container_settings", move |_| {
1824        set_session_container_settings_to(
1825            &database_path(),
1826            &session_id,
1827            cpus.as_deref(),
1828            memory.as_deref(),
1829            &mounts,
1830            &updated_at,
1831        )
1832    })
1833}
1834
1835fn set_session_container_settings_to(
1836    path: &Path,
1837    session_id: &str,
1838    cpus: Option<&str>,
1839    memory: Option<&str>,
1840    mounts: &[AdditionalMount],
1841    updated_at: &str,
1842) -> Result<()> {
1843    if updated_at.trim().is_empty() {
1844        bail!("session update timestamp is empty");
1845    }
1846    crate::targets::validate_additional_mounts(mounts)?;
1847    let mut connection = open(path)?;
1848    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1849    let changed = tx.execute(
1850        "UPDATE sessions
1851         SET container_cpus = ?2, container_memory = ?3, updated_at = ?4
1852         WHERE session_id = ?1",
1853        params![session_id, cpus, memory, updated_at],
1854    )?;
1855    if changed != 1 {
1856        bail!("unknown session {session_id}");
1857    }
1858    tx.execute(
1859        "DELETE FROM session_mounts WHERE session_id = ?1",
1860        [session_id],
1861    )?;
1862    for (ordinal, mount) in mounts.iter().enumerate() {
1863        tx.execute(
1864            "INSERT INTO session_mounts(session_id, ordinal, source, destination, read_only)
1865             VALUES (?1, ?2, ?3, ?4, ?5)",
1866            params![
1867                session_id,
1868                ordinal as i64,
1869                path_to_blob(&mount.source),
1870                path_to_blob(&mount.destination),
1871                mount.read_only
1872            ],
1873        )?;
1874    }
1875    tx.commit()?;
1876    Ok(())
1877}
1878
1879fn set_session_title_override_to(
1880    path: &Path,
1881    session_id: &str,
1882    title: &str,
1883    updated_at: &str,
1884) -> Result<()> {
1885    if title.trim().is_empty() {
1886        bail!("session title is empty");
1887    }
1888    if updated_at.trim().is_empty() {
1889        bail!("session update timestamp is empty");
1890    }
1891    let connection = open(path)?;
1892    let changed = connection.execute(
1893        "UPDATE sessions
1894         SET session_title_override = ?2, updated_at = ?3
1895         WHERE session_id = ?1",
1896        params![session_id, title, updated_at],
1897    )?;
1898    if changed != 1 {
1899        bail!("unknown session {session_id}");
1900    }
1901    Ok(())
1902}
1903
1904/// Persist the latest ACP-provided title without replacing unrelated session
1905/// fields that may have changed in another supervised controller task.
1906pub fn set_session_acp_title(session_id: &str, title: Option<&str>) -> Result<()> {
1907    let session_id = session_id.to_owned();
1908    let title = title.map(str::to_owned);
1909    submit_database_write("set_session_acp_title", move |_| {
1910        set_session_acp_title_to(&database_path(), &session_id, title.as_deref())
1911    })
1912}
1913
1914fn set_session_acp_title_to(path: &Path, session_id: &str, title: Option<&str>) -> Result<()> {
1915    if title.is_some_and(|title| title.trim().is_empty()) {
1916        bail!("ACP session title is empty");
1917    }
1918    let title = title.and_then(mj_core::state::normalize_session_title);
1919    let connection = open(path)?;
1920    let changed = connection.execute(
1921        "UPDATE sessions SET acp_session_title = ?2 WHERE session_id = ?1",
1922        params![session_id, title],
1923    )?;
1924    if changed != 1 {
1925        bail!("unknown session {session_id}");
1926    }
1927    Ok(())
1928}
1929
1930/// Commit the successful handshake for a newly provisioned worker without
1931/// replacing checkpoint or display metadata owned by other controller tasks.
1932pub fn mark_session_worker_connected(
1933    session_id: &str,
1934    native_session_id: Option<&str>,
1935    updated_at: &str,
1936) -> Result<()> {
1937    let session_id = session_id.to_owned();
1938    let native_session_id = native_session_id.map(str::to_owned);
1939    let updated_at = updated_at.to_owned();
1940    submit_database_write("mark_session_worker_connected", move |_| {
1941        mark_session_worker_connected_to(
1942            &database_path(),
1943            &session_id,
1944            native_session_id.as_deref(),
1945            &updated_at,
1946        )
1947    })
1948}
1949
1950fn mark_session_worker_connected_to(
1951    path: &Path,
1952    session_id: &str,
1953    native_session_id: Option<&str>,
1954    updated_at: &str,
1955) -> Result<()> {
1956    if updated_at.trim().is_empty() {
1957        bail!("worker connection timestamp is empty");
1958    }
1959    let connection = open(path)?;
1960    let changed = connection.execute(
1961        "UPDATE sessions
1962         SET state = 'running',
1963             native_session_id = coalesce(?2, native_session_id),
1964             updated_at = ?3,
1965             last_error = NULL
1966         WHERE session_id = ?1",
1967        params![session_id, native_session_id, updated_at],
1968    )?;
1969    if changed != 1 {
1970        bail!("unknown session {session_id}");
1971    }
1972    Ok(())
1973}
1974
1975fn recover_interrupted_checkpointing_sessions_to(path: &Path, updated_at: &str) -> Result<usize> {
1976    if updated_at.trim().is_empty() {
1977        bail!("checkpoint recovery timestamp is empty");
1978    }
1979    let connection = open(path)?;
1980    connection
1981        .execute(
1982            "UPDATE sessions
1983             SET state = 'running', updated_at = ?1, last_checkpoint_error = ?2
1984             WHERE state = 'checkpointing'",
1985            params![
1986                updated_at,
1987                "checkpointing was interrupted by a controller restart; the target was left running"
1988            ],
1989        )
1990        .context("recover interrupted checkpointing sessions")
1991}
1992
1993fn save_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
1994    save_session_with_container_size_to(path, session, None)
1995}
1996
1997fn save_session_with_container_size_to(
1998    path: &Path,
1999    session: &SessionRecord,
2000    container_size: Option<(&str, HostContainerSize)>,
2001) -> Result<()> {
2002    validate_session_record(session)?;
2003
2004    let mut connection = open(path)?;
2005    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2006    if let Some(existing_bundle) = tx
2007        .query_row(
2008            "SELECT bundle_id FROM session_contexts WHERE session_id = ?1",
2009            [session.id.as_str()],
2010            |row| row.get::<_, String>(0),
2011        )
2012        .optional()?
2013        && existing_bundle != session.bundle_id
2014    {
2015        bail!(
2016            "session {} was already associated with bundle {}, not {}",
2017            session.id,
2018            existing_bundle,
2019            session.bundle_id
2020        );
2021    }
2022    let mut session = session.clone();
2023    let moving: bool = tx.query_row(
2024        "SELECT EXISTS(SELECT 1 FROM session_moves WHERE session_id=?1
2025         AND json_extract(operation_json, '$.phase') IN ('preparing','closing_source','resuming_destination','starting_queue'))",
2026        [&session.id], |row| row.get(0),
2027    )?;
2028    if moving {
2029        // A Move may provision for minutes while clients keep editing drafts
2030        // and titles. Merge these independently owned fields in this same
2031        // transaction rather than restoring the lifecycle's earlier copy.
2032        let (draft, title, acp_title, viewed, archived) = tx.query_row(
2033            "SELECT draft_input, session_title_override, acp_session_title, viewed_through_event_ordinal, archived
2034             FROM sessions WHERE session_id=?1", [&session.id], |row| Ok((
2035                row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<String>>(2)?,
2036                row.get::<_, u64>(3)?, row.get::<_, bool>(4)?,
2037            )),
2038        )?;
2039        session.draft_input = draft;
2040        session.session_title_override = title;
2041        session.acp_session_title = acp_title;
2042        session.viewed_through_event_ordinal = viewed;
2043        session.archived = archived;
2044    }
2045    insert_session(&tx, &session)?;
2046    if let Some((host, size)) = container_size {
2047        write_host_container_size(&tx, host, size)?;
2048    }
2049    tx.commit()?;
2050    Ok(())
2051}
2052
2053fn save_lifecycle_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
2054    validate_session_record(session)?;
2055
2056    let mut connection = open(path)?;
2057    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2058    update_lifecycle_fields(&tx, session)?;
2059    tx.commit()?;
2060    Ok(())
2061}
2062
2063fn save_checkpointed_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
2064    validate_session_record(session)?;
2065
2066    let mut connection = open(path)?;
2067    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2068    update_lifecycle_fields(&tx, session)?;
2069    tx.execute(
2070        "UPDATE sessions SET native_session_id = ?2 WHERE session_id = ?1",
2071        params![session.id, session.native_session_id],
2072    )?;
2073    replace_checkpoint(&tx, session)?;
2074    tx.commit()?;
2075    Ok(())
2076}
2077
2078fn validate_session_record(session: &SessionRecord) -> Result<()> {
2079    let mut validation = State::default();
2080    validation
2081        .sessions
2082        .insert(session.id.clone(), session.clone());
2083    validation.validate()
2084}
2085
2086/// Remove one operational session while retaining its relational history
2087/// context and prompt history.
2088pub fn delete_session(session_id: &str) -> Result<()> {
2089    let session_id = session_id.to_owned();
2090    submit_database_write("delete_session", move |_| {
2091        delete_session_from(&database_path(), &session_id)
2092    })
2093}
2094
2095fn delete_session_from(path: &Path, session_id: &str) -> Result<()> {
2096    let connection = open(path)?;
2097    connection.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
2098    Ok(())
2099}
2100
2101/// Load a session's whole projection, transcript and all.
2102///
2103/// Crate-private on purpose. The cost of this call is everything that has ever
2104/// happened in the conversation, and the callers that made that a visible
2105/// problem — the runtime poll and the resume reply — were both outside this
2106/// crate. What they wanted was [`load_materialized_projection_tail`]; what
2107/// they reached for was this, because it was public and its name did not say
2108/// otherwise. The remaining caller owns a live projection and genuinely needs
2109/// all of it.
2110pub fn load_materialized_session(session_id: &str) -> Result<Option<MaterializedSession>> {
2111    load_materialized_session_from(&database_path(), session_id)
2112}
2113
2114/// Load only the projection fields needed by dashboard session summaries.
2115/// Transcript bodies for tools, plans, thoughts, and old messages stay in
2116/// SQLite, which keeps dashboard startup independent of transcript size.
2117pub fn load_materialized_session_summary(
2118    session_id: &str,
2119) -> Result<Option<MaterializedSessionSummary>> {
2120    load_materialized_session_summary_from(&database_path(), session_id)
2121}
2122
2123fn load_materialized_session_summary_from(
2124    path: &Path,
2125    session_id: &str,
2126) -> Result<Option<MaterializedSessionSummary>> {
2127    let connection = open_reader(path)?;
2128    let row = connection
2129        .query_row(
2130            "SELECT applied_event_ordinal, last_activity_at_ms, execution_state,
2131                    running_started_at_ms, session_title
2132             FROM materialized_sessions WHERE session_id = ?1",
2133            [session_id],
2134            |row| {
2135                Ok((
2136                    row.get::<_, u64>(0)?,
2137                    row.get::<_, Option<i64>>(1)?,
2138                    row.get::<_, String>(2)?,
2139                    row.get::<_, Option<i64>>(3)?,
2140                    row.get::<_, Option<String>>(4)?,
2141                ))
2142            },
2143        )
2144        .optional()?;
2145    let Some((
2146        applied_event_ordinal,
2147        last_activity_at_ms,
2148        execution,
2149        running_started_at_ms,
2150        session_title,
2151    )) = row
2152    else {
2153        return Ok(None);
2154    };
2155
2156    let last_user_message = last_materialized_user_message(&connection, session_id)?;
2157    let last_agent_message = last_materialized_agent_message(&connection, session_id)?;
2158    let last_agent_message_follows_last_user =
2159        last_agent_message
2160            .as_ref()
2161            .is_some_and(|(agent_position, _)| {
2162                last_user_message
2163                    .as_ref()
2164                    .is_none_or(|(user_position, _)| agent_position > user_position)
2165            });
2166    let mut ordinal_statement = connection.prepare(
2167        "SELECT latest_content_event_ordinal
2168         FROM materialized_transcript_items
2169         WHERE session_id = ?1
2170           AND latest_content_event_ordinal IS NOT NULL
2171           AND EXISTS (
2172               SELECT 1 FROM json_each(
2173                   CASE
2174                       WHEN latest_content_event_ordinal IS NOT NULL
2175                           AND json_valid(body_json)
2176                       THEN body_json
2177                       ELSE '{}'
2178                   END,
2179                   '$.chunks'
2180               ) AS chunk
2181               WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
2182                 AND (
2183                     json_extract(chunk.value, '$.content.type') <> 'text'
2184                     OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
2185                 )
2186           )
2187         ORDER BY position, stable_id",
2188    )?;
2189    let agent_message_latest_content_ordinals = ordinal_statement
2190        .query_map([session_id], |row| row.get::<_, u64>(0))?
2191        .collect::<rusqlite::Result<Vec<_>>>()?;
2192    let restart_pattern = format!("{}*", mj_core::transcript::SESSION_RESTART_ITEM_PREFIX);
2193    let mut restart_statement = connection.prepare(
2194        "SELECT position
2195         FROM materialized_transcript_items
2196         WHERE session_id = ?1 AND stable_id GLOB ?2
2197         ORDER BY position, stable_id",
2198    )?;
2199    let session_restart_event_ordinals = restart_statement
2200        .query_map((session_id, restart_pattern), |row| row.get::<_, u64>(0))?
2201        .collect::<rusqlite::Result<Vec<_>>>()?;
2202
2203    Ok(Some(MaterializedSessionSummary {
2204        session_id: session_id.to_owned(),
2205        applied_event_ordinal,
2206        last_activity_at_ms,
2207        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
2208        session_title,
2209        last_agent_message: last_agent_message.map(|(_, message)| message),
2210        last_user_message: last_user_message.map(|(_, message)| message),
2211        last_agent_message_follows_last_user,
2212        agent_message_latest_content_ordinals,
2213        session_restart_event_ordinals,
2214    }))
2215}
2216
2217/// The oldest visible user message, which is where a session's provisional
2218/// title comes from. It sits at the head of the transcript, so a projection
2219/// loaded as a tail cannot find it by scanning; this reads it directly.
2220fn first_materialized_user_message(
2221    connection: &Connection,
2222    session_id: &str,
2223) -> Result<Option<(u64, String)>> {
2224    materialized_user_message(connection, session_id, true)
2225}
2226
2227fn last_materialized_user_message(
2228    connection: &Connection,
2229    session_id: &str,
2230) -> Result<Option<(u64, String)>> {
2231    materialized_user_message(connection, session_id, false)
2232}
2233
2234fn materialized_user_message(
2235    connection: &Connection,
2236    session_id: &str,
2237    oldest_first: bool,
2238) -> Result<Option<(u64, String)>> {
2239    let mut statement = connection.prepare(if oldest_first {
2240        "SELECT position, body_json
2241         FROM materialized_transcript_items
2242         WHERE session_id = ?1
2243           AND json_extract(
2244               CASE
2245                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2246                   THEN body_json
2247                   ELSE '{}'
2248               END,
2249               '$.kind'
2250           ) = 'user'
2251         ORDER BY position, stable_id"
2252    } else {
2253        "SELECT position, body_json
2254         FROM materialized_transcript_items
2255         WHERE session_id = ?1
2256           AND json_extract(
2257               CASE
2258                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2259                   THEN body_json
2260                   ELSE '{}'
2261               END,
2262               '$.kind'
2263           ) = 'user'
2264         ORDER BY position DESC, stable_id DESC"
2265    })?;
2266    let rows = statement.query_map([session_id], |row| {
2267        Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
2268    })?;
2269    for row in rows {
2270        let (position, body_json) = row?;
2271        let body: TranscriptBody = serde_json::from_str(&body_json)
2272            .with_context(|| format!("parse materialized user message for session {session_id}"))?;
2273        let TranscriptBody::User { content } = body else {
2274            continue;
2275        };
2276        let text = mj_core::transcript::materialized_content_text(&content);
2277        if !text.trim().is_empty() {
2278            return Ok(Some((position, text)));
2279        }
2280    }
2281    Ok(None)
2282}
2283
2284/// Where the newest turn began: a user message, or the marker for a turn the
2285/// harness started on its own. This is the recovery boundary, so it reads a
2286/// position only and never has to decode a transcript body.
2287fn last_materialized_turn_start(connection: &Connection, session_id: &str) -> Result<Option<u64>> {
2288    Ok(connection
2289        .query_row(
2290            "SELECT position
2291             FROM materialized_transcript_items
2292             WHERE session_id = ?1
2293               AND (
2294                   stable_id GLOB ?2
2295                   OR json_extract(
2296                       CASE
2297                           WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2298                           THEN body_json
2299                           ELSE '{}'
2300                       END,
2301                       '$.kind'
2302                   ) = 'user'
2303               )
2304             ORDER BY position DESC, stable_id DESC
2305             LIMIT 1",
2306            params![
2307                session_id,
2308                format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX)
2309            ],
2310            |row| row.get::<_, u64>(0),
2311        )
2312        .optional()?)
2313}
2314
2315fn last_materialized_agent_message(
2316    connection: &Connection,
2317    session_id: &str,
2318) -> Result<Option<(u64, String)>> {
2319    last_materialized_agent_message_in(connection, session_id, 0)
2320}
2321
2322/// The newest nonempty agent message strictly after `after_position`, flattened
2323/// to text. Restricting by position is how one turn's final message is read.
2324fn last_materialized_agent_message_after(
2325    connection: &Connection,
2326    session_id: &str,
2327    after_position: u64,
2328) -> Result<Option<String>> {
2329    Ok(
2330        last_materialized_agent_message_in(connection, session_id, after_position)?
2331            .map(|(_, text)| text),
2332    )
2333}
2334
2335fn last_materialized_agent_message_in(
2336    connection: &Connection,
2337    session_id: &str,
2338    after_position: u64,
2339) -> Result<Option<(u64, String)>> {
2340    let row = connection
2341        .query_row(
2342            "SELECT position, body_json
2343             FROM materialized_transcript_items
2344             WHERE session_id = ?1
2345               AND position > ?2
2346               AND latest_content_event_ordinal IS NOT NULL
2347               AND EXISTS (
2348                   SELECT 1 FROM json_each(
2349                       CASE
2350                           WHEN latest_content_event_ordinal IS NOT NULL
2351                               AND json_valid(body_json)
2352                           THEN body_json
2353                           ELSE '{}'
2354                       END,
2355                       '$.chunks'
2356                   ) AS chunk
2357                   WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
2358                     AND (
2359                         json_extract(chunk.value, '$.content.type') <> 'text'
2360                         OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
2361                     )
2362               )
2363             ORDER BY position DESC, stable_id DESC
2364             LIMIT 1",
2365            params![session_id, after_position],
2366            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
2367        )
2368        .optional()?;
2369    let Some((position, body_json)) = row else {
2370        return Ok(None);
2371    };
2372    let body: TranscriptBody = serde_json::from_str(&body_json)
2373        .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
2374    let TranscriptBody::Agent { chunks, .. } = body else {
2375        return Ok(None);
2376    };
2377    let text = mj_core::transcript::materialized_chunks_text(&chunks);
2378    Ok((!text.trim().is_empty()).then_some((position, text)))
2379}
2380
2381/// Execution state, the running turn, and the last finished turn's outcome.
2382pub type MaterializedTurnState = (
2383    MaterializedExecutionState,
2384    Option<MaterializedTurn>,
2385    Option<MaterializedTurnOutcome>,
2386);
2387
2388/// Where a session stands turn by turn: what is running now, and how the last
2389/// finished prompt ended. Returns `None` when the session has no projection
2390/// row. The API's wait loop reads this for sessions whose actor is gone.
2391pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
2392    load_materialized_turn_outcome_from(&database_path(), session_id)
2393}
2394
2395fn load_materialized_turn_outcome_from(
2396    path: &Path,
2397    session_id: &str,
2398) -> Result<Option<MaterializedTurnState>> {
2399    let connection = open_reader(path)?;
2400    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
2401        return Ok(None);
2402    };
2403    Ok(Some((
2404        fields.execution,
2405        fields.active_turn,
2406        fields.last_turn_outcome,
2407    )))
2408}
2409
2410/// Summarize the turn that began at `turn_start_position`.
2411pub fn load_materialized_turn_summary(
2412    session_id: &str,
2413    turn_start_position: u64,
2414) -> Result<TurnSummary> {
2415    load_materialized_turn_summary_from(&database_path(), session_id, turn_start_position)
2416}
2417
2418fn load_materialized_turn_summary_from(
2419    path: &Path,
2420    session_id: &str,
2421    turn_start_position: u64,
2422) -> Result<TurnSummary> {
2423    let connection = open_reader(path)?;
2424    let turn_number = connection.query_row(
2425        "SELECT COUNT(*)
2426         FROM materialized_transcript_items
2427         WHERE session_id = ?1
2428           AND position <= ?3
2429           AND (
2430               stable_id GLOB ?2
2431               OR json_extract(
2432                   CASE
2433                       WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
2434                       THEN body_json
2435                       ELSE '{}'
2436                   END,
2437                   '$.kind'
2438               ) = 'user'
2439           )",
2440        params![
2441            session_id,
2442            format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
2443            turn_start_position
2444        ],
2445        |row| row.get::<_, u64>(0),
2446    )?;
2447    let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
2448        "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
2449         FROM materialized_transcript_items
2450         WHERE session_id = ?1 AND position >= ?2",
2451        params![session_id, turn_start_position],
2452        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
2453    )?;
2454    let final_message =
2455        last_materialized_agent_message_after(&connection, session_id, turn_start_position)?;
2456    Ok(TurnSummary {
2457        turn_number,
2458        turn_started_at_ms,
2459        last_changed_at_ms,
2460        final_message,
2461    })
2462}
2463
2464/// Read the transcript items whose sequence is above `after_seq`, oldest
2465/// first. Returns `None` when the session has no projection row.
2466///
2467/// The sequence is `COALESCE(latest_content_event_ordinal, position)`: an
2468/// agent message is rewritten while it streams, so paging by position would
2469/// hand a caller the message as it was first created and never send the
2470/// finished text. Paging by this sequence sends the item again exactly when it
2471/// changed, and a caller that keeps the highest sequence it saw resumes from
2472/// there whether the session is live or long stopped.
2473pub fn load_materialized_transcript_after(
2474    session_id: &str,
2475    after_seq: u64,
2476    limit: usize,
2477) -> Result<Option<TranscriptPage>> {
2478    load_materialized_transcript_after_from(&database_path(), session_id, after_seq, limit)
2479}
2480
2481fn load_materialized_transcript_after_from(
2482    path: &Path,
2483    session_id: &str,
2484    after_seq: u64,
2485    limit: usize,
2486) -> Result<Option<TranscriptPage>> {
2487    load_materialized_transcript_filtered_from(path, session_id, after_seq, limit, None)
2488}
2489
2490pub fn load_materialized_transcript_filtered(
2491    session_id: &str,
2492    after_seq: u64,
2493    limit: usize,
2494    role: Option<mj_core::transcript::TranscriptRole>,
2495) -> Result<Option<TranscriptPage>> {
2496    load_materialized_transcript_filtered_from(&database_path(), session_id, after_seq, limit, role)
2497}
2498
2499fn load_materialized_transcript_filtered_from(
2500    path: &Path,
2501    session_id: &str,
2502    after_seq: u64,
2503    limit: usize,
2504    role: Option<mj_core::transcript::TranscriptRole>,
2505) -> Result<Option<TranscriptPage>> {
2506    let mut reader = open_reader(path)?;
2507    let connection = reader.transaction()?;
2508    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
2509        return Ok(None);
2510    };
2511    let role = role.map(|r| r.storage_kind());
2512    let mut statement = connection.prepare(
2513        "WITH matches AS (
2514             SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
2515             FROM materialized_transcript_items WHERE session_id = ?1
2516             AND COALESCE(latest_content_event_ordinal, position) > ?2
2517             AND (?4 IS NULL OR json_extract(body_json, '$.kind') = ?4)
2518         ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
2519         SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
2520                last_changed_at_ms, body_json
2521         FROM matches WHERE seq <= (SELECT seq FROM boundary)
2522         ORDER BY seq, stable_id",
2523    )?;
2524    let rows = statement
2525        .query_map(
2526            params![session_id, after_seq, limit.clamp(1, 1000) as i64, role],
2527            |row| {
2528                Ok((
2529                    row.get::<_, String>(0)?,
2530                    row.get::<_, u64>(1)?,
2531                    row.get::<_, Option<u64>>(2)?,
2532                    row.get::<_, i64>(3)?,
2533                    row.get::<_, i64>(4)?,
2534                    row.get::<_, String>(5)?,
2535                ))
2536            },
2537        )?
2538        .collect::<rusqlite::Result<Vec<_>>>()?;
2539    let items = rows
2540        .into_iter()
2541        .map(
2542            |(
2543                stable_id,
2544                position,
2545                latest_content_event_ordinal,
2546                created_at_ms,
2547                last_changed_at_ms,
2548                body_json,
2549            )| {
2550                Ok(Arc::new(TranscriptItem {
2551                    stable_id,
2552                    position,
2553                    latest_content_event_ordinal,
2554                    created_at_ms,
2555                    last_changed_at_ms,
2556                    body: serde_json::from_str(&body_json).with_context(|| {
2557                        format!("parse materialized transcript body for session {session_id}")
2558                    })?,
2559                }))
2560            },
2561        )
2562        .collect::<Result<Vec<_>>>()?;
2563    let latest_seq = connection.query_row(
2564        "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
2565         FROM materialized_transcript_items
2566         WHERE session_id = ?1",
2567        [session_id],
2568        |row| row.get::<_, u64>(0),
2569    )?;
2570    let last_seq = items.last().map_or(after_seq, |item| item.seq());
2571    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))?;
2572    Ok(Some(TranscriptPage {
2573        next_after_seq: if more {
2574            last_seq
2575        } else {
2576            latest_seq.max(after_seq)
2577        },
2578        items,
2579        latest_seq,
2580        execution: fields.execution,
2581    }))
2582}
2583
2584/// Read the newest `limit` transcript items for a session, oldest first.
2585///
2586/// A conversation view seeds itself from the tail and discards everything
2587/// before it — `ChatState::from_materialized_tail` keeps `TAIL_SEED_ITEMS`
2588/// and drops the rest — so reading the whole transcript to show the end of it
2589/// is work proportional to history for a result that never was. On a real
2590/// session that meant reading 28,066 rows to render 256.
2591///
2592/// The `materialized_transcript_position` index covers the ordering, so this
2593/// costs the rows it returns rather than the rows that exist.
2594pub fn load_materialized_transcript_tail(
2595    session_id: &str,
2596    limit: usize,
2597) -> Result<Vec<Arc<TranscriptItem>>> {
2598    load_materialized_transcript_tail_from(&database_path(), session_id, limit)
2599}
2600
2601fn load_materialized_transcript_tail_from(
2602    path: &Path,
2603    session_id: &str,
2604    limit: usize,
2605) -> Result<Vec<Arc<TranscriptItem>>> {
2606    read_materialized_transcript(&open_reader(path)?, session_id, Some(limit))
2607}
2608
2609/// How many transcript rows one retention pass rewrites.
2610///
2611/// The daemon is the single database writer, so a pass that rewrote every row
2612/// of a long session would stall every other write behind it. A capped pass
2613/// leaves the rest for the next checkpoint, which is the next time any of it
2614/// becomes redundant anyway.
2615const RETENTION_BATCH_ITEMS: usize = 4_096;
2616
2617/// Rows below this are already small enough that rewriting them would cost
2618/// more than it reclaims.
2619const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
2620
2621/// Drop tool output that a verified checkpoint already holds.
2622///
2623/// The projection only ever grew: the only deletes were a per-item remove, a
2624/// whole-session wipe, and the `sessions` cascade. One measured session reached
2625/// 28,066 items and 635 MiB, of which 561 MB was tool-call content.
2626///
2627/// A checkpoint archive carries the complete transcript up to its event
2628/// frontier, and one checkpoint per session is retained, so every item at or
2629/// below `event_frontier` is durably recorded elsewhere. What stays here is
2630/// what the transcript still shows: which tool ran, on what, with what result,
2631/// and each edit's diffstat. See
2632/// [`mj_transcript::transcript::compact_tool_call_for_retention`].
2633pub fn compact_materialized_transcript_through(
2634    session_id: &str,
2635    event_frontier: u64,
2636) -> Result<TranscriptRetention> {
2637    let session_id = session_id.to_owned();
2638    submit_database_write("compact_materialized_transcript", move |_| {
2639        compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
2640    })
2641}
2642
2643fn compact_materialized_transcript_in(
2644    path: &Path,
2645    session_id: &str,
2646    event_frontier: u64,
2647) -> Result<TranscriptRetention> {
2648    let mut connection = open(path)?;
2649    let candidates = {
2650        let mut statement = connection.prepare(
2651            "SELECT stable_id, body_json
2652             FROM materialized_transcript_items
2653             WHERE session_id = ?1
2654               AND position <= ?2
2655               AND length(body_json) > ?3
2656               AND json_extract(
2657                   CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
2658                   '$.kind'
2659               ) = 'tool'
2660             ORDER BY position, stable_id
2661             LIMIT ?4",
2662        )?;
2663        statement
2664            .query_map(
2665                params![
2666                    session_id,
2667                    event_frontier,
2668                    RETENTION_BODY_FLOOR_BYTES as i64,
2669                    RETENTION_BATCH_ITEMS as i64 + 1
2670                ],
2671                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2672            )?
2673            .collect::<rusqlite::Result<Vec<_>>>()?
2674    };
2675    let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
2676    let mut retention = TranscriptRetention {
2677        remaining,
2678        ..TranscriptRetention::default()
2679    };
2680    let transaction =
2681        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2682    for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
2683        let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
2684            Ok(body) => body,
2685            // A row this cannot read is a row it must not rewrite.
2686            Err(error) => {
2687                tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
2688                continue;
2689            }
2690        };
2691        if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
2692            continue;
2693        }
2694        let compacted = serde_json::to_string(&body)
2695            .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
2696        if compacted.len() >= body_json.len() {
2697            continue;
2698        }
2699        transaction.execute(
2700            "UPDATE materialized_transcript_items SET body_json = ?3
2701             WHERE session_id = ?1 AND stable_id = ?2",
2702            params![session_id, stable_id, compacted],
2703        )?;
2704        retention.items += 1;
2705        retention.bytes += body_json.len() - compacted.len();
2706    }
2707    transaction.commit()?;
2708    Ok(retention)
2709}
2710
2711/// How many transcript items a polled projection carries.
2712///
2713/// Every viewer of a polled projection is bounded already: the conversation
2714/// pane keeps `chat::TAIL_SEED_ITEMS` (256) entries, and the browser
2715/// transcript keeps 1,000 rendered lines. This is set above both, since an
2716/// entry renders to at least one line, so the window is the whole of what any
2717/// of them would show.
2718pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
2719
2720/// Load a projection carrying only the end of its transcript.
2721///
2722/// The steady-state poll reloads a session's projection every time anything
2723/// about it moves. Loading the whole transcript to do that is work
2724/// proportional to everything that has ever happened in the conversation —
2725/// 635 MiB and 28,066 items on one measured session — for a view that shows
2726/// the last few hundred entries. This reads the window instead, plus the two
2727/// facts that live outside it, each with one indexed query. See
2728/// [`ProjectionWindow`].
2729pub fn load_materialized_projection_tail(
2730    session_id: &str,
2731    transcript_limit: usize,
2732) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
2733    load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
2734}
2735
2736fn load_materialized_projection_tail_from(
2737    path: &Path,
2738    session_id: &str,
2739    transcript_limit: usize,
2740) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
2741    let connection = open_reader(path)?;
2742    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
2743        return Ok(None);
2744    };
2745    let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
2746    let total_items = connection.query_row(
2747        "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
2748        [session_id],
2749        |row| row.get::<_, usize>(0),
2750    )?;
2751    let window = ProjectionWindow {
2752        omitted_items: total_items.saturating_sub(transcript.len()),
2753        provisional_title: first_materialized_user_message(&connection, session_id)?
2754            .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
2755        latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
2756    };
2757    let materialized = MaterializedSession {
2758        session_id: session_id.to_owned(),
2759        applied_event_ordinal: fields.applied_event_ordinal,
2760        applied_event_digest: fields.applied_event_digest,
2761        last_activity_at_ms: fields.last_activity_at_ms,
2762        execution: fields.execution,
2763        session_title: fields.session_title,
2764        configuration: fields.configuration,
2765        transcript,
2766        queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
2767        pending_elicitations: fields.pending_elicitations,
2768        active_turn: fields.active_turn,
2769        last_turn_outcome: fields.last_turn_outcome,
2770    };
2771    materialized.validate()?;
2772    Ok(Some((materialized, window)))
2773}
2774
2775/// Read only the projection's event frontier. Deciding whether a stored
2776/// projection already matches an archive costs one row this way, instead of
2777/// deserializing every transcript item to compare two integers.
2778pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
2779    materialized_event_frontier_from(&database_path(), session_id)
2780}
2781
2782fn materialized_event_frontier_from(
2783    path: &Path,
2784    session_id: &str,
2785) -> Result<Option<(u64, String)>> {
2786    Ok(open_reader(path)?
2787        .query_row(
2788            "SELECT applied_event_ordinal, applied_event_digest
2789             FROM materialized_sessions WHERE session_id = ?1",
2790            [session_id],
2791            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
2792        )
2793        .optional()?)
2794}
2795
2796/// Replace a session's durable prompt queue without touching its transcript or
2797/// event frontier. Resume uses this when it keeps the stored projection but
2798/// still has to drop the queue the archive carried.
2799pub fn replace_materialized_queued_prompts(
2800    session_id: &str,
2801    queued_prompts: &[MaterializedQueuedPrompt],
2802) -> Result<()> {
2803    let session_id = session_id.to_owned();
2804    let queued_prompts = queued_prompts.to_vec();
2805    submit_database_write("replace_materialized_queued_prompts", move |_| {
2806        replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
2807    })
2808}
2809
2810fn replace_materialized_queued_prompts_in(
2811    path: &Path,
2812    session_id: &str,
2813    queued_prompts: &[MaterializedQueuedPrompt],
2814) -> Result<()> {
2815    let mut connection = open(path)?;
2816    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
2817    if !session_exists(&tx, session_id)? {
2818        bail!("unknown session {session_id}");
2819    }
2820    replace_materialized_queue(&tx, session_id, queued_prompts)?;
2821    tx.commit()?;
2822    Ok(())
2823}
2824
2825/// Load only the durable prompt queues without deserializing transcript rows.
2826/// Dashboard startup uses this path so work is proportional to queued prompts,
2827/// not to the complete retained conversation history.
2828pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
2829{
2830    load_materialized_queued_prompts_from(&database_path())
2831}
2832
2833fn load_materialized_queued_prompts_from(
2834    path: &Path,
2835) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
2836    let connection = open_reader(path)?;
2837    let mut statement = connection.prepare(
2838        "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
2839         FROM materialized_queued_prompts
2840         ORDER BY session_id, ordinal",
2841    )?;
2842    let rows = statement.query_map([], |row| {
2843        Ok((
2844            row.get::<_, String>(0)?,
2845            row.get::<_, String>(1)?,
2846            row.get::<_, String>(2)?,
2847            row.get::<_, String>(3)?,
2848            row.get::<_, i64>(4)?,
2849            row.get::<_, Option<u64>>(5)?,
2850        ))
2851    })?;
2852    let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
2853    for row in rows {
2854        let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
2855            row?;
2856        let content = serde_json::from_str(&content_json).with_context(|| {
2857            format!("parse materialized queued prompt for session {session_id}")
2858        })?;
2859        let kind = serde_json::from_str(&kind_json).with_context(|| {
2860            format!("parse materialized queue entry kind for session {session_id}")
2861        })?;
2862        queues
2863            .entry(session_id)
2864            .or_default()
2865            .push(MaterializedQueuedPrompt {
2866                command_id,
2867                kind,
2868                content,
2869                queued_at_ms,
2870                accepted_ordinal,
2871            });
2872    }
2873    Ok(queues)
2874}
2875
2876fn load_materialized_session_from(
2877    path: &Path,
2878    session_id: &str,
2879) -> Result<Option<MaterializedSession>> {
2880    let connection = open_reader(path)?;
2881    load_materialized_session_with(&connection, session_id)
2882}
2883
2884fn load_materialized_session_with(
2885    connection: &Connection,
2886    session_id: &str,
2887) -> Result<Option<MaterializedSession>> {
2888    let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
2889        return Ok(None);
2890    };
2891    let materialized = MaterializedSession {
2892        session_id: session_id.to_owned(),
2893        applied_event_ordinal: fields.applied_event_ordinal,
2894        applied_event_digest: fields.applied_event_digest,
2895        last_activity_at_ms: fields.last_activity_at_ms,
2896        execution: fields.execution,
2897        session_title: fields.session_title,
2898        configuration: fields.configuration,
2899        transcript: read_materialized_transcript(connection, session_id, None)?,
2900        queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
2901        pending_elicitations: fields.pending_elicitations,
2902        active_turn: fields.active_turn,
2903        last_turn_outcome: fields.last_turn_outcome,
2904    };
2905    materialized.validate()?;
2906    Ok(Some(materialized))
2907}
2908
2909/// Everything a projection holds apart from its transcript and its queue.
2910struct MaterializedSessionFields {
2911    applied_event_ordinal: u64,
2912    applied_event_digest: String,
2913    last_activity_at_ms: Option<i64>,
2914    execution: MaterializedExecutionState,
2915    session_title: Option<String>,
2916    configuration: BTreeMap<String, serde_json::Value>,
2917    pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
2918    active_turn: Option<MaterializedTurn>,
2919    last_turn_outcome: Option<MaterializedTurnOutcome>,
2920}
2921
2922fn read_materialized_session_fields(
2923    connection: &Connection,
2924    session_id: &str,
2925) -> Result<Option<MaterializedSessionFields>> {
2926    let row = connection
2927        .query_row(
2928            "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
2929                    execution_state, running_started_at_ms, session_title, configuration_json,
2930                    pending_elicitations_json, active_turn_json, last_turn_outcome_json
2931             FROM materialized_sessions WHERE session_id = ?1",
2932            [session_id],
2933            |row| {
2934                Ok((
2935                    row.get::<_, u64>(0)?,
2936                    row.get::<_, String>(1)?,
2937                    row.get::<_, Option<i64>>(2)?,
2938                    row.get::<_, String>(3)?,
2939                    row.get::<_, Option<i64>>(4)?,
2940                    row.get::<_, Option<String>>(5)?,
2941                    row.get::<_, String>(6)?,
2942                    row.get::<_, String>(7)?,
2943                    row.get::<_, Option<String>>(8)?,
2944                    row.get::<_, Option<String>>(9)?,
2945                ))
2946            },
2947        )
2948        .optional()?;
2949    let Some((
2950        applied_event_ordinal,
2951        applied_event_digest,
2952        last_activity_at_ms,
2953        execution,
2954        running_started_at_ms,
2955        session_title,
2956        configuration_json,
2957        pending_elicitations_json,
2958        active_turn_json,
2959        last_turn_outcome_json,
2960    )) = row
2961    else {
2962        return Ok(None);
2963    };
2964    Ok(Some(MaterializedSessionFields {
2965        applied_event_ordinal,
2966        applied_event_digest,
2967        last_activity_at_ms,
2968        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
2969        session_title,
2970        configuration: serde_json::from_str(&configuration_json).with_context(|| {
2971            format!("parse materialized configuration for session {session_id}")
2972        })?,
2973        pending_elicitations: serde_json::from_str(&pending_elicitations_json)
2974            .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
2975        active_turn: active_turn_json
2976            .as_deref()
2977            .map(serde_json::from_str)
2978            .transpose()
2979            .with_context(|| format!("parse active turn for session {session_id}"))?,
2980        last_turn_outcome: last_turn_outcome_json
2981            .as_deref()
2982            .map(serde_json::from_str)
2983            .transpose()
2984            .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
2985    }))
2986}
2987
2988/// Read a session's transcript, oldest first. `limit` reads only that many
2989/// items from the end, walking the `materialized_transcript_position` index
2990/// backwards so the read costs the rows it returns.
2991fn read_materialized_transcript(
2992    connection: &Connection,
2993    session_id: &str,
2994    limit: Option<usize>,
2995) -> Result<Vec<Arc<TranscriptItem>>> {
2996    let mut statement = connection.prepare(match limit {
2997        Some(_) => {
2998            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
2999                    last_changed_at_ms, body_json
3000             FROM materialized_transcript_items
3001             WHERE session_id = ?1
3002             ORDER BY position DESC, stable_id DESC
3003             LIMIT ?2"
3004        }
3005        None => {
3006            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
3007                    last_changed_at_ms, body_json
3008             FROM materialized_transcript_items
3009             WHERE session_id = ?1
3010             ORDER BY position, stable_id"
3011        }
3012    })?;
3013    let read = |row: &rusqlite::Row<'_>| {
3014        Ok((
3015            row.get::<_, String>(0)?,
3016            row.get::<_, u64>(1)?,
3017            row.get::<_, Option<u64>>(2)?,
3018            row.get::<_, i64>(3)?,
3019            row.get::<_, i64>(4)?,
3020            row.get::<_, String>(5)?,
3021        ))
3022    };
3023    let rows = match limit {
3024        Some(limit) => statement
3025            .query_map(params![session_id, limit as i64], read)?
3026            .collect::<rusqlite::Result<Vec<_>>>()?,
3027        None => statement
3028            .query_map([session_id], read)?
3029            .collect::<rusqlite::Result<Vec<_>>>()?,
3030    };
3031    let mut transcript = rows
3032        .into_iter()
3033        .map(
3034            |(
3035                stable_id,
3036                position,
3037                latest_content_event_ordinal,
3038                created_at_ms,
3039                last_changed_at_ms,
3040                body_json,
3041            )| {
3042                Ok(Arc::new(TranscriptItem {
3043                    stable_id,
3044                    position,
3045                    latest_content_event_ordinal,
3046                    created_at_ms,
3047                    last_changed_at_ms,
3048                    body: serde_json::from_str(&body_json).with_context(|| {
3049                        format!("parse materialized transcript body for session {session_id}")
3050                    })?,
3051                }))
3052            },
3053        )
3054        .collect::<Result<Vec<_>>>()?;
3055    if limit.is_some() {
3056        // The bounded query walks the index backwards to bound what it reads;
3057        // every caller wants the transcript in the order it was written.
3058        transcript.reverse();
3059    }
3060    Ok(transcript)
3061}
3062
3063fn read_materialized_queued_prompts(
3064    connection: &Connection,
3065    session_id: &str,
3066) -> Result<Vec<MaterializedQueuedPrompt>> {
3067    let mut statement = connection.prepare(
3068        "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
3069         FROM materialized_queued_prompts
3070         WHERE session_id = ?1
3071         ORDER BY ordinal",
3072    )?;
3073    let rows = statement
3074        .query_map([session_id], |row| {
3075            Ok((
3076                row.get::<_, String>(0)?,
3077                row.get::<_, String>(1)?,
3078                row.get::<_, String>(2)?,
3079                row.get::<_, i64>(3)?,
3080                row.get::<_, Option<u64>>(4)?,
3081            ))
3082        })?
3083        .collect::<rusqlite::Result<Vec<_>>>()?;
3084    rows.into_iter()
3085        .map(
3086            |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
3087                Ok(MaterializedQueuedPrompt {
3088                    command_id,
3089                    kind: serde_json::from_str(&kind_json).with_context(|| {
3090                        format!("parse materialized queue entry kind for session {session_id}")
3091                    })?,
3092                    content: serde_json::from_str(&content_json).with_context(|| {
3093                        format!("parse materialized queued prompt for session {session_id}")
3094                    })?,
3095                    queued_at_ms,
3096                    accepted_ordinal,
3097                })
3098            },
3099        )
3100        .collect()
3101}
3102
3103/// Replace a complete projection, primarily when seeding a restored
3104/// checkpoint. Operational `SessionRecord` metadata and read receipts are not
3105/// modified.
3106pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
3107    let materialized = materialized.clone();
3108    submit_database_write("save_materialized_session", move |_| {
3109        save_materialized_session_to(&database_path(), &materialized)
3110    })
3111}
3112
3113fn save_materialized_session_to(path: &Path, materialized: &MaterializedSession) -> Result<()> {
3114    materialized.validate()?;
3115    let mut connection = open(path)?;
3116    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3117    if !session_exists(&tx, &materialized.session_id)? {
3118        bail!("unknown session {}", materialized.session_id);
3119    }
3120    write_materialized_session(&tx, materialized)?;
3121    tx.commit()?;
3122    Ok(())
3123}
3124
3125/// One relay page being applied inside a single write transaction. The relay
3126/// retains everything past the last acknowledgement, so a page that fails
3127/// part-way rolls back to the previous durable frontier and is simply
3128/// redelivered. Only a committed page may be acknowledged.
3129pub struct ProjectionPage<'a> {
3130    session_id: &'a str,
3131    transaction: Transaction<'a>,
3132    applied_ordinal: u64,
3133    applied_digest: String,
3134    dirty: bool,
3135    pending: MaterializedSessionMutation,
3136    pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
3137    pending_turns: Vec<MaterializedTurnOutcome>,
3138    pending_events: Vec<(i64, ApiEventData)>,
3139}
3140
3141struct PendingTranscriptMutation {
3142    final_mutation: TranscriptMutation,
3143    remove_before_upsert: bool,
3144}
3145
3146impl ProjectionPage<'_> {
3147    /// Apply the projection effects of the next relay event to the open page.
3148    /// The event must continue the chain the page has reached so far, which is
3149    /// the persisted frontier plus every event already applied to this page.
3150    pub fn apply(
3151        &mut self,
3152        event_ordinal: u64,
3153        previous_event_digest: &str,
3154        event_digest: &str,
3155        mutation: &MaterializedSessionMutation,
3156    ) -> Result<ProjectionApplyOutcome> {
3157        if event_ordinal == 0 {
3158            bail!("relay event ordinal must be positive");
3159        }
3160        // A v2 event carries no chain link (empty previous digest). Its
3161        // continuity to the projection frontier is proven by ordinal
3162        // contiguity plus the attach cursor the controller validated against
3163        // the worker, not by an in-record back-reference; divergence is caught
3164        // there, before any event is applied.
3165        let chained = !previous_event_digest.is_empty();
3166        if chained {
3167            validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
3168        }
3169        validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
3170        let session_id = self.session_id;
3171        let applied = self.applied_ordinal;
3172        if event_ordinal < applied {
3173            return Ok(ProjectionApplyOutcome::AlreadyApplied);
3174        }
3175        if event_ordinal == applied {
3176            if event_digest != self.applied_digest {
3177                bail!(
3178                    "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
3179                    self.applied_digest
3180                );
3181            }
3182            return Ok(ProjectionApplyOutcome::AlreadyApplied);
3183        }
3184        let expected = applied
3185            .checked_add(1)
3186            .context("materialized event ordinal overflow")?;
3187        if event_ordinal != expected {
3188            bail!(
3189                "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
3190            );
3191        }
3192        if chained && previous_event_digest != self.applied_digest {
3193            bail!(
3194                "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
3195                self.applied_digest
3196            );
3197        }
3198
3199        if let Some(activity_at_ms) = mutation.last_activity_at_ms {
3200            self.pending.last_activity_at_ms = Some(
3201                self.pending
3202                    .last_activity_at_ms
3203                    .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
3204            );
3205        }
3206        if let Some(execution) = mutation.execution {
3207            self.pending.execution = Some(execution);
3208        }
3209        if let Some(title) = &mutation.session_title {
3210            if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
3211                bail!("materialized session title cannot be empty");
3212            }
3213            self.pending.session_title = Some(title.clone());
3214        }
3215        if let Some(configuration) = &mutation.configuration {
3216            self.pending.configuration = Some(configuration.clone());
3217        }
3218        for item_mutation in &mutation.transcript {
3219            match item_mutation {
3220                TranscriptMutation::Upsert(item) => {
3221                    item.validate(event_ordinal)?;
3222                    let stable_id = item.stable_id.clone();
3223                    let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
3224                        PendingTranscriptMutation {
3225                            final_mutation: TranscriptMutation::Upsert(item.clone()),
3226                            remove_before_upsert: false,
3227                        }
3228                    });
3229                    entry.remove_before_upsert |=
3230                        matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
3231                    entry.final_mutation = TranscriptMutation::Upsert(item.clone());
3232                }
3233                TranscriptMutation::Remove { stable_id } => {
3234                    if stable_id.trim().is_empty() {
3235                        bail!("cannot remove a transcript item with an empty stable id");
3236                    }
3237                    let removed = TranscriptMutation::Remove {
3238                        stable_id: stable_id.clone(),
3239                    };
3240                    self.pending_transcript
3241                        .entry(stable_id.clone())
3242                        .and_modify(|entry| entry.final_mutation = removed.clone())
3243                        .or_insert(PendingTranscriptMutation {
3244                            final_mutation: removed,
3245                            remove_before_upsert: false,
3246                        });
3247                }
3248            }
3249        }
3250        if let Some(queued_prompts) = &mutation.queued_prompts {
3251            self.pending.queued_prompts = Some(queued_prompts.clone());
3252        }
3253        if let Some(pending_elicitations) = &mutation.pending_elicitations {
3254            self.pending.pending_elicitations = Some(pending_elicitations.clone());
3255        }
3256        self.pending
3257            .config_results
3258            .extend(mutation.config_results.clone());
3259        if let Some(active_turn) = &mutation.active_turn {
3260            self.pending.active_turn = Some(active_turn.clone());
3261        }
3262        if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
3263            self.pending_turns.push(last_turn_outcome.clone());
3264            self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
3265        }
3266        if let Some(cost) = &mutation.provider_cost {
3267            self.pending.provider_cost = Some(cost.clone());
3268        }
3269        self.pending_events.extend(
3270            mutation
3271                .api_events
3272                .iter()
3273                .cloned()
3274                .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
3275        );
3276        self.applied_ordinal = event_ordinal;
3277        event_digest.clone_into(&mut self.applied_digest);
3278        self.dirty = true;
3279        Ok(ProjectionApplyOutcome::Applied)
3280    }
3281
3282    /// Persist the coalesced final state of this page. Intermediate event
3283    /// frontiers are useful only for chain validation: a page commits or rolls
3284    /// back as a unit, so writing them individually adds no recovery value.
3285    fn flush(&mut self) -> Result<()> {
3286        if !self.dirty {
3287            return Ok(());
3288        }
3289        let tx = &self.transaction;
3290        let session_id = self.session_id;
3291        if let Some(execution) = self.pending.execution {
3292            let (state, started_at_ms) = materialized_execution_columns(execution);
3293            tx.execute(
3294                "UPDATE materialized_sessions
3295                 SET execution_state = ?2, running_started_at_ms = ?3
3296                 WHERE session_id = ?1",
3297                params![session_id, state, started_at_ms],
3298            )?;
3299        }
3300        if let Some(title) = &self.pending.session_title {
3301            tx.execute(
3302                "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
3303                params![session_id, title],
3304            )?;
3305        }
3306        if let Some(configuration) = &self.pending.configuration {
3307            tx.execute(
3308                "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
3309                params![session_id, serde_json::to_string(configuration)?],
3310            )?;
3311        }
3312        for pending in self.pending_transcript.values() {
3313            match &pending.final_mutation {
3314                TranscriptMutation::Upsert(item) => {
3315                    // A remove followed by an upsert deliberately starts a new
3316                    // item identity. Preserve that boundary even though other
3317                    // repeated updates are coalesced to one write.
3318                    if pending.remove_before_upsert {
3319                        tx.execute(
3320                            "DELETE FROM materialized_transcript_items
3321                             WHERE session_id = ?1 AND stable_id = ?2",
3322                            params![session_id, item.stable_id],
3323                        )?;
3324                    }
3325                    upsert_transcript_item(tx, session_id, item)?;
3326                }
3327                TranscriptMutation::Remove { stable_id } => {
3328                    tx.execute(
3329                        "DELETE FROM materialized_transcript_items
3330                         WHERE session_id = ?1 AND stable_id = ?2",
3331                        params![session_id, stable_id],
3332                    )?;
3333                }
3334            }
3335        }
3336        if let Some(queued_prompts) = &self.pending.queued_prompts {
3337            replace_materialized_queue(tx, session_id, queued_prompts)?;
3338        }
3339        if let Some(pending_elicitations) = &self.pending.pending_elicitations {
3340            tx.execute(
3341                "UPDATE materialized_sessions
3342                 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
3343                params![session_id, serde_json::to_string(pending_elicitations)?],
3344            )?;
3345        }
3346        for (recorded_at_ms, event) in &self.pending_events {
3347            events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
3348        }
3349        for turn in &self.pending_turns {
3350            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)?])?;
3351        }
3352        if let Some(cost) = &self.pending.provider_cost {
3353            tx.execute(
3354                "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
3355                params![session_id, serde_json::to_string(cost)?],
3356            )?;
3357        }
3358        for (command_id, error) in &self.pending.config_results {
3359            tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
3360        }
3361        if let Some(active_turn) = &self.pending.active_turn {
3362            tx.execute(
3363                "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
3364                params![
3365                    session_id,
3366                    active_turn
3367                        .as_ref()
3368                        .map(serde_json::to_string)
3369                        .transpose()?
3370                ],
3371            )?;
3372        }
3373        if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
3374            tx.execute(
3375                "UPDATE materialized_sessions
3376                 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
3377                params![session_id, serde_json::to_string(last_turn_outcome)?],
3378            )?;
3379        }
3380        tx.execute(
3381            "UPDATE materialized_sessions
3382             SET last_activity_at_ms = CASE
3383                     WHEN ?2 IS NULL THEN last_activity_at_ms
3384                     WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
3385                     ELSE last_activity_at_ms
3386                 END,
3387                 applied_event_ordinal = ?3,
3388                 applied_event_digest = ?4
3389             WHERE session_id = ?1",
3390            params![
3391                session_id,
3392                self.pending.last_activity_at_ms,
3393                self.applied_ordinal,
3394                self.applied_digest,
3395            ],
3396        )?;
3397        Ok(())
3398    }
3399}
3400
3401/// Apply one relay page in a single transaction. `fill` feeds the page's
3402/// events through [`ProjectionPage::apply`]; the projection changes and the
3403/// event frontier commit together only when `fill` succeeds, so callers may
3404/// acknowledge the page's last ordinal to the relay after this returns.
3405pub fn apply_projection_page<T>(
3406    session_id: &str,
3407    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
3408) -> Result<T>
3409where
3410    T: Send + 'static,
3411{
3412    let session_id = session_id.to_owned();
3413    submit_database_write("apply_projection_page", move |connection| {
3414        apply_projection_page_with(connection, &session_id, fill)
3415    })
3416}
3417
3418#[cfg(test)]
3419fn apply_projection_page_to<T>(
3420    path: &Path,
3421    session_id: &str,
3422    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
3423) -> Result<T> {
3424    let mut connection = open(path)?;
3425    apply_projection_page_with(&mut connection, session_id, fill)
3426}
3427
3428fn apply_projection_page_with<T>(
3429    connection: &mut Connection,
3430    session_id: &str,
3431    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
3432) -> Result<T> {
3433    let transaction =
3434        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3435    let (applied_ordinal, applied_digest) = transaction
3436        .query_row(
3437            "SELECT applied_event_ordinal, applied_event_digest
3438             FROM materialized_sessions WHERE session_id = ?1",
3439            [session_id],
3440            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
3441        )
3442        .optional()?
3443        .with_context(|| format!("unknown session {session_id}"))?;
3444    validate_relay_event_frontier(
3445        applied_ordinal,
3446        &applied_digest,
3447        "persisted relay event frontier",
3448    )?;
3449    let mut page = ProjectionPage {
3450        session_id,
3451        transaction,
3452        applied_ordinal,
3453        applied_digest,
3454        dirty: false,
3455        pending: MaterializedSessionMutation::default(),
3456        pending_transcript: BTreeMap::new(),
3457        pending_turns: Vec::new(),
3458        pending_events: Vec::new(),
3459    };
3460    // Dropping the page on failure rolls the whole transaction back, leaving
3461    // the projection at the frontier the relay last saw acknowledged.
3462    let filled = fill(&mut page)?;
3463    page.flush()?;
3464    page.transaction.commit()?;
3465    Ok(filled)
3466}
3467
3468/// Apply exactly one relay event, as a page of one.
3469pub fn apply_projection_event(
3470    session_id: &str,
3471    event_ordinal: u64,
3472    previous_event_digest: &str,
3473    event_digest: &str,
3474    mutation: &MaterializedSessionMutation,
3475) -> Result<ProjectionApplyOutcome> {
3476    let session_id = session_id.to_owned();
3477    let previous_event_digest = previous_event_digest.to_owned();
3478    let event_digest = event_digest.to_owned();
3479    let mutation = mutation.clone();
3480    submit_database_write("apply_projection_event", move |connection| {
3481        apply_projection_page_with(connection, &session_id, |page| {
3482            page.apply(
3483                event_ordinal,
3484                &previous_event_digest,
3485                &event_digest,
3486                &mutation,
3487            )
3488        })
3489    })
3490}
3491
3492#[cfg(test)]
3493fn apply_projection_event_to(
3494    path: &Path,
3495    session_id: &str,
3496    event_ordinal: u64,
3497    previous_event_digest: &str,
3498    event_digest: &str,
3499    mutation: &MaterializedSessionMutation,
3500) -> Result<ProjectionApplyOutcome> {
3501    apply_projection_page_to(path, session_id, |page| {
3502        page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
3503    })
3504}
3505
3506/// Advance the persisted detach/read receipt monotonically. A receipt cannot
3507/// acknowledge an event the controller projection has not durably applied.
3508pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
3509    let session_id = session_id.to_owned();
3510    submit_database_write("advance_viewed_through_event_ordinal", move |_| {
3511        advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
3512    })
3513}
3514
3515fn advance_viewed_through_event_ordinal_to(
3516    path: &Path,
3517    session_id: &str,
3518    through: u64,
3519) -> Result<u64> {
3520    let mut connection = open(path)?;
3521    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3522    let applied = tx
3523        .query_row(
3524            "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
3525            [session_id],
3526            |row| row.get::<_, u64>(0),
3527        )
3528        .optional()?
3529        .with_context(|| format!("unknown session {session_id}"))?;
3530    if through > applied {
3531        bail!(
3532            "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
3533        );
3534    }
3535    tx.execute(
3536        "UPDATE sessions
3537         SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
3538         WHERE session_id = ?1",
3539        params![session_id, through],
3540    )?;
3541    let receipt = tx.query_row(
3542        "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
3543        [session_id],
3544        |row| row.get::<_, u64>(0),
3545    )?;
3546    tx.commit()?;
3547    Ok(receipt)
3548}
3549
3550/// Overwrite the unsent chat input carried across a detach. Unlike the read
3551/// receipt this is not monotonic: a draft can shrink, and an empty string
3552/// clears it.
3553pub fn set_session_draft_input(session_id: &str, draft: &str) -> Result<()> {
3554    let session_id = session_id.to_owned();
3555    let draft = draft.to_owned();
3556    submit_database_write("set_session_draft_input", move |_| {
3557        set_session_draft_input_at(&database_path(), &session_id, &draft)
3558    })
3559}
3560
3561fn set_session_draft_input_at(path: &Path, session_id: &str, draft: &str) -> Result<()> {
3562    let mut connection = open(path)?;
3563    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3564    let updated = tx.execute(
3565        "UPDATE sessions SET draft_input = ?2 WHERE session_id = ?1",
3566        params![session_id, draft],
3567    )?;
3568    ensure!(updated == 1, "unknown session {session_id}");
3569    tx.commit()?;
3570    Ok(())
3571}
3572
3573/// Retire a submitted shared draft without erasing a newer client's edit.
3574pub fn clear_session_draft_input_if_matches(session_id: &str, expected: &str) -> Result<()> {
3575    let session_id = session_id.to_owned();
3576    let expected = expected.to_owned();
3577    submit_database_write("clear_session_draft_input_if_matches", move |connection| {
3578        connection.execute(
3579            "UPDATE sessions SET draft_input = '' WHERE session_id = ?1 AND draft_input = ?2",
3580            params![session_id, expected],
3581        )?;
3582        Ok(())
3583    })
3584}
3585
3586/// Atomically apply the controller's MRU policy for newly used mount sources.
3587pub fn remember_mount_sources(host: &str, mounts: &[AdditionalMount]) -> Result<()> {
3588    if mounts.is_empty() {
3589        return Ok(());
3590    }
3591    let host = host.to_owned();
3592    let sources = mounts
3593        .iter()
3594        .map(|mount| mount.source.clone())
3595        .collect::<Vec<_>>();
3596    submit_database_write("remember_mount_sources", move |_| {
3597        remember_sources(&database_path(), &host, sources)
3598    })
3599}
3600
3601/// Replace one host's remembered mount sources with exactly this list, so the
3602/// dashboard can forget a directory the user no longer wants suggested.
3603/// What each workspace last chose for a second opinion.
3604///
3605/// The selection is remembered so a repeat review does not ask again, and it
3606/// is workspace scoped because a reviewer that suits one project rarely suits
3607/// the next. Values are validated against what the harness advertises now
3608/// before they are used, so a retired profile is harmless here.
3609pub fn reviewer_defaults() -> Result<mj_core::second_opinion::ReviewerDefaults> {
3610    reviewer_defaults_in(&database_path())
3611}
3612
3613fn reviewer_defaults_in(path: &Path) -> Result<mj_core::second_opinion::ReviewerDefaults> {
3614    let connection = open_reader(path)?;
3615    let mut statement = connection.prepare(
3616        "SELECT workspace_id, profile_id, model, effort FROM second_opinion_defaults
3617         ORDER BY workspace_id, profile_id, model",
3618    )?;
3619    let mut defaults = mj_core::second_opinion::ReviewerDefaults::default();
3620    let mut rows = statement.query([])?;
3621    while let Some(row) = rows.next()? {
3622        let workspace_id: String = row.get(0)?;
3623        let profile_id: String = row.get(1)?;
3624        let model: String = row.get(2)?;
3625        let effort: String = row.get(3)?;
3626        defaults.restore(&workspace_id, &profile_id, &model, &effort);
3627    }
3628    Ok(defaults)
3629}
3630
3631/// Record one confirmed selection.
3632pub fn remember_reviewer_selection(
3633    workspace_id: &str,
3634    selection: &mj_core::second_opinion::ReviewerSelection,
3635) -> Result<()> {
3636    let workspace_id = workspace_id.to_owned();
3637    let selection = selection.clone();
3638    submit_database_write("remember_reviewer_selection", move |_| {
3639        remember_reviewer_selection_in(&database_path(), &workspace_id, &selection)
3640    })
3641}
3642
3643fn remember_reviewer_selection_in(
3644    path: &Path,
3645    workspace_id: &str,
3646    selection: &mj_core::second_opinion::ReviewerSelection,
3647) -> Result<()> {
3648    ensure!(
3649        !workspace_id.trim().is_empty(),
3650        "second-opinion defaults need a workspace"
3651    );
3652    let mut connection = open(path)?;
3653    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3654    let (profile_id, model, effort) = selection.stored_values();
3655    // One profile is the workspace's reviewer at a time, so the rows for the
3656    // others stop being the remembered choice rather than accumulating.
3657    tx.execute(
3658        "DELETE FROM second_opinion_defaults WHERE workspace_id = ?1 AND profile_id <> ?2",
3659        params![workspace_id, profile_id],
3660    )?;
3661    tx.execute(
3662        "INSERT INTO second_opinion_defaults(workspace_id, profile_id, model, effort)
3663         VALUES (?1, ?2, ?3, ?4)
3664         ON CONFLICT(workspace_id, profile_id, model) DO UPDATE SET effort = excluded.effort",
3665        params![workspace_id, profile_id, model, effort],
3666    )?;
3667    tx.commit()?;
3668    Ok(())
3669}
3670
3671/// The open review for `session_id`, if the session has one.
3672pub fn active_review(session_id: &str) -> Result<Option<StoredReview>> {
3673    active_review_in(&database_path(), session_id)
3674}
3675
3676fn active_review_in(path: &Path, session_id: &str) -> Result<Option<StoredReview>> {
3677    let connection = open_reader(path)?;
3678    let row = connection
3679        .query_row(
3680            "SELECT workflow, generation, context_baseline, native_lost, reviewer_transcript
3681             FROM second_opinion_reviews WHERE session_id = ?1",
3682            [session_id],
3683            |row| {
3684                Ok((
3685                    row.get::<_, String>(0)?,
3686                    row.get::<_, i64>(1)?,
3687                    row.get::<_, i64>(2)?,
3688                    row.get::<_, i64>(3)?,
3689                    row.get::<_, String>(4)?,
3690                ))
3691            },
3692        )
3693        .optional()?;
3694    let Some((workflow, generation, baseline, native_lost, transcript)) = row else {
3695        return Ok(None);
3696    };
3697    Ok(Some(StoredReview {
3698        workflow: serde_json::from_str(&workflow).context("parse the stored review workflow")?,
3699        generation: u64::try_from(generation).unwrap_or_default(),
3700        context_baseline: u64::try_from(baseline).unwrap_or_default(),
3701        native_lost: native_lost != 0,
3702        reviewer_transcript: serde_json::from_str(&transcript)
3703            .context("parse the stored reviewer transcript")?,
3704    }))
3705}
3706
3707/// Records the open review, replacing any earlier one for this session.
3708pub fn save_active_review(session_id: &str, review: &StoredReview) -> Result<()> {
3709    let session_id = session_id.to_owned();
3710    let review = review.clone();
3711    submit_database_write("save_active_review", move |_| {
3712        save_active_review_in(&database_path(), &session_id, &review)
3713    })
3714}
3715
3716fn save_active_review_in(path: &Path, session_id: &str, review: &StoredReview) -> Result<()> {
3717    let connection = open(path)?;
3718    connection.execute(
3719        "INSERT INTO second_opinion_reviews(
3720             session_id, workflow, generation, context_baseline, native_lost,
3721             reviewer_transcript
3722         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3723         ON CONFLICT(session_id) DO UPDATE SET
3724             workflow = excluded.workflow,
3725             generation = excluded.generation,
3726             context_baseline = excluded.context_baseline,
3727             native_lost = excluded.native_lost,
3728             reviewer_transcript = excluded.reviewer_transcript",
3729        params![
3730            session_id,
3731            serde_json::to_string(&review.workflow)?,
3732            i64::try_from(review.generation).unwrap_or(i64::MAX),
3733            i64::try_from(review.context_baseline).unwrap_or(i64::MAX),
3734            i64::from(review.native_lost),
3735            serde_json::to_string(&review.reviewer_transcript)?,
3736        ],
3737    )?;
3738    Ok(())
3739}
3740
3741/// Forgets the open review once it has finished.
3742pub fn clear_active_review(session_id: &str) -> Result<()> {
3743    let session_id = session_id.to_owned();
3744    submit_database_write("clear_active_review", move |_| {
3745        clear_active_review_in(&database_path(), &session_id)
3746    })
3747}
3748
3749fn clear_active_review_in(path: &Path, session_id: &str) -> Result<()> {
3750    let connection = open(path)?;
3751    connection.execute(
3752        "DELETE FROM second_opinion_reviews WHERE session_id = ?1",
3753        [session_id],
3754    )?;
3755    Ok(())
3756}
3757
3758/// How far `session_id` has been reviewed, or a fresh state when it has never
3759/// been reviewed.
3760pub fn turn_review_state(session_id: &str) -> Result<TurnReviewState> {
3761    turn_review_state_in(&database_path(), session_id)
3762}
3763
3764fn turn_review_state_in(path: &Path, session_id: &str) -> Result<TurnReviewState> {
3765    let connection = open_reader(path)?;
3766    let row = connection
3767        .query_row(
3768            "SELECT baselines, reviewed_through_ordinal, prior_review, active,
3769                    pending_forward
3770             FROM turn_review_state WHERE session_id = ?1",
3771            [session_id],
3772            |row| {
3773                Ok((
3774                    row.get::<_, String>(0)?,
3775                    row.get::<_, i64>(1)?,
3776                    row.get::<_, Option<String>>(2)?,
3777                    row.get::<_, Option<String>>(3)?,
3778                    row.get::<_, Option<String>>(4)?,
3779                ))
3780            },
3781        )
3782        .optional()?;
3783    let Some((baselines, ordinal, prior, active, pending_forward)) = row else {
3784        return Ok(TurnReviewState::default());
3785    };
3786    Ok(TurnReviewState {
3787        baselines: serde_json::from_str(&baselines).context("parse the stored review baselines")?,
3788        reviewed_through_ordinal: u64::try_from(ordinal).unwrap_or_default(),
3789        prior_review: prior
3790            .map(|prior| serde_json::from_str(&prior))
3791            .transpose()
3792            .context("parse the stored prior review")?,
3793        active,
3794        pending_forward: pending_forward
3795            .map(|pending| serde_json::from_str(&pending))
3796            .transpose()
3797            .context("parse the stored pending review handoff")?,
3798    })
3799}
3800
3801/// Records how far a session has been reviewed.
3802pub fn save_turn_review_state(session_id: &str, state: &TurnReviewState) -> Result<()> {
3803    let session_id = session_id.to_owned();
3804    let state = state.clone();
3805    submit_database_write("save_turn_review_state", move |_| {
3806        save_turn_review_state_in(&database_path(), &session_id, &state)
3807    })
3808}
3809
3810fn save_turn_review_state_in(path: &Path, session_id: &str, state: &TurnReviewState) -> Result<()> {
3811    let connection = open(path)?;
3812    connection.execute(
3813        "INSERT INTO turn_review_state(
3814             session_id, baselines, reviewed_through_ordinal, prior_review, active,
3815             pending_forward
3816         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3817         ON CONFLICT(session_id) DO UPDATE SET
3818             baselines = excluded.baselines,
3819             reviewed_through_ordinal = excluded.reviewed_through_ordinal,
3820             prior_review = excluded.prior_review,
3821             active = excluded.active,
3822             pending_forward = excluded.pending_forward",
3823        params![
3824            session_id,
3825            serde_json::to_string(&state.baselines)?,
3826            i64::try_from(state.reviewed_through_ordinal).unwrap_or(i64::MAX),
3827            state
3828                .prior_review
3829                .as_ref()
3830                .map(serde_json::to_string)
3831                .transpose()?,
3832            state.active,
3833            state
3834                .pending_forward
3835                .as_ref()
3836                .map(serde_json::to_string)
3837                .transpose()?,
3838        ],
3839    )?;
3840    Ok(())
3841}
3842
3843/// Clears every session's in-flight review flag.
3844///
3845/// A review that was running when the daemon stopped is not resumed: the
3846/// baseline never advanced, so the next review covers the same change, and
3847/// half a multi-agent fan-out is not worth rebuilding. A pending corrective
3848/// handoff is returned as well so the host can retry its exact command id.
3849/// Baselines are deliberately left alone, which is what makes interruption
3850/// lossless. Returns the sessions whose review or handoff was interrupted.
3851pub fn clear_interrupted_turn_reviews() -> Result<Vec<String>> {
3852    submit_database_write("clear_interrupted_turn_reviews", move |_| {
3853        clear_interrupted_turn_reviews_in(&database_path())
3854    })
3855}
3856
3857fn clear_interrupted_turn_reviews_in(path: &Path) -> Result<Vec<String>> {
3858    let connection = open(path)?;
3859    let interrupted = {
3860        let mut statement = connection.prepare(
3861            "SELECT session_id FROM turn_review_state
3862                 WHERE active IS NOT NULL OR pending_forward IS NOT NULL",
3863        )?;
3864        let mut rows = statement.query([])?;
3865        let mut interrupted = Vec::new();
3866        while let Some(row) = rows.next()? {
3867            interrupted.push(row.get::<_, String>(0)?);
3868        }
3869        interrupted
3870    };
3871    connection.execute(
3872        "UPDATE turn_review_state SET active = NULL WHERE active IS NOT NULL",
3873        [],
3874    )?;
3875    Ok(interrupted)
3876}
3877
3878/// Marks this session's reviewer conversation as no longer continuable, and
3879/// reports the generation a future review must start under.
3880///
3881/// Losing the target takes the reviewer's native session with it. The
3882/// materialized transcript is kept for reference, but the next review is a new
3883/// conversation, so it runs under a new generation.
3884pub fn lose_reviewer_continuity(session_id: &str) -> Result<u64> {
3885    let session_id = session_id.to_owned();
3886    submit_database_write("lose_reviewer_continuity", move |_| {
3887        lose_reviewer_continuity_in(&database_path(), &session_id)
3888    })
3889}
3890
3891fn lose_reviewer_continuity_in(path: &Path, session_id: &str) -> Result<u64> {
3892    let Some(mut review) = active_review_in(path, session_id)? else {
3893        return Ok(0);
3894    };
3895    if review.native_lost {
3896        return Ok(review.generation);
3897    }
3898    review.native_lost = true;
3899    review.generation = review.generation.saturating_add(1);
3900    save_active_review_in(path, session_id, &review)?;
3901    Ok(review.generation)
3902}
3903
3904pub fn replace_mount_history(host: &str, sources: &[PathBuf]) -> Result<()> {
3905    let host = host.to_owned();
3906    let sources = sources.to_vec();
3907    submit_database_write("replace_mount_history", move |_| {
3908        replace_mount_history_in(&database_path(), &host, &sources)
3909    })
3910}
3911
3912fn replace_mount_history_in(path: &Path, host: &str, sources: &[PathBuf]) -> Result<()> {
3913    let mut connection = open(path)?;
3914    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3915    write_mount_history(&tx, host, sources)?;
3916    tx.commit()?;
3917    Ok(())
3918}
3919
3920fn write_mount_history(tx: &Transaction<'_>, host: &str, sources: &[PathBuf]) -> Result<()> {
3921    tx.execute("DELETE FROM mount_history WHERE host = ?1", [host])?;
3922    let mut written = Vec::new();
3923    for source in sources.iter().take(20) {
3924        if written.contains(source) {
3925            continue;
3926        }
3927        tx.execute(
3928            "INSERT INTO mount_history(host, source, ordinal) VALUES (?1, ?2, ?3)",
3929            params![host, path_to_blob(source), written.len() as i64],
3930        )?;
3931        written.push(source.clone());
3932    }
3933    Ok(())
3934}
3935
3936fn write_host_container_size(
3937    tx: &Transaction<'_>,
3938    host: &str,
3939    size: HostContainerSize,
3940) -> Result<()> {
3941    ensure!(!host.trim().is_empty(), "container size host is empty");
3942    let cpus = i64::try_from(size.cpus).context("container CPU count exceeds SQLite range")?;
3943    let memory =
3944        i64::try_from(size.memory_bytes).context("container memory exceeds SQLite range")?;
3945    ensure!(
3946        cpus > 0 && memory > 0,
3947        "container size values must be positive"
3948    );
3949    tx.execute(
3950        "INSERT INTO host_container_sizes(host, cpus, memory_bytes)
3951         VALUES (?1, ?2, ?3)
3952         ON CONFLICT(host) DO UPDATE SET cpus = excluded.cpus, memory_bytes = excluded.memory_bytes",
3953        params![host, cpus, memory],
3954    )?;
3955    Ok(())
3956}
3957
3958pub fn remember_project_directory(host: &str, directory: &Path) -> Result<()> {
3959    let host = format!("project:{host}");
3960    let directory = directory.to_path_buf();
3961    submit_database_write("remember_project_directory", move |_| {
3962        remember_sources(&database_path(), &host, std::iter::once(directory))
3963    })
3964}
3965
3966fn remember_sources(
3967    path: &Path,
3968    host: &str,
3969    new_sources: impl IntoIterator<Item = PathBuf>,
3970) -> Result<()> {
3971    let mut connection = open(path)?;
3972    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
3973    let mut sources = {
3974        let mut statement =
3975            tx.prepare("SELECT source FROM mount_history WHERE host = ?1 ORDER BY ordinal")?;
3976        statement
3977            .query_map([host], |row| Ok(blob_to_path(row.get_ref(0)?.as_blob()?)))?
3978            .collect::<rusqlite::Result<Vec<_>>>()?
3979    };
3980    let additions = new_sources.into_iter().collect::<Vec<_>>();
3981    for source in additions.iter().rev() {
3982        sources.retain(|existing| existing != source);
3983        sources.insert(0, source.clone());
3984    }
3985    sources.truncate(20);
3986    write_mount_history(&tx, host, &sources)?;
3987    tx.commit()?;
3988    Ok(())
3989}
3990
3991pub fn record_recovery_success(
3992    session_id: &str,
3993    native_session_id: &str,
3994    checkpoint: &CheckpointMetadata,
3995) -> Result<()> {
3996    let session_id = session_id.to_owned();
3997    let native_session_id = native_session_id.to_owned();
3998    let checkpoint = checkpoint.clone();
3999    submit_database_write("record_recovery_success", move |_| {
4000        record_recovery_success_to(
4001            &database_path(),
4002            &session_id,
4003            &native_session_id,
4004            &checkpoint,
4005        )
4006    })
4007}
4008
4009fn record_recovery_success_to(
4010    path: &Path,
4011    session_id: &str,
4012    native_session_id: &str,
4013    checkpoint: &CheckpointMetadata,
4014) -> Result<()> {
4015    let mut connection = open(path)?;
4016    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4017    let changed = tx.execute(
4018        "UPDATE sessions
4019         SET native_session_id = ?2, last_checkpoint_error = NULL
4020         WHERE session_id = ?1",
4021        params![session_id, native_session_id],
4022    )?;
4023    if changed != 1 {
4024        bail!("unknown session {session_id}");
4025    }
4026    tx.execute(
4027        "INSERT INTO session_checkpoints(
4028             session_id, archive_path, sha256, created_at, event_frontier
4029         ) VALUES (?1,?2,?3,?4,?5)
4030         ON CONFLICT(session_id) DO UPDATE SET
4031             archive_path = excluded.archive_path,
4032             sha256 = excluded.sha256,
4033             created_at = excluded.created_at,
4034             event_frontier = excluded.event_frontier",
4035        params![
4036            session_id,
4037            path_to_blob(&checkpoint.archive_path),
4038            checkpoint.sha256,
4039            checkpoint.created_at,
4040            checkpoint.event_frontier,
4041        ],
4042    )?;
4043    tx.commit()?;
4044    Ok(())
4045}
4046
4047pub fn record_recovery_failure(session_id: &str, detail: &str) -> Result<()> {
4048    let session_id = session_id.to_owned();
4049    let detail = detail.to_owned();
4050    submit_database_write("record_recovery_failure", move |_| {
4051        record_recovery_failure_to(&database_path(), &session_id, &detail)
4052    })
4053}
4054
4055fn record_recovery_failure_to(path: &Path, session_id: &str, detail: &str) -> Result<()> {
4056    let connection = open(path)?;
4057    let changed = connection.execute(
4058        "UPDATE sessions SET last_checkpoint_error = ?2 WHERE session_id = ?1",
4059        params![session_id, detail],
4060    )?;
4061    if changed != 1 {
4062        bail!("unknown session {session_id}");
4063    }
4064    Ok(())
4065}
4066
4067pub fn save_state_to(path: &Path, state: &State) -> Result<()> {
4068    state.validate()?;
4069    let mut connection = open(path)?;
4070    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4071    let existing_contexts = existing_contexts(&tx)?;
4072    let existing_sessions = {
4073        let mut statement = tx.prepare("SELECT session_id FROM sessions")?;
4074        statement
4075            .query_map([], |row| row.get::<_, String>(0))?
4076            .collect::<rusqlite::Result<Vec<_>>>()?
4077    };
4078    tx.execute(
4079        "DELETE FROM subagent_sessions
4080         WHERE child_session_id NOT IN (SELECT session_id FROM sessions)
4081            OR parent_session_id NOT IN (SELECT session_id FROM sessions)",
4082        [],
4083    )?;
4084    let existing_subagents = {
4085        let mut statement =
4086            tx.prepare("SELECT child_session_id, parent_session_id FROM subagent_sessions")?;
4087        statement
4088            .query_map([], |row| {
4089                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4090            })?
4091            .collect::<rusqlite::Result<Vec<_>>>()?
4092    };
4093    for (child_id, parent_id) in existing_subagents {
4094        if !state.subagents.contains_key(&child_id)
4095            || !state.sessions.contains_key(&child_id)
4096            || !state.sessions.contains_key(&parent_id)
4097        {
4098            tx.execute(
4099                "DELETE FROM subagent_sessions WHERE child_session_id = ?1",
4100                [child_id],
4101            )?;
4102        }
4103    }
4104    for session_id in existing_sessions {
4105        if !state.sessions.contains_key(&session_id) {
4106            tx.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
4107        }
4108    }
4109    tx.execute("DELETE FROM mount_history", [])?;
4110    tx.execute("DELETE FROM host_container_sizes", [])?;
4111    for session in state.sessions.values() {
4112        if let Some((existing_bundle, existing_workspace)) = existing_contexts.get(&session.id) {
4113            ensure!(
4114                existing_bundle == &session.bundle_id,
4115                "session {} was already associated with bundle {}, not {}",
4116                session.id,
4117                existing_bundle,
4118                session.bundle_id
4119            );
4120            ensure!(
4121                existing_workspace == &session.workspace_id,
4122                "session {} was already associated with workspace {}, not {}",
4123                session.id,
4124                existing_workspace,
4125                session.workspace_id
4126            );
4127        }
4128        insert_session(&tx, session)?;
4129    }
4130    for subagent in state.subagents.values() {
4131        let record_json = serde_json::to_string(subagent)?;
4132        tx.execute(
4133            "INSERT INTO subagent_sessions(
4134                 child_session_id, parent_session_id, request_key, record_json
4135             ) VALUES (?1, ?2, ?3, ?4)
4136             ON CONFLICT(child_session_id) DO UPDATE SET
4137                 parent_session_id = excluded.parent_session_id,
4138                 request_key = excluded.request_key,
4139                 record_json = excluded.record_json",
4140            params![
4141                subagent.child_session_id,
4142                subagent.parent_session_id,
4143                subagent.request_key,
4144                record_json
4145            ],
4146        )?;
4147    }
4148    for (host, sources) in &state.mount_history {
4149        for (ordinal, source) in sources.iter().enumerate() {
4150            tx.execute(
4151                "INSERT INTO mount_history(host, source, ordinal) VALUES (?1, ?2, ?3)",
4152                params![host, path_to_blob(source), ordinal as i64],
4153            )?;
4154        }
4155    }
4156    for (host, size) in &state.container_sizes {
4157        write_host_container_size(&tx, host, *size)?;
4158    }
4159    tx.commit()?;
4160    Ok(())
4161}
4162
4163fn existing_contexts(tx: &Transaction<'_>) -> Result<BTreeMap<String, (String, String)>> {
4164    let mut statement =
4165        tx.prepare("SELECT session_id, bundle_id, workspace_id FROM session_contexts")?;
4166    let rows = statement.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?))))?;
4167    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
4168}
4169
4170fn session_exists(tx: &Transaction<'_>, session_id: &str) -> Result<bool> {
4171    Ok(tx
4172        .query_row(
4173            "SELECT 1 FROM sessions WHERE session_id = ?1",
4174            [session_id],
4175            |_| Ok(()),
4176        )
4177        .optional()?
4178        .is_some())
4179}
4180
4181fn write_materialized_session(
4182    tx: &Transaction<'_>,
4183    materialized: &MaterializedSession,
4184) -> Result<()> {
4185    let (execution, running_started_at_ms) = materialized_execution_columns(materialized.execution);
4186    tx.execute(
4187        "INSERT INTO materialized_sessions(
4188             session_id, applied_event_ordinal, applied_event_digest, execution_state,
4189             running_started_at_ms, session_title, configuration_json, last_activity_at_ms,
4190             pending_elicitations_json, active_turn_json, last_turn_outcome_json
4191         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
4192         ON CONFLICT(session_id) DO UPDATE SET
4193             applied_event_ordinal = excluded.applied_event_ordinal,
4194             applied_event_digest = excluded.applied_event_digest,
4195             execution_state = excluded.execution_state,
4196             running_started_at_ms = excluded.running_started_at_ms,
4197             session_title = excluded.session_title,
4198             configuration_json = excluded.configuration_json,
4199             last_activity_at_ms = excluded.last_activity_at_ms,
4200             pending_elicitations_json = excluded.pending_elicitations_json,
4201             active_turn_json = excluded.active_turn_json,
4202             last_turn_outcome_json = excluded.last_turn_outcome_json",
4203        params![
4204            materialized.session_id,
4205            materialized.applied_event_ordinal,
4206            materialized.applied_event_digest,
4207            execution,
4208            running_started_at_ms,
4209            materialized.session_title,
4210            serde_json::to_string(&materialized.configuration)?,
4211            materialized.last_activity_at_ms,
4212            serde_json::to_string(&materialized.pending_elicitations)?,
4213            materialized
4214                .active_turn
4215                .as_ref()
4216                .map(serde_json::to_string)
4217                .transpose()?,
4218            materialized
4219                .last_turn_outcome
4220                .as_ref()
4221                .map(serde_json::to_string)
4222                .transpose()?,
4223        ],
4224    )?;
4225    tx.execute(
4226        "DELETE FROM materialized_transcript_items WHERE session_id = ?1",
4227        [materialized.session_id.as_str()],
4228    )?;
4229    for item in &materialized.transcript {
4230        upsert_transcript_item(tx, &materialized.session_id, item)?;
4231    }
4232    replace_materialized_queue(tx, &materialized.session_id, &materialized.queued_prompts)?;
4233    Ok(())
4234}
4235
4236fn upsert_transcript_item(
4237    tx: &Transaction<'_>,
4238    session_id: &str,
4239    item: &TranscriptItem,
4240) -> Result<()> {
4241    let existing = tx
4242        .query_row(
4243            "SELECT position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms
4244             FROM materialized_transcript_items
4245             WHERE session_id = ?1 AND stable_id = ?2",
4246            params![session_id, item.stable_id],
4247            |row| {
4248                Ok((
4249                    row.get::<_, u64>(0)?,
4250                    row.get::<_, Option<u64>>(1)?,
4251                    row.get::<_, i64>(2)?,
4252                    row.get::<_, i64>(3)?,
4253                ))
4254            },
4255        )
4256        .optional()?;
4257    if let Some((position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms)) =
4258        existing
4259    {
4260        if position != item.position || created_at_ms != item.created_at_ms {
4261            return Err(ProjectionIntegrityError(format!(
4262                "transcript item {:?} changed immutable identity fields",
4263                item.stable_id
4264            ))
4265            .into());
4266        }
4267        if item.last_changed_at_ms < last_changed_at_ms {
4268            return Err(ProjectionIntegrityError(format!(
4269                "transcript item {:?} moved its changed timestamp backwards",
4270                item.stable_id
4271            ))
4272            .into());
4273        }
4274        if latest_content_event_ordinal.is_some_and(|existing| {
4275            item.latest_content_event_ordinal
4276                .is_none_or(|next| next < existing)
4277        }) {
4278            return Err(ProjectionIntegrityError(format!(
4279                "transcript item {:?} moved its latest content ordinal backwards",
4280                item.stable_id
4281            ))
4282            .into());
4283        }
4284        tx.execute(
4285            "UPDATE materialized_transcript_items
4286             SET latest_content_event_ordinal = ?3, last_changed_at_ms = ?4, body_json = ?5
4287             WHERE session_id = ?1 AND stable_id = ?2",
4288            params![
4289                session_id,
4290                item.stable_id,
4291                item.latest_content_event_ordinal,
4292                item.last_changed_at_ms,
4293                serde_json::to_string(&item.body)?,
4294            ],
4295        )?;
4296    } else {
4297        tx.execute(
4298            "INSERT INTO materialized_transcript_items(
4299                 session_id, stable_id, position, latest_content_event_ordinal,
4300                 created_at_ms, last_changed_at_ms, body_json
4301             ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
4302            params![
4303                session_id,
4304                item.stable_id,
4305                item.position,
4306                item.latest_content_event_ordinal,
4307                item.created_at_ms,
4308                item.last_changed_at_ms,
4309                serde_json::to_string(&item.body)?,
4310            ],
4311        )?;
4312    }
4313    Ok(())
4314}
4315
4316fn replace_materialized_queue(
4317    tx: &Transaction<'_>,
4318    session_id: &str,
4319    queued_prompts: &[MaterializedQueuedPrompt],
4320) -> Result<()> {
4321    let mut command_ids = BTreeSet::new();
4322    for prompt in queued_prompts {
4323        if prompt.command_id.trim().is_empty() {
4324            bail!("materialized prompt queue has an empty command id");
4325        }
4326        if !command_ids.insert(prompt.command_id.as_str()) {
4327            bail!(
4328                "materialized prompt queue contains duplicate command {:?}",
4329                prompt.command_id
4330            );
4331        }
4332    }
4333    tx.execute(
4334        "DELETE FROM materialized_queued_prompts WHERE session_id = ?1",
4335        [session_id],
4336    )?;
4337    for (ordinal, prompt) in queued_prompts.iter().enumerate() {
4338        tx.execute(
4339            "INSERT INTO materialized_queued_prompts(
4340                 session_id, ordinal, command_id, kind_json, content_json, queued_at_ms,
4341                 accepted_ordinal
4342             ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
4343            params![
4344                session_id,
4345                ordinal as i64,
4346                prompt.command_id,
4347                serde_json::to_string(&prompt.kind)?,
4348                serde_json::to_string(&prompt.content)?,
4349                prompt.queued_at_ms,
4350                prompt.accepted_ordinal,
4351            ],
4352        )?;
4353    }
4354    Ok(())
4355}
4356
4357fn materialized_execution_columns(
4358    execution: MaterializedExecutionState,
4359) -> (&'static str, Option<i64>) {
4360    match execution {
4361        MaterializedExecutionState::Idle => ("idle", None),
4362        MaterializedExecutionState::Running { started_at_ms } => ("running", Some(started_at_ms)),
4363        MaterializedExecutionState::Closing => ("closing", None),
4364        MaterializedExecutionState::Closed => ("closed", None),
4365    }
4366}
4367
4368fn parse_materialized_execution(
4369    execution: &str,
4370    running_started_at_ms: Option<i64>,
4371) -> Result<MaterializedExecutionState> {
4372    match (execution, running_started_at_ms) {
4373        ("idle", None) => Ok(MaterializedExecutionState::Idle),
4374        ("running", Some(started_at_ms)) => {
4375            Ok(MaterializedExecutionState::Running { started_at_ms })
4376        }
4377        ("closing", None) => Ok(MaterializedExecutionState::Closing),
4378        ("closed", None) => Ok(MaterializedExecutionState::Closed),
4379        _ => bail!("invalid materialized execution state {execution:?}"),
4380    }
4381}
4382
4383/// Write every field of a session, including the ones other writers own.
4384/// Only a flow that authors the whole record — creation, import, resume, or
4385/// orphan adoption — may use this.
4386fn insert_session(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4387    tx.execute(
4388        "INSERT INTO session_contexts(session_id, bundle_id, created_at, workspace_id)
4389         VALUES (?1, ?2, ?3, ?4)
4390         ON CONFLICT(session_id) DO NOTHING",
4391        params![
4392            session.id,
4393            session.bundle_id,
4394            session.created_at,
4395            session.workspace_id
4396        ],
4397    )?;
4398    let (stored_bundle, stored_workspace): (String, String) = tx.query_row(
4399        "SELECT bundle_id, workspace_id FROM session_contexts WHERE session_id = ?1",
4400        [session.id.as_str()],
4401        |row| Ok((row.get(0)?, row.get(1)?)),
4402    )?;
4403    ensure!(
4404        stored_bundle == session.bundle_id,
4405        "session {} belongs to bundle {}, not {}",
4406        session.id,
4407        stored_bundle,
4408        session.bundle_id
4409    );
4410    ensure!(
4411        stored_workspace == session.workspace_id,
4412        "session {} belongs to workspace {}, not {}",
4413        session.id,
4414        stored_workspace,
4415        session.workspace_id
4416    );
4417    tx.execute(
4418        "INSERT INTO sessions(
4419             session_id, title, harness_kind, last_profile, target_template_id, state,
4420             native_session_id, acp_session_title, session_title_override, updated_at,
4421             viewed_through_event_ordinal, last_error, resource_allocation,
4422             last_checkpoint_error, project_directory, managed_worktree,
4423             container_cpus, container_memory, archived, draft_input, create_managed_worktree,
4424             mjolnir_subagents
4425         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22)
4426         ON CONFLICT(session_id) DO UPDATE SET
4427             title = excluded.title,
4428             harness_kind = excluded.harness_kind,
4429             last_profile = excluded.last_profile,
4430             target_template_id = excluded.target_template_id,
4431             state = excluded.state,
4432             native_session_id = excluded.native_session_id,
4433             acp_session_title = excluded.acp_session_title,
4434             session_title_override = excluded.session_title_override,
4435             updated_at = excluded.updated_at,
4436             viewed_through_event_ordinal = max(
4437                 sessions.viewed_through_event_ordinal,
4438                 excluded.viewed_through_event_ordinal
4439             ),
4440             last_error = excluded.last_error,
4441             resource_allocation = excluded.resource_allocation,
4442             last_checkpoint_error = excluded.last_checkpoint_error,
4443             project_directory = excluded.project_directory,
4444             managed_worktree = excluded.managed_worktree,
4445             container_cpus = excluded.container_cpus,
4446             container_memory = excluded.container_memory,
4447             archived = excluded.archived,
4448             create_managed_worktree = excluded.create_managed_worktree,
4449             mjolnir_subagents = excluded.mjolnir_subagents",
4450        params![
4451            session.id,
4452            session.title,
4453            session.harness_kind.id(),
4454            session.last_profile,
4455            session.target_template_id,
4456            session_state_name(session.state),
4457            session.native_session_id,
4458            session.acp_session_title,
4459            session.session_title_override,
4460            session.updated_at,
4461            session.viewed_through_event_ordinal,
4462            session.last_error,
4463            session
4464                .resource_allocation
4465                .as_ref()
4466                .map(serde_json::to_string)
4467                .transpose()?,
4468            session.last_checkpoint_error,
4469            session
4470                .project_directory
4471                .as_ref()
4472                .map(|path| path_to_blob(path)),
4473            session
4474                .managed_worktree
4475                .as_ref()
4476                .map(serde_json::to_string)
4477                .transpose()?,
4478            session.container_cpus,
4479            session.container_memory,
4480            session.archived,
4481            session.draft_input,
4482            session.create_managed_worktree,
4483            session.mjolnir_subagents,
4484        ],
4485    )?;
4486    tx.execute(
4487        "INSERT INTO materialized_sessions(session_id) VALUES (?1)
4488         ON CONFLICT(session_id) DO NOTHING",
4489        [session.id.as_str()],
4490    )?;
4491    replace_targets(tx, session)?;
4492    tx.execute(
4493        "DELETE FROM session_mounts WHERE session_id = ?1",
4494        [session.id.as_str()],
4495    )?;
4496    for (ordinal, mount) in session.additional_mounts.iter().enumerate() {
4497        tx.execute(
4498            "INSERT INTO session_mounts(session_id, ordinal, source, destination, read_only)
4499             VALUES (?1, ?2, ?3, ?4, ?5)",
4500            params![
4501                session.id,
4502                ordinal as i64,
4503                path_to_blob(&mount.source),
4504                path_to_blob(&mount.destination),
4505                mount.read_only
4506            ],
4507        )?;
4508    }
4509    replace_checkpoint(tx, session)?;
4510    Ok(())
4511}
4512
4513/// Update the columns a lifecycle transition owns, plus the target locator
4514/// that provisioning and teardown maintain with them. The row must exist:
4515/// a transition never resurrects a session another writer deleted.
4516fn update_lifecycle_fields(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4517    let changed = tx.execute(
4518        // The detach ordinal only ever moves forward, so a transition that
4519        // started before a detach receipt cannot rewind it.
4520        "UPDATE sessions
4521         SET title = ?2,
4522             harness_kind = ?3,
4523             last_profile = ?4,
4524             target_template_id = ?5,
4525             state = ?6,
4526             updated_at = ?7,
4527             viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?8),
4528             last_error = ?9,
4529             resource_allocation = ?10,
4530             last_checkpoint_error = ?11,
4531             project_directory = ?12,
4532             managed_worktree = ?13
4533         WHERE session_id = ?1",
4534        params![
4535            session.id,
4536            session.title,
4537            session.harness_kind.id(),
4538            session.last_profile,
4539            session.target_template_id,
4540            session_state_name(session.state),
4541            session.updated_at,
4542            session.viewed_through_event_ordinal,
4543            session.last_error,
4544            session
4545                .resource_allocation
4546                .as_ref()
4547                .map(serde_json::to_string)
4548                .transpose()?,
4549            session.last_checkpoint_error,
4550            session
4551                .project_directory
4552                .as_ref()
4553                .map(|path| path_to_blob(path)),
4554            session
4555                .managed_worktree
4556                .as_ref()
4557                .map(serde_json::to_string)
4558                .transpose()?,
4559        ],
4560    )?;
4561    if changed != 1 {
4562        bail!("unknown session {}", session.id);
4563    }
4564    replace_targets(tx, session)
4565}
4566
4567fn replace_targets(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4568    tx.execute(
4569        "DELETE FROM session_targets WHERE session_id = ?1",
4570        [session.id.as_str()],
4571    )?;
4572    if let Some(target) = &session.target {
4573        insert_target(tx, &session.id, target)?;
4574    }
4575    Ok(())
4576}
4577
4578fn replace_checkpoint(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
4579    tx.execute(
4580        "DELETE FROM session_checkpoints WHERE session_id = ?1",
4581        [session.id.as_str()],
4582    )?;
4583    if let Some(checkpoint) = &session.checkpoint {
4584        tx.execute(
4585            "INSERT INTO session_checkpoints(session_id, archive_path, sha256, created_at, event_frontier)
4586             VALUES (?1, ?2, ?3, ?4, ?5)",
4587            params![
4588                session.id,
4589                path_to_blob(&checkpoint.archive_path),
4590                checkpoint.sha256,
4591                checkpoint.created_at,
4592                checkpoint.event_frontier,
4593            ],
4594        )?;
4595    }
4596    Ok(())
4597}
4598
4599fn insert_target(tx: &Transaction<'_>, session_id: &str, target: &TargetLocator) -> Result<()> {
4600    let (kind, host, resource, address, workspace, worker_id, workspace_storage) = match target {
4601        TargetLocator::LocalBare { worker_root } => (
4602            "local-bare",
4603            None,
4604            None,
4605            None,
4606            Some(path_to_blob(worker_root)),
4607            None,
4608            None,
4609        ),
4610        TargetLocator::LocalPodman {
4611            container_id,
4612            workspace_storage,
4613        } => (
4614            "local-podman",
4615            None,
4616            Some(container_id.as_str()),
4617            None,
4618            None,
4619            None,
4620            Some(serde_json::to_string(workspace_storage)?),
4621        ),
4622        TargetLocator::LocalDocker { container_id } => (
4623            "local-docker",
4624            None,
4625            Some(container_id.as_str()),
4626            None,
4627            None,
4628            None,
4629            None,
4630        ),
4631        TargetLocator::SshDocker { host, container_id } => (
4632            "ssh-docker",
4633            Some(host.as_str()),
4634            Some(container_id.as_str()),
4635            None,
4636            None,
4637            None,
4638            None,
4639        ),
4640        TargetLocator::AppleContainer { container_id } => (
4641            "apple-container",
4642            None,
4643            Some(container_id.as_str()),
4644            None,
4645            None,
4646            None,
4647            None,
4648        ),
4649        TargetLocator::AwsEc2 {
4650            instance_id,
4651            address,
4652        } => (
4653            "aws-ec2",
4654            None,
4655            Some(instance_id.as_str()),
4656            address.as_deref(),
4657            None,
4658            None,
4659            None,
4660        ),
4661        TargetLocator::SshBare {
4662            host,
4663            workspace,
4664            worker_id,
4665        } => (
4666            "ssh-bare",
4667            Some(host.as_str()),
4668            None,
4669            None,
4670            Some(path_to_blob(workspace)),
4671            worker_id.as_deref(),
4672            None,
4673        ),
4674        TargetLocator::SshPodman {
4675            host,
4676            container_id,
4677            workspace_storage,
4678        } => (
4679            "ssh-podman",
4680            Some(host.as_str()),
4681            Some(container_id.as_str()),
4682            None,
4683            None,
4684            None,
4685            Some(serde_json::to_string(workspace_storage)?),
4686        ),
4687    };
4688    tx.execute(
4689        "INSERT INTO session_targets(session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage)
4690         VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
4691        params![session_id, kind, host, resource, address, workspace, worker_id, workspace_storage],
4692    )?;
4693    Ok(())
4694}
4695
4696fn load_targets(connection: &Connection, state: &mut State) -> Result<()> {
4697    let mut statement = connection.prepare(
4698        "SELECT session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage
4699         FROM session_targets",
4700    )?;
4701    let rows = statement.query_map([], |row| {
4702        let session_id: String = row.get(0)?;
4703        let kind: String = row.get(1)?;
4704        let host: Option<String> = row.get(2)?;
4705        let resource: Option<String> = row.get(3)?;
4706        let address: Option<String> = row.get(4)?;
4707        let workspace = row.get_ref(5)?.blob_or_null()?.map(blob_to_path);
4708        let worker_id: Option<String> = row.get(6)?;
4709        let workspace_storage = row
4710            .get::<_, Option<String>>(7)?
4711            .map(|serialized| {
4712                serde_json::from_str(&serialized).map_err(|error| {
4713                    rusqlite::Error::FromSqlConversionFailure(7, Type::Text, Box::new(error))
4714                })
4715            })
4716            .transpose()?
4717            .unwrap_or_default();
4718        let target = match kind.as_str() {
4719            "local-bare" => TargetLocator::LocalBare {
4720                worker_root: workspace.unwrap(),
4721            },
4722            "local-podman" => TargetLocator::LocalPodman {
4723                container_id: resource.unwrap(),
4724                workspace_storage,
4725            },
4726            "local-docker" => TargetLocator::LocalDocker {
4727                container_id: resource.unwrap(),
4728            },
4729            "apple-container" => TargetLocator::AppleContainer {
4730                container_id: resource.unwrap(),
4731            },
4732            "aws-ec2" => TargetLocator::AwsEc2 {
4733                instance_id: resource.unwrap(),
4734                address,
4735            },
4736            "ssh-bare" => TargetLocator::SshBare {
4737                host: host.unwrap(),
4738                workspace: workspace.unwrap(),
4739                worker_id,
4740            },
4741            "ssh-docker" => TargetLocator::SshDocker {
4742                host: host.unwrap(),
4743                container_id: resource.unwrap(),
4744            },
4745            "ssh-podman" => TargetLocator::SshPodman {
4746                host: host.unwrap(),
4747                container_id: resource.unwrap(),
4748                workspace_storage,
4749            },
4750            _ => unreachable!("target kind constrained by schema"),
4751        };
4752        Ok((session_id, target))
4753    })?;
4754    for row in rows {
4755        let (session_id, target) = row?;
4756        state.sessions.get_mut(&session_id).unwrap().target = Some(target);
4757    }
4758    Ok(())
4759}
4760
4761fn load_mounts(connection: &Connection, state: &mut State) -> Result<()> {
4762    let mut statement = connection.prepare(
4763        "SELECT session_id, source, destination, read_only
4764         FROM session_mounts ORDER BY session_id, ordinal",
4765    )?;
4766    let rows = statement.query_map([], |row| {
4767        Ok((
4768            row.get::<_, String>(0)?,
4769            AdditionalMount {
4770                source: blob_to_path(row.get_ref(1)?.as_blob()?),
4771                destination: blob_to_path(row.get_ref(2)?.as_blob()?),
4772                read_only: row.get(3)?,
4773            },
4774        ))
4775    })?;
4776    for row in rows {
4777        let (session_id, mount) = row?;
4778        state
4779            .sessions
4780            .get_mut(&session_id)
4781            .unwrap()
4782            .additional_mounts
4783            .push(mount);
4784    }
4785    Ok(())
4786}
4787
4788fn load_checkpoints(connection: &Connection, state: &mut State) -> Result<()> {
4789    let mut statement = connection.prepare(
4790        "SELECT session_id, archive_path, sha256, created_at, event_frontier FROM session_checkpoints",
4791    )?;
4792    let rows = statement.query_map([], |row| {
4793        Ok((
4794            row.get::<_, String>(0)?,
4795            CheckpointMetadata {
4796                archive_path: blob_to_path(row.get_ref(1)?.as_blob()?),
4797                sha256: row.get(2)?,
4798                created_at: row.get(3)?,
4799                event_frontier: row.get(4)?,
4800            },
4801        ))
4802    })?;
4803    for row in rows {
4804        let (session_id, checkpoint) = row?;
4805        state.sessions.get_mut(&session_id).unwrap().checkpoint = Some(checkpoint);
4806    }
4807    Ok(())
4808}
4809
4810/// Re-associate a session with another project bundle.
4811///
4812/// A session's bundle is otherwise fixed, because prompt history is grouped by
4813/// it. Resume calls this when it converts a session between its raw and bundle
4814/// representations: the project is the same, so its history follows it, and
4815/// only the name Hel files it under changes.
4816pub fn rebind_session_bundle(session_id: &str, bundle_id: &str) -> Result<()> {
4817    let session_id = session_id.to_owned();
4818    let bundle_id = bundle_id.to_owned();
4819    submit_database_write("rebind_session_bundle", move |_| {
4820        rebind_session_bundle_to(&database_path(), &session_id, &bundle_id)
4821    })
4822}
4823
4824fn rebind_session_bundle_to(path: &Path, session_id: &str, bundle_id: &str) -> Result<()> {
4825    let mut connection = open(path)?;
4826    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4827    let changed = tx.execute(
4828        "UPDATE session_contexts SET bundle_id = ?2 WHERE session_id = ?1",
4829        params![session_id, bundle_id],
4830    )?;
4831    if changed == 0 {
4832        tx.execute(
4833            "INSERT INTO session_contexts(session_id, bundle_id, created_at) VALUES (?1, ?2, ?3)",
4834            params![session_id, bundle_id, Utc::now().to_rfc3339()],
4835        )?;
4836    }
4837    tx.commit()?;
4838    Ok(())
4839}
4840
4841pub fn record_prompt(
4842    session_id: &str,
4843    bundle_id: &str,
4844    event_ordinal: u64,
4845    submitted_at: Option<&str>,
4846    text: &str,
4847) -> Result<()> {
4848    let session_id = session_id.to_owned();
4849    let bundle_id = bundle_id.to_owned();
4850    let submitted_at = submitted_at.map(str::to_owned);
4851    let text = text.to_owned();
4852    submit_database_write("record_prompt", move |_| {
4853        record_prompt_to(
4854            &database_path(),
4855            &session_id,
4856            &bundle_id,
4857            event_ordinal,
4858            submitted_at.as_deref(),
4859            &text,
4860        )
4861    })
4862}
4863
4864fn record_prompt_to(
4865    path: &Path,
4866    session_id: &str,
4867    bundle_id: &str,
4868    event_ordinal: u64,
4869    submitted_at: Option<&str>,
4870    text: &str,
4871) -> Result<()> {
4872    if text.trim().is_empty() {
4873        return Ok(());
4874    }
4875    let mut connection = open(path)?;
4876    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4877    tx.execute(
4878        "INSERT INTO session_contexts(session_id, bundle_id, created_at) VALUES (?1, ?2, ?3)
4879         ON CONFLICT(session_id) DO NOTHING",
4880        params![session_id, bundle_id, submitted_at.unwrap_or("unknown")],
4881    )?;
4882    let actual_bundle: String = tx.query_row(
4883        "SELECT bundle_id FROM session_contexts WHERE session_id = ?1",
4884        [session_id],
4885        |row| row.get(0),
4886    )?;
4887    if actual_bundle != bundle_id {
4888        bail!("session {session_id} belongs to bundle {actual_bundle}, not {bundle_id}");
4889    }
4890    tx.execute(
4891        "INSERT INTO prompt_history(session_id, event_ordinal, submitted_at, text)
4892         VALUES (?1, ?2, ?3, ?4)
4893         ON CONFLICT(session_id, event_ordinal) DO NOTHING",
4894        params![
4895            session_id,
4896            event_ordinal,
4897            submitted_at
4898                .map(str::to_owned)
4899                .unwrap_or_else(|| Utc::now().to_rfc3339()),
4900            text,
4901        ],
4902    )?;
4903    tx.commit()?;
4904    Ok(())
4905}
4906
4907pub fn search_prompts(
4908    session_id: &str,
4909    bundle_id: &str,
4910    scope: HistoryScope,
4911    query: &str,
4912) -> Result<Vec<PromptHistoryEntry>> {
4913    search_prompts_from(&database_path(), session_id, bundle_id, scope, query)
4914}
4915
4916/// Search prompt history, stopping at `limit` matches.
4917///
4918/// `search_prompts` pages through the whole table and stops only when a page
4919/// comes back short, which is fine for a terminal running it against a local
4920/// database and is not something an HTTP route may reach.
4921pub fn search_prompts_bounded(
4922    session_id: &str,
4923    bundle_id: &str,
4924    scope: HistoryScope,
4925    query: &str,
4926    limit: usize,
4927) -> Result<BoundedPromptHistory> {
4928    search_prompts_bounded_from(&database_path(), session_id, bundle_id, scope, query, limit)
4929}
4930
4931fn search_prompts_bounded_from(
4932    path: &Path,
4933    session_id: &str,
4934    bundle_id: &str,
4935    scope: HistoryScope,
4936    query: &str,
4937    limit: usize,
4938) -> Result<BoundedPromptHistory> {
4939    const PAGE_SIZE: usize = 256;
4940    /// How many rows the search may read before giving up on finding more.
4941    /// A query that matches nothing must not walk an unbounded history.
4942    const MAX_ROWS_SCANNED: usize = 4_096;
4943
4944    let connection = open_reader(path)?;
4945    let query = query.to_lowercase();
4946    let mut seen = std::collections::HashSet::new();
4947    let mut matches = Vec::new();
4948    let mut before = i64::MAX;
4949    let mut scanned = 0;
4950    let mut truncated = false;
4951    loop {
4952        let page = match scope {
4953            HistoryScope::Project => query_history_page(
4954                &connection,
4955                "SELECT h.history_id, h.session_id, h.text
4956                 FROM prompt_history h JOIN session_contexts c USING(session_id)
4957                 WHERE c.bundle_id = ?1 AND h.history_id < ?2
4958                 ORDER BY h.history_id DESC LIMIT ?3",
4959                params![bundle_id, before, PAGE_SIZE as i64],
4960            )?,
4961            HistoryScope::Session => query_history_page(
4962                &connection,
4963                "SELECT history_id, session_id, text FROM prompt_history
4964                 WHERE session_id = ?1 AND history_id < ?2
4965                 ORDER BY history_id DESC LIMIT ?3",
4966                params![session_id, before, PAGE_SIZE as i64],
4967            )?,
4968            HistoryScope::All => query_history_page(
4969                &connection,
4970                "SELECT history_id, session_id, text FROM prompt_history
4971                 WHERE history_id < ?1 ORDER BY history_id DESC LIMIT ?2",
4972                params![before, PAGE_SIZE as i64],
4973            )?,
4974        };
4975        let page_len = page.len();
4976        for entry in page {
4977            before = entry.id;
4978            scanned += 1;
4979            if entry.text.to_lowercase().contains(&query) && seen.insert(entry.text.clone()) {
4980                if matches.len() == limit {
4981                    truncated = true;
4982                    break;
4983                }
4984                matches.push(entry);
4985            }
4986        }
4987        if truncated || page_len < PAGE_SIZE {
4988            break;
4989        }
4990        if scanned >= MAX_ROWS_SCANNED {
4991            truncated = true;
4992            break;
4993        }
4994    }
4995    Ok(BoundedPromptHistory {
4996        entries: matches,
4997        truncated,
4998    })
4999}
5000
5001fn search_prompts_from(
5002    path: &Path,
5003    session_id: &str,
5004    bundle_id: &str,
5005    scope: HistoryScope,
5006    query: &str,
5007) -> Result<Vec<PromptHistoryEntry>> {
5008    const PAGE_SIZE: usize = 256;
5009    let connection = open_reader(path)?;
5010    let query = query.to_lowercase();
5011    let mut seen = std::collections::HashSet::new();
5012    let mut matches = Vec::new();
5013    let mut before = i64::MAX;
5014    loop {
5015        let page = match scope {
5016            HistoryScope::Project => query_history_page(
5017                &connection,
5018                "SELECT h.history_id, h.session_id, h.text
5019                 FROM prompt_history h JOIN session_contexts c USING(session_id)
5020                 WHERE c.bundle_id = ?1 AND h.history_id < ?2
5021                 ORDER BY h.history_id DESC LIMIT ?3",
5022                params![bundle_id, before, PAGE_SIZE as i64],
5023            )?,
5024            HistoryScope::Session => query_history_page(
5025                &connection,
5026                "SELECT history_id, session_id, text FROM prompt_history
5027                 WHERE session_id = ?1 AND history_id < ?2
5028                 ORDER BY history_id DESC LIMIT ?3",
5029                params![session_id, before, PAGE_SIZE as i64],
5030            )?,
5031            HistoryScope::All => query_history_page(
5032                &connection,
5033                "SELECT history_id, session_id, text FROM prompt_history
5034                 WHERE history_id < ?1 ORDER BY history_id DESC LIMIT ?2",
5035                params![before, PAGE_SIZE as i64],
5036            )?,
5037        };
5038        let page_len = page.len();
5039        for entry in page {
5040            before = entry.id;
5041            if entry.text.to_lowercase().contains(&query) && seen.insert(entry.text.clone()) {
5042                matches.push(entry);
5043            }
5044        }
5045        if page_len < PAGE_SIZE {
5046            break;
5047        }
5048    }
5049    Ok(matches)
5050}
5051
5052fn query_history_page(
5053    connection: &Connection,
5054    sql: &str,
5055    parameters: impl rusqlite::Params,
5056) -> Result<Vec<PromptHistoryEntry>> {
5057    let mut statement = connection.prepare_cached(sql)?;
5058    let rows = statement.query_map(parameters, |row| {
5059        Ok(PromptHistoryEntry {
5060            id: row.get(0)?,
5061            session_id: row.get(1)?,
5062            text: row.get(2)?,
5063        })
5064    })?;
5065    rows.collect::<rusqlite::Result<Vec<_>>>()
5066        .map_err(Into::into)
5067}
5068
5069pub fn migrate_legacy_state() -> Result<()> {
5070    let legacy = mj_core::state::state_path();
5071    let database = database_path();
5072    migrate_legacy_state_from(&legacy, &database)
5073}
5074
5075fn migrate_legacy_state_from(legacy: &Path, database: &Path) -> Result<()> {
5076    if !legacy.exists() {
5077        return Ok(());
5078    }
5079    // The database may exist after an interrupted migration. The legacy file
5080    // remains the authority until the import commits and this file is renamed.
5081    let mut state = State::load_json_from(legacy)?;
5082    // Legacy worker sequence numbers are not relay event ordinals. Carrying
5083    // them across the new compatibility floor could mark unseen relay events
5084    // as read.
5085    for session in state.sessions.values_mut() {
5086        session.viewed_through_event_ordinal = 0;
5087    }
5088    save_state_to(database, &state)?;
5089    let migrated = legacy.with_file_name("state.json.migrated-v1");
5090    fs::rename(legacy, &migrated)
5091        .with_context(|| format!("retain migrated Mjolnir state as {}", migrated.display()))?;
5092    Ok(())
5093}
5094
5095fn session_state_name(value: SessionState) -> &'static str {
5096    match value {
5097        SessionState::Provisioning => "provisioning",
5098        SessionState::Running => "running",
5099        SessionState::Disconnected => "disconnected",
5100        SessionState::Checkpointing => "checkpointing",
5101        SessionState::Closing => "closing",
5102        SessionState::Destroying => "destroying",
5103        SessionState::Stopped => "stopped",
5104        SessionState::Lost => "lost",
5105        SessionState::Error => "error",
5106        SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
5107    }
5108}
5109fn parse_session_state(value: &str) -> SessionState {
5110    match value {
5111        "provisioning" => SessionState::Provisioning,
5112        "running" => SessionState::Running,
5113        "disconnected" => SessionState::Disconnected,
5114        "checkpointing" => SessionState::Checkpointing,
5115        "closing" => SessionState::Closing,
5116        "destroying" => SessionState::Destroying,
5117        // Rows written before the verb was renamed still say "archived".
5118        "stopped" | "archived" => SessionState::Stopped,
5119        "lost" => SessionState::Lost,
5120        "error" => SessionState::Error,
5121        "destroyed-with-data-loss" => SessionState::DestroyedWithDataLoss,
5122        _ => unreachable!(),
5123    }
5124}
5125
5126#[cfg(unix)]
5127fn path_to_blob(path: &Path) -> Vec<u8> {
5128    use std::os::unix::ffi::OsStrExt;
5129    path.as_os_str().as_bytes().to_vec()
5130}
5131#[cfg(unix)]
5132fn blob_to_path(bytes: &[u8]) -> PathBuf {
5133    use std::os::unix::ffi::OsStrExt;
5134    PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
5135}
5136#[cfg(windows)]
5137fn path_to_blob(path: &Path) -> Vec<u8> {
5138    use std::os::windows::ffi::OsStrExt;
5139    path.as_os_str()
5140        .encode_wide()
5141        .flat_map(u16::to_le_bytes)
5142        .collect()
5143}
5144#[cfg(windows)]
5145fn blob_to_path(bytes: &[u8]) -> PathBuf {
5146    use std::os::windows::ffi::OsStringExt;
5147    let wide = bytes
5148        .as_chunks::<2>()
5149        .0
5150        .iter()
5151        .map(|b| u16::from_le_bytes([b[0], b[1]]))
5152        .collect::<Vec<_>>();
5153    PathBuf::from(std::ffi::OsString::from_wide(&wide))
5154}
5155
5156trait ValueRefExt<'a> {
5157    fn blob_or_null(self) -> rusqlite::Result<Option<&'a [u8]>>;
5158}
5159impl<'a> ValueRefExt<'a> for rusqlite::types::ValueRef<'a> {
5160    fn blob_or_null(self) -> rusqlite::Result<Option<&'a [u8]>> {
5161        match self {
5162            rusqlite::types::ValueRef::Null => Ok(None),
5163            value => Ok(Some(value.as_blob()?)),
5164        }
5165    }
5166}
5167
5168/// The exact result of a configuration command, once durably projected.
5169pub fn load_config_result(session_id: &str, command_id: &str) -> Result<Option<Option<String>>> {
5170    Ok(open_reader(&database_path())?
5171        .query_row(
5172            "SELECT error FROM api_config_results WHERE session_id = ?1 AND command_id = ?2",
5173            params![session_id, command_id],
5174            |row| row.get(0),
5175        )
5176        .optional()?)
5177}
5178
5179pub fn load_profile_config_cache(
5180    profile: &str,
5181    model: &str,
5182    fingerprint: &str,
5183) -> Result<Option<String>> {
5184    load_profile_config_cache_from(&database_path(), profile, model, fingerprint)
5185}
5186
5187fn load_profile_config_cache_from(
5188    path: &Path,
5189    profile: &str,
5190    model: &str,
5191    fingerprint: &str,
5192) -> Result<Option<String>> {
5193    Ok(open_reader(path)?.query_row(
5194        "SELECT body FROM profile_config_cache WHERE profile = ?1 AND model = ?2 AND fingerprint = ?3 AND observed_at > ?4",
5195        params![profile, model, fingerprint, Utc::now().timestamp() - 86400], |row| row.get(0),
5196    ).optional()?)
5197}
5198
5199pub fn save_profile_config_cache(
5200    profile: String,
5201    model: String,
5202    fingerprint: String,
5203    body: String,
5204) -> Result<()> {
5205    submit_database_write("save profile configuration cache", move |connection| {
5206        save_profile_config_cache_with(connection, &profile, &model, &fingerprint, &body)
5207    })
5208}
5209
5210fn save_profile_config_cache_with(
5211    connection: &Connection,
5212    profile: &str,
5213    model: &str,
5214    fingerprint: &str,
5215    body: &str,
5216) -> Result<()> {
5217    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])?;
5218    Ok(())
5219}
5220
5221#[cfg(test)]
5222mod tests;