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