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