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