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, None)
225}
226
227pub(super) fn last_materialized_agent_message_within(
234 connection: &Connection,
235 session_id: &str,
236 after_position: u64,
237 through_position: u64,
238) -> Result<Option<String>> {
239 Ok(last_materialized_agent_message_in(
240 connection,
241 session_id,
242 after_position,
243 Some(through_position),
244 )?
245 .map(|(_, text)| text))
246}
247
248pub(super) fn last_materialized_agent_message_in(
249 connection: &Connection,
250 session_id: &str,
251 after_position: u64,
252 through_position: Option<u64>,
253) -> Result<Option<(u64, String)>> {
254 let row = connection
255 .query_row(
256 "SELECT position, body_json
257 FROM materialized_transcript_items
258 WHERE session_id = ?1
259 AND position > ?2
260 AND (?3 IS NULL OR position <= ?3)
261 AND latest_content_event_ordinal IS NOT NULL
262 AND EXISTS (
263 SELECT 1 FROM json_each(
264 CASE
265 WHEN latest_content_event_ordinal IS NOT NULL
266 AND json_valid(body_json)
267 THEN body_json
268 ELSE '{}'
269 END,
270 '$.chunks'
271 ) AS chunk
272 WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
273 AND (
274 json_extract(chunk.value, '$.content.type') <> 'text'
275 OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
276 )
277 )
278 ORDER BY position DESC, stable_id DESC
279 LIMIT 1",
280 params![session_id, after_position, through_position],
281 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
282 )
283 .optional()?;
284 let Some((position, body_json)) = row else {
285 return Ok(None);
286 };
287 let body: TranscriptBody = serde_json::from_str(&body_json)
288 .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
289 let TranscriptBody::Agent { chunks, .. } = body else {
290 return Ok(None);
291 };
292 let text = mj_core::transcript::materialized_chunks_text(&chunks);
293 Ok((!text.trim().is_empty()).then_some((position, text)))
294}
295
296pub type MaterializedTurnState = (
298 MaterializedExecutionState,
299 Option<MaterializedTurn>,
300 Option<MaterializedTurnOutcome>,
301);
302
303pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
307 load_materialized_turn_outcome_from(&database_path(), session_id)
308}
309
310pub(super) fn load_materialized_turn_outcome_from(
311 path: &Path,
312 session_id: &str,
313) -> Result<Option<MaterializedTurnState>> {
314 let connection = open_reader(path)?;
315 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
316 return Ok(None);
317 };
318 Ok(Some((
319 fields.execution,
320 fields.active_turn,
321 fields.last_turn_outcome,
322 )))
323}
324
325pub fn load_materialized_finished_turn_message(session_id: &str) -> Result<Option<String>> {
336 load_materialized_finished_turn_message_from(&database_path(), session_id)
337}
338
339pub(super) fn load_materialized_finished_turn_message_from(
340 path: &Path,
341 session_id: &str,
342) -> Result<Option<String>> {
343 let connection = open_reader(path)?;
344 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
345 return Ok(None);
346 };
347 let Some(turn) = fields.last_turn_outcome else {
348 return Ok(None);
349 };
350 let Some(start_position) = turn.turn_start_position else {
351 return Ok(None);
352 };
353 last_materialized_agent_message_within(
354 &connection,
355 session_id,
356 start_position,
357 turn.completed_ordinal,
358 )
359}
360
361pub fn load_materialized_turn_summary(
369 session_id: &str,
370 turn_start_position: u64,
371 turn_completed_position: u64,
372) -> Result<TurnSummary> {
373 load_materialized_turn_summary_from(
374 &database_path(),
375 session_id,
376 turn_start_position,
377 turn_completed_position,
378 )
379}
380
381pub(super) fn load_materialized_turn_summary_from(
382 path: &Path,
383 session_id: &str,
384 turn_start_position: u64,
385 turn_completed_position: u64,
386) -> Result<TurnSummary> {
387 let connection = open_reader(path)?;
388 let turn_number = connection.query_row(
389 "SELECT COUNT(*)
390 FROM materialized_transcript_items
391 WHERE session_id = ?1
392 AND position <= ?3
393 AND (
394 stable_id GLOB ?2
395 OR json_extract(
396 CASE
397 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
398 THEN body_json
399 ELSE '{}'
400 END,
401 '$.kind'
402 ) = 'user'
403 )",
404 params![
405 session_id,
406 format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
407 turn_start_position
408 ],
409 |row| row.get::<_, u64>(0),
410 )?;
411 let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
412 "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
413 FROM materialized_transcript_items
414 WHERE session_id = ?1 AND position >= ?2 AND position <= ?3",
415 params![session_id, turn_start_position, turn_completed_position],
416 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
417 )?;
418 let final_message = last_materialized_agent_message_within(
419 &connection,
420 session_id,
421 turn_start_position,
422 turn_completed_position,
423 )?;
424 Ok(TurnSummary {
425 turn_number,
426 turn_started_at_ms,
427 last_changed_at_ms,
428 final_message,
429 })
430}
431
432pub fn load_materialized_transcript_filtered(
433 session_id: &str,
434 after_seq: u64,
435 limit: usize,
436 role: Option<mj_core::transcript::TranscriptRole>,
437) -> Result<Option<TranscriptPage>> {
438 load_materialized_transcript_filtered_from(&database_path(), session_id, after_seq, limit, role)
439}
440
441pub(super) fn load_materialized_transcript_filtered_from(
442 path: &Path,
443 session_id: &str,
444 after_seq: u64,
445 limit: usize,
446 role: Option<mj_core::transcript::TranscriptRole>,
447) -> Result<Option<TranscriptPage>> {
448 let mut reader = open_reader(path)?;
449 let connection = reader.transaction()?;
450 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
451 return Ok(None);
452 };
453 let role = role.map(|r| r.storage_kind());
454 let mut statement = connection.prepare(
455 "WITH matches AS (
456 SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
457 FROM materialized_transcript_items WHERE session_id = ?1
458 AND COALESCE(latest_content_event_ordinal, position) > ?2
459 AND (?4 IS NULL OR json_extract(body_json, '$.kind') = ?4)
460 ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
461 SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
462 last_changed_at_ms, body_json
463 FROM matches WHERE seq <= (SELECT seq FROM boundary)
464 ORDER BY seq, stable_id",
465 )?;
466 let rows = statement
467 .query_map(
468 params![session_id, after_seq, limit.clamp(1, 1000) as i64, role],
469 |row| {
470 Ok((
471 row.get::<_, String>(0)?,
472 row.get::<_, u64>(1)?,
473 row.get::<_, Option<u64>>(2)?,
474 row.get::<_, i64>(3)?,
475 row.get::<_, i64>(4)?,
476 row.get::<_, String>(5)?,
477 ))
478 },
479 )?
480 .collect::<rusqlite::Result<Vec<_>>>()?;
481 let items = rows
482 .into_iter()
483 .map(
484 |(
485 stable_id,
486 position,
487 latest_content_event_ordinal,
488 created_at_ms,
489 last_changed_at_ms,
490 body_json,
491 )| {
492 Ok(Arc::new(TranscriptItem {
493 stable_id,
494 position,
495 latest_content_event_ordinal,
496 created_at_ms,
497 last_changed_at_ms,
498 body: decode_transcript_body(&body_json, session_id)?,
499 }))
500 },
501 )
502 .collect::<Result<Vec<_>>>()?;
503 let latest_seq = connection.query_row(
504 "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
505 FROM materialized_transcript_items
506 WHERE session_id = ?1",
507 [session_id],
508 |row| row.get::<_, u64>(0),
509 )?;
510 let last_seq = items.last().map_or(after_seq, |item| item.seq());
511 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))?;
512 Ok(Some(TranscriptPage {
513 next_after_seq: if more {
514 last_seq
515 } else {
516 latest_seq.max(after_seq)
517 },
518 items,
519 latest_seq,
520 execution: fields.execution,
521 }))
522}
523
524pub(super) const RETENTION_BATCH_ITEMS: usize = 4_096;
531
532pub(super) const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
535
536pub fn compact_materialized_transcript_through(
549 session_id: &str,
550 event_frontier: u64,
551) -> Result<TranscriptRetention> {
552 let session_id = session_id.to_owned();
553 submit_database_write("compact_materialized_transcript", move |_| {
554 compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
555 })
556}
557
558pub(super) fn compact_materialized_transcript_in(
559 path: &Path,
560 session_id: &str,
561 event_frontier: u64,
562) -> Result<TranscriptRetention> {
563 let mut connection = open(path)?;
564 let candidates = {
565 let mut statement = connection.prepare(
566 "SELECT stable_id, body_json
567 FROM materialized_transcript_items
568 WHERE session_id = ?1
569 AND position <= ?2
570 AND length(body_json) > ?3
571 AND json_extract(
572 CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
573 '$.kind'
574 ) = 'tool'
575 ORDER BY position, stable_id
576 LIMIT ?4",
577 )?;
578 statement
579 .query_map(
580 params![
581 session_id,
582 event_frontier,
583 RETENTION_BODY_FLOOR_BYTES as i64,
584 RETENTION_BATCH_ITEMS as i64 + 1
585 ],
586 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
587 )?
588 .collect::<rusqlite::Result<Vec<_>>>()?
589 };
590 let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
591 let mut retention = TranscriptRetention {
592 remaining,
593 ..TranscriptRetention::default()
594 };
595 let transaction =
596 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
597 for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
598 let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
599 Ok(body) => body,
600 Err(error) => {
602 tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
603 continue;
604 }
605 };
606 if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
607 continue;
608 }
609 let compacted = serde_json::to_string(&body)
610 .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
611 if compacted.len() >= body_json.len() {
612 continue;
613 }
614 transaction.execute(
615 "UPDATE materialized_transcript_items SET body_json = ?3
616 WHERE session_id = ?1 AND stable_id = ?2",
617 params![session_id, stable_id, compacted],
618 )?;
619 retention.items += 1;
620 retention.bytes += body_json.len() - compacted.len();
621 }
622 transaction.commit()?;
623 Ok(retention)
624}
625
626pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
634
635pub fn load_materialized_projection_tail(
645 session_id: &str,
646 transcript_limit: usize,
647) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
648 load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
649}
650
651pub(super) fn load_materialized_projection_tail_from(
652 path: &Path,
653 session_id: &str,
654 transcript_limit: usize,
655) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
656 let connection = open_reader(path)?;
657 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
658 return Ok(None);
659 };
660 let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
661 let total_items = connection.query_row(
662 "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
663 [session_id],
664 |row| row.get::<_, usize>(0),
665 )?;
666 let window = ProjectionWindow {
667 omitted_items: total_items.saturating_sub(transcript.len()),
668 provisional_title: first_materialized_user_message(&connection, session_id)?
669 .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
670 latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
671 };
672 let materialized = MaterializedSession {
673 session_id: session_id.to_owned(),
674 applied_event_ordinal: fields.applied_event_ordinal,
675 applied_event_digest: fields.applied_event_digest,
676 last_activity_at_ms: fields.last_activity_at_ms,
677 execution: fields.execution,
678 session_title: fields.session_title,
679 configuration: fields.configuration,
680 transcript,
681 queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
682 pending_elicitations: fields.pending_elicitations,
683 active_turn: fields.active_turn,
684 last_turn_outcome: fields.last_turn_outcome,
685 };
686 materialized.validate()?;
687 Ok(Some((materialized, window)))
688}
689
690pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
694 materialized_event_frontier_from(&database_path(), session_id)
695}
696
697pub(super) fn materialized_event_frontier_from(
698 path: &Path,
699 session_id: &str,
700) -> Result<Option<(u64, String)>> {
701 Ok(open_reader(path)?
702 .query_row(
703 "SELECT applied_event_ordinal, applied_event_digest
704 FROM materialized_sessions WHERE session_id = ?1",
705 [session_id],
706 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
707 )
708 .optional()?)
709}
710
711pub fn replace_materialized_queued_prompts(
715 session_id: &str,
716 queued_prompts: &[MaterializedQueuedPrompt],
717) -> Result<()> {
718 let session_id = session_id.to_owned();
719 let queued_prompts = queued_prompts.to_vec();
720 submit_database_write("replace_materialized_queued_prompts", move |_| {
721 replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
722 })
723}
724
725pub(super) fn replace_materialized_queued_prompts_in(
726 path: &Path,
727 session_id: &str,
728 queued_prompts: &[MaterializedQueuedPrompt],
729) -> Result<()> {
730 let mut connection = open(path)?;
731 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
732 if !session_exists(&tx, session_id)? {
733 bail!("unknown session {session_id}");
734 }
735 replace_materialized_queue(&tx, session_id, queued_prompts)?;
736 tx.commit()?;
737 Ok(())
738}
739
740pub fn load_transcribed_session_activity() -> Result<BTreeMap<String, Option<i64>>> {
747 load_transcribed_session_activity_from(&database_path())
748}
749
750fn load_transcribed_session_activity_from(path: &Path) -> Result<BTreeMap<String, Option<i64>>> {
751 let connection = open_reader(path)?;
752 let mut statement = connection.prepare(
753 "SELECT session_id, last_activity_at_ms
754 FROM materialized_sessions s
755 WHERE EXISTS (
756 SELECT 1 FROM materialized_transcript_items i
757 WHERE i.session_id = s.session_id
758 )",
759 )?;
760 let rows = statement.query_map([], |row| {
761 Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
762 })?;
763 let mut activity = BTreeMap::new();
764 for row in rows {
765 let (session_id, last_activity_at_ms) = row?;
766 activity.insert(session_id, last_activity_at_ms);
767 }
768 Ok(activity)
769}
770
771pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
775{
776 load_materialized_queued_prompts_from(&database_path())
777}
778
779pub(super) fn load_materialized_queued_prompts_from(
780 path: &Path,
781) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
782 let connection = open_reader(path)?;
783 let mut statement = connection.prepare(
784 "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
785 FROM materialized_queued_prompts
786 ORDER BY session_id, ordinal",
787 )?;
788 let rows = statement.query_map([], |row| {
789 Ok((
790 row.get::<_, String>(0)?,
791 row.get::<_, String>(1)?,
792 row.get::<_, String>(2)?,
793 row.get::<_, String>(3)?,
794 row.get::<_, i64>(4)?,
795 row.get::<_, Option<u64>>(5)?,
796 ))
797 })?;
798 let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
799 for row in rows {
800 let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
801 row?;
802 let content = serde_json::from_str(&content_json).with_context(|| {
803 format!("parse materialized queued prompt for session {session_id}")
804 })?;
805 let kind = serde_json::from_str(&kind_json).with_context(|| {
806 format!("parse materialized queue entry kind for session {session_id}")
807 })?;
808 queues
809 .entry(session_id)
810 .or_default()
811 .push(MaterializedQueuedPrompt {
812 command_id,
813 kind,
814 content,
815 queued_at_ms,
816 accepted_ordinal,
817 });
818 }
819 Ok(queues)
820}
821
822pub(super) fn load_materialized_session_from(
823 path: &Path,
824 session_id: &str,
825) -> Result<Option<MaterializedSession>> {
826 let connection = open_reader(path)?;
827 load_materialized_session_with(&connection, session_id)
828}
829
830pub(super) fn load_materialized_session_with(
831 connection: &Connection,
832 session_id: &str,
833) -> Result<Option<MaterializedSession>> {
834 let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
835 return Ok(None);
836 };
837 let materialized = MaterializedSession {
838 session_id: session_id.to_owned(),
839 applied_event_ordinal: fields.applied_event_ordinal,
840 applied_event_digest: fields.applied_event_digest,
841 last_activity_at_ms: fields.last_activity_at_ms,
842 execution: fields.execution,
843 session_title: fields.session_title,
844 configuration: fields.configuration,
845 transcript: read_materialized_transcript(connection, session_id, None)?,
846 queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
847 pending_elicitations: fields.pending_elicitations,
848 active_turn: fields.active_turn,
849 last_turn_outcome: fields.last_turn_outcome,
850 };
851 materialized.validate()?;
852 Ok(Some(materialized))
853}
854
855pub(super) struct MaterializedSessionFields {
857 pub(super) applied_event_ordinal: u64,
858 pub(super) applied_event_digest: String,
859 pub(super) last_activity_at_ms: Option<i64>,
860 pub(super) execution: MaterializedExecutionState,
861 pub(super) session_title: Option<String>,
862 pub(super) configuration: BTreeMap<String, serde_json::Value>,
863 pub(super) pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
864 pub(super) active_turn: Option<MaterializedTurn>,
865 pub(super) last_turn_outcome: Option<MaterializedTurnOutcome>,
866}
867
868pub(super) fn read_materialized_session_fields(
869 connection: &Connection,
870 session_id: &str,
871) -> Result<Option<MaterializedSessionFields>> {
872 let row = connection
873 .query_row(
874 "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
875 execution_state, running_started_at_ms, session_title, configuration_json,
876 pending_elicitations_json, active_turn_json, last_turn_outcome_json
877 FROM materialized_sessions WHERE session_id = ?1",
878 [session_id],
879 |row| {
880 Ok((
881 row.get::<_, u64>(0)?,
882 row.get::<_, String>(1)?,
883 row.get::<_, Option<i64>>(2)?,
884 row.get::<_, String>(3)?,
885 row.get::<_, Option<i64>>(4)?,
886 row.get::<_, Option<String>>(5)?,
887 row.get::<_, String>(6)?,
888 row.get::<_, String>(7)?,
889 row.get::<_, Option<String>>(8)?,
890 row.get::<_, Option<String>>(9)?,
891 ))
892 },
893 )
894 .optional()?;
895 let Some((
896 applied_event_ordinal,
897 applied_event_digest,
898 last_activity_at_ms,
899 execution,
900 running_started_at_ms,
901 session_title,
902 configuration_json,
903 pending_elicitations_json,
904 active_turn_json,
905 last_turn_outcome_json,
906 )) = row
907 else {
908 return Ok(None);
909 };
910 Ok(Some(MaterializedSessionFields {
911 applied_event_ordinal,
912 applied_event_digest,
913 last_activity_at_ms,
914 execution: parse_materialized_execution(&execution, running_started_at_ms)?,
915 session_title,
916 configuration: serde_json::from_str(&configuration_json).with_context(|| {
917 format!("parse materialized configuration for session {session_id}")
918 })?,
919 pending_elicitations: serde_json::from_str(&pending_elicitations_json)
920 .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
921 active_turn: active_turn_json
922 .as_deref()
923 .map(serde_json::from_str)
924 .transpose()
925 .with_context(|| format!("parse active turn for session {session_id}"))?,
926 last_turn_outcome: last_turn_outcome_json
927 .as_deref()
928 .map(serde_json::from_str)
929 .transpose()
930 .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
931 }))
932}
933
934pub(super) fn read_materialized_transcript(
938 connection: &Connection,
939 session_id: &str,
940 limit: Option<usize>,
941) -> Result<Vec<Arc<TranscriptItem>>> {
942 let mut statement = connection.prepare(match limit {
943 Some(_) => {
944 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
945 last_changed_at_ms, body_json
946 FROM materialized_transcript_items
947 WHERE session_id = ?1
948 ORDER BY position DESC, stable_id DESC
949 LIMIT ?2"
950 }
951 None => {
952 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
953 last_changed_at_ms, body_json
954 FROM materialized_transcript_items
955 WHERE session_id = ?1
956 ORDER BY position, stable_id"
957 }
958 })?;
959 let read = |row: &rusqlite::Row<'_>| {
960 Ok((
961 row.get::<_, String>(0)?,
962 row.get::<_, u64>(1)?,
963 row.get::<_, Option<u64>>(2)?,
964 row.get::<_, i64>(3)?,
965 row.get::<_, i64>(4)?,
966 row.get::<_, String>(5)?,
967 ))
968 };
969 let rows = match limit {
970 Some(limit) => statement
971 .query_map(params![session_id, limit as i64], read)?
972 .collect::<rusqlite::Result<Vec<_>>>()?,
973 None => statement
974 .query_map([session_id], read)?
975 .collect::<rusqlite::Result<Vec<_>>>()?,
976 };
977 let mut transcript = rows
978 .into_iter()
979 .map(
980 |(
981 stable_id,
982 position,
983 latest_content_event_ordinal,
984 created_at_ms,
985 last_changed_at_ms,
986 body_json,
987 )| {
988 Ok(Arc::new(TranscriptItem {
989 stable_id,
990 position,
991 latest_content_event_ordinal,
992 created_at_ms,
993 last_changed_at_ms,
994 body: decode_transcript_body(&body_json, session_id)?,
995 }))
996 },
997 )
998 .collect::<Result<Vec<_>>>()?;
999 if limit.is_some() {
1000 transcript.reverse();
1003 }
1004 Ok(transcript)
1005}
1006
1007pub(super) fn read_materialized_queued_prompts(
1008 connection: &Connection,
1009 session_id: &str,
1010) -> Result<Vec<MaterializedQueuedPrompt>> {
1011 let mut statement = connection.prepare(
1012 "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
1013 FROM materialized_queued_prompts
1014 WHERE session_id = ?1
1015 ORDER BY ordinal",
1016 )?;
1017 let rows = statement
1018 .query_map([session_id], |row| {
1019 Ok((
1020 row.get::<_, String>(0)?,
1021 row.get::<_, String>(1)?,
1022 row.get::<_, String>(2)?,
1023 row.get::<_, i64>(3)?,
1024 row.get::<_, Option<u64>>(4)?,
1025 ))
1026 })?
1027 .collect::<rusqlite::Result<Vec<_>>>()?;
1028 rows.into_iter()
1029 .map(
1030 |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
1031 Ok(MaterializedQueuedPrompt {
1032 command_id,
1033 kind: serde_json::from_str(&kind_json).with_context(|| {
1034 format!("parse materialized queue entry kind for session {session_id}")
1035 })?,
1036 content: serde_json::from_str(&content_json).with_context(|| {
1037 format!("parse materialized queued prompt for session {session_id}")
1038 })?,
1039 queued_at_ms,
1040 accepted_ordinal,
1041 })
1042 },
1043 )
1044 .collect()
1045}
1046
1047pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
1051 let materialized = materialized.clone();
1052 submit_database_write("save_materialized_session", move |_| {
1053 save_materialized_session_to(&database_path(), &materialized)
1054 })
1055}
1056
1057pub(super) fn save_materialized_session_to(
1058 path: &Path,
1059 materialized: &MaterializedSession,
1060) -> Result<()> {
1061 materialized.validate()?;
1062 let mut connection = open(path)?;
1063 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1064 if !session_exists(&tx, &materialized.session_id)? {
1065 bail!("unknown session {}", materialized.session_id);
1066 }
1067 write_materialized_session(&tx, materialized)?;
1068 tx.commit()?;
1069 Ok(())
1070}
1071
1072pub struct ProjectionPage<'a> {
1077 pub(super) session_id: &'a str,
1078 pub(super) transaction: Transaction<'a>,
1079 pub(super) applied_ordinal: u64,
1080 pub(super) applied_digest: String,
1081 pub(super) dirty: bool,
1082 pub(super) pending: MaterializedSessionMutation,
1083 pub(super) pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
1084 pub(super) pending_turns: Vec<MaterializedTurnOutcome>,
1085 pub(super) pending_events: Vec<(i64, ApiEventData)>,
1086}
1087
1088pub(super) struct PendingTranscriptMutation {
1089 pub(super) final_mutation: TranscriptMutation,
1090 pub(super) remove_before_upsert: bool,
1091}
1092
1093impl ProjectionPage<'_> {
1094 pub fn apply(
1098 &mut self,
1099 event_ordinal: u64,
1100 previous_event_digest: &str,
1101 event_digest: &str,
1102 mutation: &MaterializedSessionMutation,
1103 ) -> Result<ProjectionApplyOutcome> {
1104 if event_ordinal == 0 {
1105 bail!("relay event ordinal must be positive");
1106 }
1107 let chained = !previous_event_digest.is_empty();
1113 if chained {
1114 validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
1115 }
1116 validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
1117 let session_id = self.session_id;
1118 let applied = self.applied_ordinal;
1119 if event_ordinal < applied {
1120 return Ok(ProjectionApplyOutcome::AlreadyApplied);
1121 }
1122 if event_ordinal == applied {
1123 if event_digest != self.applied_digest {
1124 bail!(
1125 "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
1126 self.applied_digest
1127 );
1128 }
1129 return Ok(ProjectionApplyOutcome::AlreadyApplied);
1130 }
1131 let expected = applied
1132 .checked_add(1)
1133 .context("materialized event ordinal overflow")?;
1134 if event_ordinal != expected {
1135 bail!(
1136 "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
1137 );
1138 }
1139 if chained && previous_event_digest != self.applied_digest {
1140 bail!(
1141 "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
1142 self.applied_digest
1143 );
1144 }
1145
1146 if let Some(activity_at_ms) = mutation.last_activity_at_ms {
1147 self.pending.last_activity_at_ms = Some(
1148 self.pending
1149 .last_activity_at_ms
1150 .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
1151 );
1152 }
1153 if let Some(execution) = mutation.execution {
1154 self.pending.execution = Some(execution);
1155 }
1156 if let Some(title) = &mutation.session_title {
1157 if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
1158 bail!("materialized session title cannot be empty");
1159 }
1160 self.pending.session_title = Some(title.clone());
1161 }
1162 if let Some(configuration) = &mutation.configuration {
1163 self.pending.configuration = Some(configuration.clone());
1164 }
1165 for item_mutation in &mutation.transcript {
1166 match item_mutation {
1167 TranscriptMutation::Upsert(item) => {
1168 item.validate(event_ordinal)?;
1169 let stable_id = item.stable_id.clone();
1170 let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
1171 PendingTranscriptMutation {
1172 final_mutation: TranscriptMutation::Upsert(item.clone()),
1173 remove_before_upsert: false,
1174 }
1175 });
1176 entry.remove_before_upsert |=
1177 matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
1178 entry.final_mutation = TranscriptMutation::Upsert(item.clone());
1179 }
1180 TranscriptMutation::Remove { stable_id } => {
1181 if stable_id.trim().is_empty() {
1182 bail!("cannot remove a transcript item with an empty stable id");
1183 }
1184 let removed = TranscriptMutation::Remove {
1185 stable_id: stable_id.clone(),
1186 };
1187 self.pending_transcript
1188 .entry(stable_id.clone())
1189 .and_modify(|entry| entry.final_mutation = removed.clone())
1190 .or_insert(PendingTranscriptMutation {
1191 final_mutation: removed,
1192 remove_before_upsert: false,
1193 });
1194 }
1195 }
1196 }
1197 if let Some(queued_prompts) = &mutation.queued_prompts {
1198 self.pending.queued_prompts = Some(queued_prompts.clone());
1199 }
1200 if let Some(pending_elicitations) = &mutation.pending_elicitations {
1201 self.pending.pending_elicitations = Some(pending_elicitations.clone());
1202 }
1203 self.pending
1204 .config_results
1205 .extend(mutation.config_results.clone());
1206 if let Some(active_turn) = &mutation.active_turn {
1207 self.pending.active_turn = Some(active_turn.clone());
1208 }
1209 if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
1210 self.pending_turns.push(last_turn_outcome.clone());
1211 self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
1212 }
1213 if let Some(cost) = &mutation.provider_cost {
1214 self.pending.provider_cost = Some(cost.clone());
1215 }
1216 self.pending_events.extend(
1217 mutation
1218 .api_events
1219 .iter()
1220 .cloned()
1221 .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
1222 );
1223 self.applied_ordinal = event_ordinal;
1224 event_digest.clone_into(&mut self.applied_digest);
1225 self.dirty = true;
1226 Ok(ProjectionApplyOutcome::Applied)
1227 }
1228
1229 pub(super) fn flush(&mut self) -> Result<()> {
1233 if !self.dirty {
1234 return Ok(());
1235 }
1236 let tx = &self.transaction;
1237 let session_id = self.session_id;
1238 if let Some(execution) = self.pending.execution {
1239 let (state, started_at_ms) = materialized_execution_columns(execution);
1240 tx.execute(
1241 "UPDATE materialized_sessions
1242 SET execution_state = ?2, running_started_at_ms = ?3
1243 WHERE session_id = ?1",
1244 params![session_id, state, started_at_ms],
1245 )?;
1246 }
1247 if let Some(title) = &self.pending.session_title {
1248 tx.execute(
1249 "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
1250 params![session_id, title],
1251 )?;
1252 }
1253 if let Some(configuration) = &self.pending.configuration {
1254 tx.execute(
1255 "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
1256 params![session_id, serde_json::to_string(configuration)?],
1257 )?;
1258 }
1259 for pending in self.pending_transcript.values() {
1260 match &pending.final_mutation {
1261 TranscriptMutation::Upsert(item) => {
1262 if pending.remove_before_upsert {
1266 tx.execute(
1267 "DELETE FROM materialized_transcript_items
1268 WHERE session_id = ?1 AND stable_id = ?2",
1269 params![session_id, item.stable_id],
1270 )?;
1271 }
1272 upsert_transcript_item(tx, session_id, item)?;
1273 }
1274 TranscriptMutation::Remove { stable_id } => {
1275 tx.execute(
1276 "DELETE FROM materialized_transcript_items
1277 WHERE session_id = ?1 AND stable_id = ?2",
1278 params![session_id, stable_id],
1279 )?;
1280 }
1281 }
1282 }
1283 if let Some(queued_prompts) = &self.pending.queued_prompts {
1284 replace_materialized_queue(tx, session_id, queued_prompts)?;
1285 }
1286 if let Some(pending_elicitations) = &self.pending.pending_elicitations {
1287 tx.execute(
1288 "UPDATE materialized_sessions
1289 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
1290 params![session_id, serde_json::to_string(pending_elicitations)?],
1291 )?;
1292 }
1293 for (recorded_at_ms, event) in &self.pending_events {
1294 events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
1295 }
1296 for turn in &self.pending_turns {
1297 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)?])?;
1298 }
1299 if let Some(cost) = &self.pending.provider_cost {
1300 tx.execute(
1301 "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
1302 params![session_id, serde_json::to_string(cost)?],
1303 )?;
1304 }
1305 for (command_id, error) in &self.pending.config_results {
1306 tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
1307 }
1308 if let Some(active_turn) = &self.pending.active_turn {
1309 tx.execute(
1310 "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
1311 params![
1312 session_id,
1313 active_turn
1314 .as_ref()
1315 .map(serde_json::to_string)
1316 .transpose()?
1317 ],
1318 )?;
1319 }
1320 if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
1321 tx.execute(
1322 "UPDATE materialized_sessions
1323 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
1324 params![session_id, serde_json::to_string(last_turn_outcome)?],
1325 )?;
1326 }
1327 tx.execute(
1328 "UPDATE materialized_sessions
1329 SET last_activity_at_ms = CASE
1330 WHEN ?2 IS NULL THEN last_activity_at_ms
1331 WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
1332 ELSE last_activity_at_ms
1333 END,
1334 applied_event_ordinal = ?3,
1335 applied_event_digest = ?4
1336 WHERE session_id = ?1",
1337 params![
1338 session_id,
1339 self.pending.last_activity_at_ms,
1340 self.applied_ordinal,
1341 self.applied_digest,
1342 ],
1343 )?;
1344 Ok(())
1345 }
1346}
1347
1348pub fn apply_projection_page<T>(
1353 session_id: &str,
1354 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
1355) -> Result<T>
1356where
1357 T: Send + 'static,
1358{
1359 let session_id = session_id.to_owned();
1360 submit_database_write("apply_projection_page", move |connection| {
1361 apply_projection_page_with(connection, &session_id, fill)
1362 })
1363}
1364
1365#[cfg(test)]
1366pub(super) fn apply_projection_page_to<T>(
1367 path: &Path,
1368 session_id: &str,
1369 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1370) -> Result<T> {
1371 let mut connection = open(path)?;
1372 apply_projection_page_with(&mut connection, session_id, fill)
1373}
1374
1375pub(super) fn apply_projection_page_with<T>(
1376 connection: &mut Connection,
1377 session_id: &str,
1378 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1379) -> Result<T> {
1380 let transaction =
1381 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1382 let (applied_ordinal, applied_digest) = transaction
1383 .query_row(
1384 "SELECT applied_event_ordinal, applied_event_digest
1385 FROM materialized_sessions WHERE session_id = ?1",
1386 [session_id],
1387 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
1388 )
1389 .optional()?
1390 .with_context(|| format!("unknown session {session_id}"))?;
1391 validate_relay_event_frontier(
1392 applied_ordinal,
1393 &applied_digest,
1394 "persisted relay event frontier",
1395 )?;
1396 let mut page = ProjectionPage {
1397 session_id,
1398 transaction,
1399 applied_ordinal,
1400 applied_digest,
1401 dirty: false,
1402 pending: MaterializedSessionMutation::default(),
1403 pending_transcript: BTreeMap::new(),
1404 pending_turns: Vec::new(),
1405 pending_events: Vec::new(),
1406 };
1407 let filled = fill(&mut page)?;
1410 page.flush()?;
1411 page.transaction.commit()?;
1412 Ok(filled)
1413}
1414
1415pub fn apply_projection_event(
1417 session_id: &str,
1418 event_ordinal: u64,
1419 previous_event_digest: &str,
1420 event_digest: &str,
1421 mutation: &MaterializedSessionMutation,
1422) -> Result<ProjectionApplyOutcome> {
1423 let session_id = session_id.to_owned();
1424 let previous_event_digest = previous_event_digest.to_owned();
1425 let event_digest = event_digest.to_owned();
1426 let mutation = mutation.clone();
1427 submit_database_write("apply_projection_event", move |connection| {
1428 apply_projection_page_with(connection, &session_id, |page| {
1429 page.apply(
1430 event_ordinal,
1431 &previous_event_digest,
1432 &event_digest,
1433 &mutation,
1434 )
1435 })
1436 })
1437}
1438
1439#[cfg(test)]
1440pub(super) fn apply_projection_event_to(
1441 path: &Path,
1442 session_id: &str,
1443 event_ordinal: u64,
1444 previous_event_digest: &str,
1445 event_digest: &str,
1446 mutation: &MaterializedSessionMutation,
1447) -> Result<ProjectionApplyOutcome> {
1448 apply_projection_page_to(path, session_id, |page| {
1449 page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
1450 })
1451}
1452
1453pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
1456 let session_id = session_id.to_owned();
1457 submit_database_write("advance_viewed_through_event_ordinal", move |_| {
1458 advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
1459 })
1460}
1461
1462pub(super) fn advance_viewed_through_event_ordinal_to(
1463 path: &Path,
1464 session_id: &str,
1465 through: u64,
1466) -> Result<u64> {
1467 let mut connection = open(path)?;
1468 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1469 let applied = tx
1470 .query_row(
1471 "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
1472 [session_id],
1473 |row| row.get::<_, u64>(0),
1474 )
1475 .optional()?
1476 .with_context(|| format!("unknown session {session_id}"))?;
1477 if through > applied {
1478 bail!(
1479 "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
1480 );
1481 }
1482 tx.execute(
1483 "UPDATE sessions
1484 SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
1485 WHERE session_id = ?1",
1486 params![session_id, through],
1487 )?;
1488 let receipt = tx.query_row(
1489 "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
1490 [session_id],
1491 |row| row.get::<_, u64>(0),
1492 )?;
1493 tx.commit()?;
1494 Ok(receipt)
1495}
1496
1497fn decode_transcript_body(body_json: &str, session_id: &str) -> Result<TranscriptBody> {
1503 let mut body: TranscriptBody = serde_json::from_str(body_json)
1504 .with_context(|| format!("parse materialized transcript body for session {session_id}"))?;
1505 match &mut body {
1506 TranscriptBody::Agent { chunks, .. } | TranscriptBody::Thought { chunks, .. } => {
1507 mj_core::transcript::coalesce_content_chunks(chunks);
1508 }
1509 _ => {}
1510 }
1511 Ok(body)
1512}