1use super::*;
2
3pub fn load_materialized_session(session_id: &str) -> Result<Option<MaterializedSession>> {
13 load_materialized_session_from(&database_path(), session_id)
14}
15
16pub fn load_materialized_session_summary(
20 session_id: &str,
21) -> Result<Option<MaterializedSessionSummary>> {
22 load_materialized_session_summary_from(&database_path(), session_id)
23}
24
25pub(super) fn load_materialized_session_summary_from(
26 path: &Path,
27 session_id: &str,
28) -> Result<Option<MaterializedSessionSummary>> {
29 let connection = open_reader(path)?;
30 let row = connection
31 .query_row(
32 "SELECT applied_event_ordinal, last_activity_at_ms, execution_state,
33 running_started_at_ms, session_title
34 FROM materialized_sessions WHERE session_id = ?1",
35 [session_id],
36 |row| {
37 Ok((
38 row.get::<_, u64>(0)?,
39 row.get::<_, Option<i64>>(1)?,
40 row.get::<_, String>(2)?,
41 row.get::<_, Option<i64>>(3)?,
42 row.get::<_, Option<String>>(4)?,
43 ))
44 },
45 )
46 .optional()?;
47 let Some((
48 applied_event_ordinal,
49 last_activity_at_ms,
50 execution,
51 running_started_at_ms,
52 session_title,
53 )) = row
54 else {
55 return Ok(None);
56 };
57
58 let last_user_message = last_materialized_user_message(&connection, session_id)?;
59 let last_agent_message = last_materialized_agent_message(&connection, session_id)?;
60 let last_agent_message_follows_last_user =
61 last_agent_message
62 .as_ref()
63 .is_some_and(|(agent_position, _)| {
64 last_user_message
65 .as_ref()
66 .is_none_or(|(user_position, _)| agent_position > user_position)
67 });
68 let mut ordinal_statement = connection.prepare(
69 "SELECT latest_content_event_ordinal
70 FROM materialized_transcript_items
71 WHERE session_id = ?1
72 AND latest_content_event_ordinal IS NOT NULL
73 AND EXISTS (
74 SELECT 1 FROM json_each(
75 CASE
76 WHEN latest_content_event_ordinal IS NOT NULL
77 AND json_valid(body_json)
78 THEN body_json
79 ELSE '{}'
80 END,
81 '$.chunks'
82 ) AS chunk
83 WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
84 AND (
85 json_extract(chunk.value, '$.content.type') <> 'text'
86 OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
87 )
88 )
89 ORDER BY position, stable_id",
90 )?;
91 let agent_message_latest_content_ordinals = ordinal_statement
92 .query_map([session_id], |row| row.get::<_, u64>(0))?
93 .collect::<rusqlite::Result<Vec<_>>>()?;
94 let restart_pattern = format!("{}*", mj_core::transcript::SESSION_RESTART_ITEM_PREFIX);
95 let mut restart_statement = connection.prepare(
96 "SELECT position
97 FROM materialized_transcript_items
98 WHERE session_id = ?1 AND stable_id GLOB ?2
99 ORDER BY position, stable_id",
100 )?;
101 let session_restart_event_ordinals = restart_statement
102 .query_map((session_id, restart_pattern), |row| row.get::<_, u64>(0))?
103 .collect::<rusqlite::Result<Vec<_>>>()?;
104
105 Ok(Some(MaterializedSessionSummary {
106 session_id: session_id.to_owned(),
107 applied_event_ordinal,
108 last_activity_at_ms,
109 execution: parse_materialized_execution(&execution, running_started_at_ms)?,
110 session_title,
111 last_agent_message: last_agent_message.map(|(_, message)| message),
112 last_user_message: last_user_message.map(|(_, message)| message),
113 last_agent_message_follows_last_user,
114 agent_message_latest_content_ordinals,
115 session_restart_event_ordinals,
116 }))
117}
118
119pub(super) fn first_materialized_user_message(
123 connection: &Connection,
124 session_id: &str,
125) -> Result<Option<(u64, String)>> {
126 materialized_user_message(connection, session_id, true)
127}
128
129pub(super) fn last_materialized_user_message(
130 connection: &Connection,
131 session_id: &str,
132) -> Result<Option<(u64, String)>> {
133 materialized_user_message(connection, session_id, false)
134}
135
136pub(super) fn materialized_user_message(
137 connection: &Connection,
138 session_id: &str,
139 oldest_first: bool,
140) -> Result<Option<(u64, String)>> {
141 let mut statement = connection.prepare(if oldest_first {
142 "SELECT position, body_json
143 FROM materialized_transcript_items
144 WHERE session_id = ?1
145 AND json_extract(
146 CASE
147 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
148 THEN body_json
149 ELSE '{}'
150 END,
151 '$.kind'
152 ) = 'user'
153 ORDER BY position, stable_id"
154 } else {
155 "SELECT position, body_json
156 FROM materialized_transcript_items
157 WHERE session_id = ?1
158 AND json_extract(
159 CASE
160 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
161 THEN body_json
162 ELSE '{}'
163 END,
164 '$.kind'
165 ) = 'user'
166 ORDER BY position DESC, stable_id DESC"
167 })?;
168 let rows = statement.query_map([session_id], |row| {
169 Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
170 })?;
171 for row in rows {
172 let (position, body_json) = row?;
173 let body: TranscriptBody = serde_json::from_str(&body_json)
174 .with_context(|| format!("parse materialized user message for session {session_id}"))?;
175 let TranscriptBody::User { content } = body else {
176 continue;
177 };
178 let text = mj_core::transcript::materialized_content_text(&content);
179 if !text.trim().is_empty() {
180 return Ok(Some((position, text)));
181 }
182 }
183 Ok(None)
184}
185
186pub(super) fn last_materialized_turn_start(
190 connection: &Connection,
191 session_id: &str,
192) -> Result<Option<u64>> {
193 Ok(connection
194 .query_row(
195 "SELECT position
196 FROM materialized_transcript_items
197 WHERE session_id = ?1
198 AND (
199 stable_id GLOB ?2
200 OR json_extract(
201 CASE
202 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
203 THEN body_json
204 ELSE '{}'
205 END,
206 '$.kind'
207 ) = 'user'
208 )
209 ORDER BY position DESC, stable_id DESC
210 LIMIT 1",
211 params![
212 session_id,
213 format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX)
214 ],
215 |row| row.get::<_, u64>(0),
216 )
217 .optional()?)
218}
219
220pub(super) fn last_materialized_agent_message(
221 connection: &Connection,
222 session_id: &str,
223) -> Result<Option<(u64, String)>> {
224 last_materialized_agent_message_in(connection, session_id, 0)
225}
226
227pub(super) fn last_materialized_agent_message_after(
230 connection: &Connection,
231 session_id: &str,
232 after_position: u64,
233) -> Result<Option<String>> {
234 Ok(
235 last_materialized_agent_message_in(connection, session_id, after_position)?
236 .map(|(_, text)| text),
237 )
238}
239
240pub(super) fn last_materialized_agent_message_in(
241 connection: &Connection,
242 session_id: &str,
243 after_position: u64,
244) -> Result<Option<(u64, String)>> {
245 let row = connection
246 .query_row(
247 "SELECT position, body_json
248 FROM materialized_transcript_items
249 WHERE session_id = ?1
250 AND position > ?2
251 AND latest_content_event_ordinal IS NOT NULL
252 AND EXISTS (
253 SELECT 1 FROM json_each(
254 CASE
255 WHEN latest_content_event_ordinal IS NOT NULL
256 AND json_valid(body_json)
257 THEN body_json
258 ELSE '{}'
259 END,
260 '$.chunks'
261 ) AS chunk
262 WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
263 AND (
264 json_extract(chunk.value, '$.content.type') <> 'text'
265 OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
266 )
267 )
268 ORDER BY position DESC, stable_id DESC
269 LIMIT 1",
270 params![session_id, after_position],
271 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
272 )
273 .optional()?;
274 let Some((position, body_json)) = row else {
275 return Ok(None);
276 };
277 let body: TranscriptBody = serde_json::from_str(&body_json)
278 .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
279 let TranscriptBody::Agent { chunks, .. } = body else {
280 return Ok(None);
281 };
282 let text = mj_core::transcript::materialized_chunks_text(&chunks);
283 Ok((!text.trim().is_empty()).then_some((position, text)))
284}
285
286pub type MaterializedTurnState = (
288 MaterializedExecutionState,
289 Option<MaterializedTurn>,
290 Option<MaterializedTurnOutcome>,
291);
292
293pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
297 load_materialized_turn_outcome_from(&database_path(), session_id)
298}
299
300pub(super) fn load_materialized_turn_outcome_from(
301 path: &Path,
302 session_id: &str,
303) -> Result<Option<MaterializedTurnState>> {
304 let connection = open_reader(path)?;
305 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
306 return Ok(None);
307 };
308 Ok(Some((
309 fields.execution,
310 fields.active_turn,
311 fields.last_turn_outcome,
312 )))
313}
314
315pub fn load_materialized_turn_summary(
317 session_id: &str,
318 turn_start_position: u64,
319) -> Result<TurnSummary> {
320 load_materialized_turn_summary_from(&database_path(), session_id, turn_start_position)
321}
322
323pub(super) fn load_materialized_turn_summary_from(
324 path: &Path,
325 session_id: &str,
326 turn_start_position: u64,
327) -> Result<TurnSummary> {
328 let connection = open_reader(path)?;
329 let turn_number = connection.query_row(
330 "SELECT COUNT(*)
331 FROM materialized_transcript_items
332 WHERE session_id = ?1
333 AND position <= ?3
334 AND (
335 stable_id GLOB ?2
336 OR json_extract(
337 CASE
338 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
339 THEN body_json
340 ELSE '{}'
341 END,
342 '$.kind'
343 ) = 'user'
344 )",
345 params![
346 session_id,
347 format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
348 turn_start_position
349 ],
350 |row| row.get::<_, u64>(0),
351 )?;
352 let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
353 "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
354 FROM materialized_transcript_items
355 WHERE session_id = ?1 AND position >= ?2",
356 params![session_id, turn_start_position],
357 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
358 )?;
359 let final_message =
360 last_materialized_agent_message_after(&connection, session_id, turn_start_position)?;
361 Ok(TurnSummary {
362 turn_number,
363 turn_started_at_ms,
364 last_changed_at_ms,
365 final_message,
366 })
367}
368
369pub fn load_materialized_transcript_filtered(
370 session_id: &str,
371 after_seq: u64,
372 limit: usize,
373 role: Option<mj_core::transcript::TranscriptRole>,
374) -> Result<Option<TranscriptPage>> {
375 load_materialized_transcript_filtered_from(&database_path(), session_id, after_seq, limit, role)
376}
377
378pub(super) fn load_materialized_transcript_filtered_from(
379 path: &Path,
380 session_id: &str,
381 after_seq: u64,
382 limit: usize,
383 role: Option<mj_core::transcript::TranscriptRole>,
384) -> Result<Option<TranscriptPage>> {
385 let mut reader = open_reader(path)?;
386 let connection = reader.transaction()?;
387 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
388 return Ok(None);
389 };
390 let role = role.map(|r| r.storage_kind());
391 let mut statement = connection.prepare(
392 "WITH matches AS (
393 SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
394 FROM materialized_transcript_items WHERE session_id = ?1
395 AND COALESCE(latest_content_event_ordinal, position) > ?2
396 AND (?4 IS NULL OR json_extract(body_json, '$.kind') = ?4)
397 ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
398 SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
399 last_changed_at_ms, body_json
400 FROM matches WHERE seq <= (SELECT seq FROM boundary)
401 ORDER BY seq, stable_id",
402 )?;
403 let rows = statement
404 .query_map(
405 params![session_id, after_seq, limit.clamp(1, 1000) as i64, role],
406 |row| {
407 Ok((
408 row.get::<_, String>(0)?,
409 row.get::<_, u64>(1)?,
410 row.get::<_, Option<u64>>(2)?,
411 row.get::<_, i64>(3)?,
412 row.get::<_, i64>(4)?,
413 row.get::<_, String>(5)?,
414 ))
415 },
416 )?
417 .collect::<rusqlite::Result<Vec<_>>>()?;
418 let items = rows
419 .into_iter()
420 .map(
421 |(
422 stable_id,
423 position,
424 latest_content_event_ordinal,
425 created_at_ms,
426 last_changed_at_ms,
427 body_json,
428 )| {
429 Ok(Arc::new(TranscriptItem {
430 stable_id,
431 position,
432 latest_content_event_ordinal,
433 created_at_ms,
434 last_changed_at_ms,
435 body: serde_json::from_str(&body_json).with_context(|| {
436 format!("parse materialized transcript body for session {session_id}")
437 })?,
438 }))
439 },
440 )
441 .collect::<Result<Vec<_>>>()?;
442 let latest_seq = connection.query_row(
443 "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
444 FROM materialized_transcript_items
445 WHERE session_id = ?1",
446 [session_id],
447 |row| row.get::<_, u64>(0),
448 )?;
449 let last_seq = items.last().map_or(after_seq, |item| item.seq());
450 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))?;
451 Ok(Some(TranscriptPage {
452 next_after_seq: if more {
453 last_seq
454 } else {
455 latest_seq.max(after_seq)
456 },
457 items,
458 latest_seq,
459 execution: fields.execution,
460 }))
461}
462
463pub(super) const RETENTION_BATCH_ITEMS: usize = 4_096;
470
471pub(super) const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
474
475pub fn compact_materialized_transcript_through(
488 session_id: &str,
489 event_frontier: u64,
490) -> Result<TranscriptRetention> {
491 let session_id = session_id.to_owned();
492 submit_database_write("compact_materialized_transcript", move |_| {
493 compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
494 })
495}
496
497pub(super) fn compact_materialized_transcript_in(
498 path: &Path,
499 session_id: &str,
500 event_frontier: u64,
501) -> Result<TranscriptRetention> {
502 let mut connection = open(path)?;
503 let candidates = {
504 let mut statement = connection.prepare(
505 "SELECT stable_id, body_json
506 FROM materialized_transcript_items
507 WHERE session_id = ?1
508 AND position <= ?2
509 AND length(body_json) > ?3
510 AND json_extract(
511 CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
512 '$.kind'
513 ) = 'tool'
514 ORDER BY position, stable_id
515 LIMIT ?4",
516 )?;
517 statement
518 .query_map(
519 params![
520 session_id,
521 event_frontier,
522 RETENTION_BODY_FLOOR_BYTES as i64,
523 RETENTION_BATCH_ITEMS as i64 + 1
524 ],
525 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
526 )?
527 .collect::<rusqlite::Result<Vec<_>>>()?
528 };
529 let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
530 let mut retention = TranscriptRetention {
531 remaining,
532 ..TranscriptRetention::default()
533 };
534 let transaction =
535 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
536 for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
537 let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
538 Ok(body) => body,
539 Err(error) => {
541 tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
542 continue;
543 }
544 };
545 if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
546 continue;
547 }
548 let compacted = serde_json::to_string(&body)
549 .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
550 if compacted.len() >= body_json.len() {
551 continue;
552 }
553 transaction.execute(
554 "UPDATE materialized_transcript_items SET body_json = ?3
555 WHERE session_id = ?1 AND stable_id = ?2",
556 params![session_id, stable_id, compacted],
557 )?;
558 retention.items += 1;
559 retention.bytes += body_json.len() - compacted.len();
560 }
561 transaction.commit()?;
562 Ok(retention)
563}
564
565pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
573
574pub fn load_materialized_projection_tail(
584 session_id: &str,
585 transcript_limit: usize,
586) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
587 load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
588}
589
590pub(super) fn load_materialized_projection_tail_from(
591 path: &Path,
592 session_id: &str,
593 transcript_limit: usize,
594) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
595 let connection = open_reader(path)?;
596 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
597 return Ok(None);
598 };
599 let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
600 let total_items = connection.query_row(
601 "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
602 [session_id],
603 |row| row.get::<_, usize>(0),
604 )?;
605 let window = ProjectionWindow {
606 omitted_items: total_items.saturating_sub(transcript.len()),
607 provisional_title: first_materialized_user_message(&connection, session_id)?
608 .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
609 latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
610 };
611 let materialized = MaterializedSession {
612 session_id: session_id.to_owned(),
613 applied_event_ordinal: fields.applied_event_ordinal,
614 applied_event_digest: fields.applied_event_digest,
615 last_activity_at_ms: fields.last_activity_at_ms,
616 execution: fields.execution,
617 session_title: fields.session_title,
618 configuration: fields.configuration,
619 transcript,
620 queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
621 pending_elicitations: fields.pending_elicitations,
622 active_turn: fields.active_turn,
623 last_turn_outcome: fields.last_turn_outcome,
624 };
625 materialized.validate()?;
626 Ok(Some((materialized, window)))
627}
628
629pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
633 materialized_event_frontier_from(&database_path(), session_id)
634}
635
636pub(super) fn materialized_event_frontier_from(
637 path: &Path,
638 session_id: &str,
639) -> Result<Option<(u64, String)>> {
640 Ok(open_reader(path)?
641 .query_row(
642 "SELECT applied_event_ordinal, applied_event_digest
643 FROM materialized_sessions WHERE session_id = ?1",
644 [session_id],
645 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
646 )
647 .optional()?)
648}
649
650pub fn replace_materialized_queued_prompts(
654 session_id: &str,
655 queued_prompts: &[MaterializedQueuedPrompt],
656) -> Result<()> {
657 let session_id = session_id.to_owned();
658 let queued_prompts = queued_prompts.to_vec();
659 submit_database_write("replace_materialized_queued_prompts", move |_| {
660 replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
661 })
662}
663
664pub(super) fn replace_materialized_queued_prompts_in(
665 path: &Path,
666 session_id: &str,
667 queued_prompts: &[MaterializedQueuedPrompt],
668) -> Result<()> {
669 let mut connection = open(path)?;
670 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
671 if !session_exists(&tx, session_id)? {
672 bail!("unknown session {session_id}");
673 }
674 replace_materialized_queue(&tx, session_id, queued_prompts)?;
675 tx.commit()?;
676 Ok(())
677}
678
679pub fn load_transcribed_session_activity() -> Result<BTreeMap<String, Option<i64>>> {
686 load_transcribed_session_activity_from(&database_path())
687}
688
689fn load_transcribed_session_activity_from(path: &Path) -> Result<BTreeMap<String, Option<i64>>> {
690 let connection = open_reader(path)?;
691 let mut statement = connection.prepare(
692 "SELECT session_id, last_activity_at_ms
693 FROM materialized_sessions s
694 WHERE EXISTS (
695 SELECT 1 FROM materialized_transcript_items i
696 WHERE i.session_id = s.session_id
697 )",
698 )?;
699 let rows = statement.query_map([], |row| {
700 Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
701 })?;
702 let mut activity = BTreeMap::new();
703 for row in rows {
704 let (session_id, last_activity_at_ms) = row?;
705 activity.insert(session_id, last_activity_at_ms);
706 }
707 Ok(activity)
708}
709
710pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
714{
715 load_materialized_queued_prompts_from(&database_path())
716}
717
718pub(super) fn load_materialized_queued_prompts_from(
719 path: &Path,
720) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
721 let connection = open_reader(path)?;
722 let mut statement = connection.prepare(
723 "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
724 FROM materialized_queued_prompts
725 ORDER BY session_id, ordinal",
726 )?;
727 let rows = statement.query_map([], |row| {
728 Ok((
729 row.get::<_, String>(0)?,
730 row.get::<_, String>(1)?,
731 row.get::<_, String>(2)?,
732 row.get::<_, String>(3)?,
733 row.get::<_, i64>(4)?,
734 row.get::<_, Option<u64>>(5)?,
735 ))
736 })?;
737 let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
738 for row in rows {
739 let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
740 row?;
741 let content = serde_json::from_str(&content_json).with_context(|| {
742 format!("parse materialized queued prompt for session {session_id}")
743 })?;
744 let kind = serde_json::from_str(&kind_json).with_context(|| {
745 format!("parse materialized queue entry kind for session {session_id}")
746 })?;
747 queues
748 .entry(session_id)
749 .or_default()
750 .push(MaterializedQueuedPrompt {
751 command_id,
752 kind,
753 content,
754 queued_at_ms,
755 accepted_ordinal,
756 });
757 }
758 Ok(queues)
759}
760
761pub(super) fn load_materialized_session_from(
762 path: &Path,
763 session_id: &str,
764) -> Result<Option<MaterializedSession>> {
765 let connection = open_reader(path)?;
766 load_materialized_session_with(&connection, session_id)
767}
768
769pub(super) fn load_materialized_session_with(
770 connection: &Connection,
771 session_id: &str,
772) -> Result<Option<MaterializedSession>> {
773 let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
774 return Ok(None);
775 };
776 let materialized = MaterializedSession {
777 session_id: session_id.to_owned(),
778 applied_event_ordinal: fields.applied_event_ordinal,
779 applied_event_digest: fields.applied_event_digest,
780 last_activity_at_ms: fields.last_activity_at_ms,
781 execution: fields.execution,
782 session_title: fields.session_title,
783 configuration: fields.configuration,
784 transcript: read_materialized_transcript(connection, session_id, None)?,
785 queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
786 pending_elicitations: fields.pending_elicitations,
787 active_turn: fields.active_turn,
788 last_turn_outcome: fields.last_turn_outcome,
789 };
790 materialized.validate()?;
791 Ok(Some(materialized))
792}
793
794pub(super) struct MaterializedSessionFields {
796 pub(super) applied_event_ordinal: u64,
797 pub(super) applied_event_digest: String,
798 pub(super) last_activity_at_ms: Option<i64>,
799 pub(super) execution: MaterializedExecutionState,
800 pub(super) session_title: Option<String>,
801 pub(super) configuration: BTreeMap<String, serde_json::Value>,
802 pub(super) pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
803 pub(super) active_turn: Option<MaterializedTurn>,
804 pub(super) last_turn_outcome: Option<MaterializedTurnOutcome>,
805}
806
807pub(super) fn read_materialized_session_fields(
808 connection: &Connection,
809 session_id: &str,
810) -> Result<Option<MaterializedSessionFields>> {
811 let row = connection
812 .query_row(
813 "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
814 execution_state, running_started_at_ms, session_title, configuration_json,
815 pending_elicitations_json, active_turn_json, last_turn_outcome_json
816 FROM materialized_sessions WHERE session_id = ?1",
817 [session_id],
818 |row| {
819 Ok((
820 row.get::<_, u64>(0)?,
821 row.get::<_, String>(1)?,
822 row.get::<_, Option<i64>>(2)?,
823 row.get::<_, String>(3)?,
824 row.get::<_, Option<i64>>(4)?,
825 row.get::<_, Option<String>>(5)?,
826 row.get::<_, String>(6)?,
827 row.get::<_, String>(7)?,
828 row.get::<_, Option<String>>(8)?,
829 row.get::<_, Option<String>>(9)?,
830 ))
831 },
832 )
833 .optional()?;
834 let Some((
835 applied_event_ordinal,
836 applied_event_digest,
837 last_activity_at_ms,
838 execution,
839 running_started_at_ms,
840 session_title,
841 configuration_json,
842 pending_elicitations_json,
843 active_turn_json,
844 last_turn_outcome_json,
845 )) = row
846 else {
847 return Ok(None);
848 };
849 Ok(Some(MaterializedSessionFields {
850 applied_event_ordinal,
851 applied_event_digest,
852 last_activity_at_ms,
853 execution: parse_materialized_execution(&execution, running_started_at_ms)?,
854 session_title,
855 configuration: serde_json::from_str(&configuration_json).with_context(|| {
856 format!("parse materialized configuration for session {session_id}")
857 })?,
858 pending_elicitations: serde_json::from_str(&pending_elicitations_json)
859 .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
860 active_turn: active_turn_json
861 .as_deref()
862 .map(serde_json::from_str)
863 .transpose()
864 .with_context(|| format!("parse active turn for session {session_id}"))?,
865 last_turn_outcome: last_turn_outcome_json
866 .as_deref()
867 .map(serde_json::from_str)
868 .transpose()
869 .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
870 }))
871}
872
873pub(super) fn read_materialized_transcript(
877 connection: &Connection,
878 session_id: &str,
879 limit: Option<usize>,
880) -> Result<Vec<Arc<TranscriptItem>>> {
881 let mut statement = connection.prepare(match limit {
882 Some(_) => {
883 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
884 last_changed_at_ms, body_json
885 FROM materialized_transcript_items
886 WHERE session_id = ?1
887 ORDER BY position DESC, stable_id DESC
888 LIMIT ?2"
889 }
890 None => {
891 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
892 last_changed_at_ms, body_json
893 FROM materialized_transcript_items
894 WHERE session_id = ?1
895 ORDER BY position, stable_id"
896 }
897 })?;
898 let read = |row: &rusqlite::Row<'_>| {
899 Ok((
900 row.get::<_, String>(0)?,
901 row.get::<_, u64>(1)?,
902 row.get::<_, Option<u64>>(2)?,
903 row.get::<_, i64>(3)?,
904 row.get::<_, i64>(4)?,
905 row.get::<_, String>(5)?,
906 ))
907 };
908 let rows = match limit {
909 Some(limit) => statement
910 .query_map(params![session_id, limit as i64], read)?
911 .collect::<rusqlite::Result<Vec<_>>>()?,
912 None => statement
913 .query_map([session_id], read)?
914 .collect::<rusqlite::Result<Vec<_>>>()?,
915 };
916 let mut transcript = rows
917 .into_iter()
918 .map(
919 |(
920 stable_id,
921 position,
922 latest_content_event_ordinal,
923 created_at_ms,
924 last_changed_at_ms,
925 body_json,
926 )| {
927 Ok(Arc::new(TranscriptItem {
928 stable_id,
929 position,
930 latest_content_event_ordinal,
931 created_at_ms,
932 last_changed_at_ms,
933 body: serde_json::from_str(&body_json).with_context(|| {
934 format!("parse materialized transcript body for session {session_id}")
935 })?,
936 }))
937 },
938 )
939 .collect::<Result<Vec<_>>>()?;
940 if limit.is_some() {
941 transcript.reverse();
944 }
945 Ok(transcript)
946}
947
948pub(super) fn read_materialized_queued_prompts(
949 connection: &Connection,
950 session_id: &str,
951) -> Result<Vec<MaterializedQueuedPrompt>> {
952 let mut statement = connection.prepare(
953 "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
954 FROM materialized_queued_prompts
955 WHERE session_id = ?1
956 ORDER BY ordinal",
957 )?;
958 let rows = statement
959 .query_map([session_id], |row| {
960 Ok((
961 row.get::<_, String>(0)?,
962 row.get::<_, String>(1)?,
963 row.get::<_, String>(2)?,
964 row.get::<_, i64>(3)?,
965 row.get::<_, Option<u64>>(4)?,
966 ))
967 })?
968 .collect::<rusqlite::Result<Vec<_>>>()?;
969 rows.into_iter()
970 .map(
971 |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
972 Ok(MaterializedQueuedPrompt {
973 command_id,
974 kind: serde_json::from_str(&kind_json).with_context(|| {
975 format!("parse materialized queue entry kind for session {session_id}")
976 })?,
977 content: serde_json::from_str(&content_json).with_context(|| {
978 format!("parse materialized queued prompt for session {session_id}")
979 })?,
980 queued_at_ms,
981 accepted_ordinal,
982 })
983 },
984 )
985 .collect()
986}
987
988pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
992 let materialized = materialized.clone();
993 submit_database_write("save_materialized_session", move |_| {
994 save_materialized_session_to(&database_path(), &materialized)
995 })
996}
997
998pub(super) fn save_materialized_session_to(
999 path: &Path,
1000 materialized: &MaterializedSession,
1001) -> Result<()> {
1002 materialized.validate()?;
1003 let mut connection = open(path)?;
1004 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1005 if !session_exists(&tx, &materialized.session_id)? {
1006 bail!("unknown session {}", materialized.session_id);
1007 }
1008 write_materialized_session(&tx, materialized)?;
1009 tx.commit()?;
1010 Ok(())
1011}
1012
1013pub struct ProjectionPage<'a> {
1018 pub(super) session_id: &'a str,
1019 pub(super) transaction: Transaction<'a>,
1020 pub(super) applied_ordinal: u64,
1021 pub(super) applied_digest: String,
1022 pub(super) dirty: bool,
1023 pub(super) pending: MaterializedSessionMutation,
1024 pub(super) pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
1025 pub(super) pending_turns: Vec<MaterializedTurnOutcome>,
1026 pub(super) pending_events: Vec<(i64, ApiEventData)>,
1027}
1028
1029pub(super) struct PendingTranscriptMutation {
1030 pub(super) final_mutation: TranscriptMutation,
1031 pub(super) remove_before_upsert: bool,
1032}
1033
1034impl ProjectionPage<'_> {
1035 pub fn apply(
1039 &mut self,
1040 event_ordinal: u64,
1041 previous_event_digest: &str,
1042 event_digest: &str,
1043 mutation: &MaterializedSessionMutation,
1044 ) -> Result<ProjectionApplyOutcome> {
1045 if event_ordinal == 0 {
1046 bail!("relay event ordinal must be positive");
1047 }
1048 let chained = !previous_event_digest.is_empty();
1054 if chained {
1055 validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
1056 }
1057 validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
1058 let session_id = self.session_id;
1059 let applied = self.applied_ordinal;
1060 if event_ordinal < applied {
1061 return Ok(ProjectionApplyOutcome::AlreadyApplied);
1062 }
1063 if event_ordinal == applied {
1064 if event_digest != self.applied_digest {
1065 bail!(
1066 "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
1067 self.applied_digest
1068 );
1069 }
1070 return Ok(ProjectionApplyOutcome::AlreadyApplied);
1071 }
1072 let expected = applied
1073 .checked_add(1)
1074 .context("materialized event ordinal overflow")?;
1075 if event_ordinal != expected {
1076 bail!(
1077 "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
1078 );
1079 }
1080 if chained && previous_event_digest != self.applied_digest {
1081 bail!(
1082 "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
1083 self.applied_digest
1084 );
1085 }
1086
1087 if let Some(activity_at_ms) = mutation.last_activity_at_ms {
1088 self.pending.last_activity_at_ms = Some(
1089 self.pending
1090 .last_activity_at_ms
1091 .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
1092 );
1093 }
1094 if let Some(execution) = mutation.execution {
1095 self.pending.execution = Some(execution);
1096 }
1097 if let Some(title) = &mutation.session_title {
1098 if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
1099 bail!("materialized session title cannot be empty");
1100 }
1101 self.pending.session_title = Some(title.clone());
1102 }
1103 if let Some(configuration) = &mutation.configuration {
1104 self.pending.configuration = Some(configuration.clone());
1105 }
1106 for item_mutation in &mutation.transcript {
1107 match item_mutation {
1108 TranscriptMutation::Upsert(item) => {
1109 item.validate(event_ordinal)?;
1110 let stable_id = item.stable_id.clone();
1111 let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
1112 PendingTranscriptMutation {
1113 final_mutation: TranscriptMutation::Upsert(item.clone()),
1114 remove_before_upsert: false,
1115 }
1116 });
1117 entry.remove_before_upsert |=
1118 matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
1119 entry.final_mutation = TranscriptMutation::Upsert(item.clone());
1120 }
1121 TranscriptMutation::Remove { stable_id } => {
1122 if stable_id.trim().is_empty() {
1123 bail!("cannot remove a transcript item with an empty stable id");
1124 }
1125 let removed = TranscriptMutation::Remove {
1126 stable_id: stable_id.clone(),
1127 };
1128 self.pending_transcript
1129 .entry(stable_id.clone())
1130 .and_modify(|entry| entry.final_mutation = removed.clone())
1131 .or_insert(PendingTranscriptMutation {
1132 final_mutation: removed,
1133 remove_before_upsert: false,
1134 });
1135 }
1136 }
1137 }
1138 if let Some(queued_prompts) = &mutation.queued_prompts {
1139 self.pending.queued_prompts = Some(queued_prompts.clone());
1140 }
1141 if let Some(pending_elicitations) = &mutation.pending_elicitations {
1142 self.pending.pending_elicitations = Some(pending_elicitations.clone());
1143 }
1144 self.pending
1145 .config_results
1146 .extend(mutation.config_results.clone());
1147 if let Some(active_turn) = &mutation.active_turn {
1148 self.pending.active_turn = Some(active_turn.clone());
1149 }
1150 if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
1151 self.pending_turns.push(last_turn_outcome.clone());
1152 self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
1153 }
1154 if let Some(cost) = &mutation.provider_cost {
1155 self.pending.provider_cost = Some(cost.clone());
1156 }
1157 self.pending_events.extend(
1158 mutation
1159 .api_events
1160 .iter()
1161 .cloned()
1162 .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
1163 );
1164 self.applied_ordinal = event_ordinal;
1165 event_digest.clone_into(&mut self.applied_digest);
1166 self.dirty = true;
1167 Ok(ProjectionApplyOutcome::Applied)
1168 }
1169
1170 pub(super) fn flush(&mut self) -> Result<()> {
1174 if !self.dirty {
1175 return Ok(());
1176 }
1177 let tx = &self.transaction;
1178 let session_id = self.session_id;
1179 if let Some(execution) = self.pending.execution {
1180 let (state, started_at_ms) = materialized_execution_columns(execution);
1181 tx.execute(
1182 "UPDATE materialized_sessions
1183 SET execution_state = ?2, running_started_at_ms = ?3
1184 WHERE session_id = ?1",
1185 params![session_id, state, started_at_ms],
1186 )?;
1187 }
1188 if let Some(title) = &self.pending.session_title {
1189 tx.execute(
1190 "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
1191 params![session_id, title],
1192 )?;
1193 }
1194 if let Some(configuration) = &self.pending.configuration {
1195 tx.execute(
1196 "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
1197 params![session_id, serde_json::to_string(configuration)?],
1198 )?;
1199 }
1200 for pending in self.pending_transcript.values() {
1201 match &pending.final_mutation {
1202 TranscriptMutation::Upsert(item) => {
1203 if pending.remove_before_upsert {
1207 tx.execute(
1208 "DELETE FROM materialized_transcript_items
1209 WHERE session_id = ?1 AND stable_id = ?2",
1210 params![session_id, item.stable_id],
1211 )?;
1212 }
1213 upsert_transcript_item(tx, session_id, item)?;
1214 }
1215 TranscriptMutation::Remove { stable_id } => {
1216 tx.execute(
1217 "DELETE FROM materialized_transcript_items
1218 WHERE session_id = ?1 AND stable_id = ?2",
1219 params![session_id, stable_id],
1220 )?;
1221 }
1222 }
1223 }
1224 if let Some(queued_prompts) = &self.pending.queued_prompts {
1225 replace_materialized_queue(tx, session_id, queued_prompts)?;
1226 }
1227 if let Some(pending_elicitations) = &self.pending.pending_elicitations {
1228 tx.execute(
1229 "UPDATE materialized_sessions
1230 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
1231 params![session_id, serde_json::to_string(pending_elicitations)?],
1232 )?;
1233 }
1234 for (recorded_at_ms, event) in &self.pending_events {
1235 events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
1236 }
1237 for turn in &self.pending_turns {
1238 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)?])?;
1239 }
1240 if let Some(cost) = &self.pending.provider_cost {
1241 tx.execute(
1242 "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
1243 params![session_id, serde_json::to_string(cost)?],
1244 )?;
1245 }
1246 for (command_id, error) in &self.pending.config_results {
1247 tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
1248 }
1249 if let Some(active_turn) = &self.pending.active_turn {
1250 tx.execute(
1251 "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
1252 params![
1253 session_id,
1254 active_turn
1255 .as_ref()
1256 .map(serde_json::to_string)
1257 .transpose()?
1258 ],
1259 )?;
1260 }
1261 if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
1262 tx.execute(
1263 "UPDATE materialized_sessions
1264 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
1265 params![session_id, serde_json::to_string(last_turn_outcome)?],
1266 )?;
1267 }
1268 tx.execute(
1269 "UPDATE materialized_sessions
1270 SET last_activity_at_ms = CASE
1271 WHEN ?2 IS NULL THEN last_activity_at_ms
1272 WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
1273 ELSE last_activity_at_ms
1274 END,
1275 applied_event_ordinal = ?3,
1276 applied_event_digest = ?4
1277 WHERE session_id = ?1",
1278 params![
1279 session_id,
1280 self.pending.last_activity_at_ms,
1281 self.applied_ordinal,
1282 self.applied_digest,
1283 ],
1284 )?;
1285 Ok(())
1286 }
1287}
1288
1289pub fn apply_projection_page<T>(
1294 session_id: &str,
1295 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
1296) -> Result<T>
1297where
1298 T: Send + 'static,
1299{
1300 let session_id = session_id.to_owned();
1301 submit_database_write("apply_projection_page", move |connection| {
1302 apply_projection_page_with(connection, &session_id, fill)
1303 })
1304}
1305
1306#[cfg(test)]
1307pub(super) fn apply_projection_page_to<T>(
1308 path: &Path,
1309 session_id: &str,
1310 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1311) -> Result<T> {
1312 let mut connection = open(path)?;
1313 apply_projection_page_with(&mut connection, session_id, fill)
1314}
1315
1316pub(super) fn apply_projection_page_with<T>(
1317 connection: &mut Connection,
1318 session_id: &str,
1319 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1320) -> Result<T> {
1321 let transaction =
1322 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1323 let (applied_ordinal, applied_digest) = transaction
1324 .query_row(
1325 "SELECT applied_event_ordinal, applied_event_digest
1326 FROM materialized_sessions WHERE session_id = ?1",
1327 [session_id],
1328 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
1329 )
1330 .optional()?
1331 .with_context(|| format!("unknown session {session_id}"))?;
1332 validate_relay_event_frontier(
1333 applied_ordinal,
1334 &applied_digest,
1335 "persisted relay event frontier",
1336 )?;
1337 let mut page = ProjectionPage {
1338 session_id,
1339 transaction,
1340 applied_ordinal,
1341 applied_digest,
1342 dirty: false,
1343 pending: MaterializedSessionMutation::default(),
1344 pending_transcript: BTreeMap::new(),
1345 pending_turns: Vec::new(),
1346 pending_events: Vec::new(),
1347 };
1348 let filled = fill(&mut page)?;
1351 page.flush()?;
1352 page.transaction.commit()?;
1353 Ok(filled)
1354}
1355
1356pub fn apply_projection_event(
1358 session_id: &str,
1359 event_ordinal: u64,
1360 previous_event_digest: &str,
1361 event_digest: &str,
1362 mutation: &MaterializedSessionMutation,
1363) -> Result<ProjectionApplyOutcome> {
1364 let session_id = session_id.to_owned();
1365 let previous_event_digest = previous_event_digest.to_owned();
1366 let event_digest = event_digest.to_owned();
1367 let mutation = mutation.clone();
1368 submit_database_write("apply_projection_event", move |connection| {
1369 apply_projection_page_with(connection, &session_id, |page| {
1370 page.apply(
1371 event_ordinal,
1372 &previous_event_digest,
1373 &event_digest,
1374 &mutation,
1375 )
1376 })
1377 })
1378}
1379
1380#[cfg(test)]
1381pub(super) fn apply_projection_event_to(
1382 path: &Path,
1383 session_id: &str,
1384 event_ordinal: u64,
1385 previous_event_digest: &str,
1386 event_digest: &str,
1387 mutation: &MaterializedSessionMutation,
1388) -> Result<ProjectionApplyOutcome> {
1389 apply_projection_page_to(path, session_id, |page| {
1390 page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
1391 })
1392}
1393
1394pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
1397 let session_id = session_id.to_owned();
1398 submit_database_write("advance_viewed_through_event_ordinal", move |_| {
1399 advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
1400 })
1401}
1402
1403pub(super) fn advance_viewed_through_event_ordinal_to(
1404 path: &Path,
1405 session_id: &str,
1406 through: u64,
1407) -> Result<u64> {
1408 let mut connection = open(path)?;
1409 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1410 let applied = tx
1411 .query_row(
1412 "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
1413 [session_id],
1414 |row| row.get::<_, u64>(0),
1415 )
1416 .optional()?
1417 .with_context(|| format!("unknown session {session_id}"))?;
1418 if through > applied {
1419 bail!(
1420 "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
1421 );
1422 }
1423 tx.execute(
1424 "UPDATE sessions
1425 SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
1426 WHERE session_id = ?1",
1427 params![session_id, through],
1428 )?;
1429 let receipt = tx.query_row(
1430 "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
1431 [session_id],
1432 |row| row.get::<_, u64>(0),
1433 )?;
1434 tx.commit()?;
1435 Ok(receipt)
1436}