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