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 fn load_materialized_activity()
27-> Result<BTreeMap<String, (Option<i64>, MaterializedExecutionState)>> {
28 load_materialized_activity_from(&database_path())
29}
30
31pub(super) fn load_materialized_activity_from(
32 path: &Path,
33) -> Result<BTreeMap<String, (Option<i64>, MaterializedExecutionState)>> {
34 let mut reader = open_reader(path)?;
35 let connection = reader.transaction()?;
36 let mut statement = connection.prepare(
37 "SELECT session_id, last_activity_at_ms, execution_state, running_started_at_ms
38 FROM materialized_sessions",
39 )?;
40 let rows = statement
41 .query_map([], |row| {
42 Ok((
43 row.get::<_, String>(0)?,
44 row.get::<_, Option<i64>>(1)?,
45 row.get::<_, String>(2)?,
46 row.get::<_, Option<i64>>(3)?,
47 ))
48 })?
49 .collect::<rusqlite::Result<Vec<_>>>()?;
50
51 rows.into_iter()
52 .map(
53 |(session_id, last_activity_at_ms, execution_state, running_started_at_ms)| {
54 Ok((
55 session_id,
56 (
57 last_activity_at_ms,
58 parse_materialized_execution(&execution_state, running_started_at_ms)?,
59 ),
60 ))
61 },
62 )
63 .collect()
64}
65
66pub(super) fn load_materialized_session_summary_from(
67 path: &Path,
68 session_id: &str,
69) -> Result<Option<MaterializedSessionSummary>> {
70 let mut reader = open_reader(path)?;
71 let connection = reader.transaction()?;
72 let row = connection
73 .query_row(
74 "SELECT applied_event_ordinal, last_activity_at_ms, execution_state,
75 running_started_at_ms, session_title
76 FROM materialized_sessions WHERE session_id = ?1",
77 [session_id],
78 |row| {
79 Ok((
80 row.get::<_, u64>(0)?,
81 row.get::<_, Option<i64>>(1)?,
82 row.get::<_, String>(2)?,
83 row.get::<_, Option<i64>>(3)?,
84 row.get::<_, Option<String>>(4)?,
85 ))
86 },
87 )
88 .optional()?;
89 let Some((
90 applied_event_ordinal,
91 last_activity_at_ms,
92 execution,
93 running_started_at_ms,
94 session_title,
95 )) = row
96 else {
97 return Ok(None);
98 };
99
100 #[cfg(test)]
101 super::tests::after_materialized_frontier_read();
102 let last_user_message = last_materialized_user_message(&connection, session_id)?;
103 let last_agent_message = last_materialized_agent_message(&connection, session_id)?;
104 let last_agent_message_follows_last_user =
105 last_agent_message
106 .as_ref()
107 .is_some_and(|(agent_position, _)| {
108 last_user_message
109 .as_ref()
110 .is_none_or(|(user_position, _)| agent_position > user_position)
111 });
112 let mut ordinal_statement = connection.prepare(
113 "SELECT latest_content_event_ordinal
114 FROM materialized_transcript_items
115 WHERE session_id = ?1
116 AND latest_content_event_ordinal IS NOT NULL
117 AND EXISTS (
118 SELECT 1 FROM json_each(
119 CASE
120 WHEN latest_content_event_ordinal IS NOT NULL
121 AND json_valid(body_json)
122 THEN body_json
123 ELSE '{}'
124 END,
125 '$.chunks'
126 ) AS chunk
127 WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
128 AND (
129 json_extract(chunk.value, '$.content.type') <> 'text'
130 OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
131 )
132 )
133 ORDER BY position, stable_id",
134 )?;
135 let agent_message_latest_content_ordinals = ordinal_statement
136 .query_map([session_id], |row| row.get::<_, u64>(0))?
137 .collect::<rusqlite::Result<Vec<_>>>()?;
138 let restart_pattern = format!("{}*", mj_core::transcript::WORK_INTERRUPTED_ITEM_PREFIX);
139 let mut restart_statement = connection.prepare(
140 "SELECT position
141 FROM materialized_transcript_items
142 WHERE session_id = ?1 AND stable_id GLOB ?2
143 ORDER BY position, stable_id",
144 )?;
145 let mut interruption_event_ordinals = restart_statement
146 .query_map((session_id, restart_pattern), |row| row.get::<_, u64>(0))?
147 .collect::<rusqlite::Result<Vec<_>>>()?;
148 let outcome: Option<String> = connection.query_row(
149 "SELECT last_turn_outcome_json FROM materialized_sessions WHERE session_id = ?1",
150 [session_id],
151 |row| row.get(0),
152 )?;
153 if let Some(ordinal) = outcome
154 .map(|json| deserialize_turn_outcome(&json))
155 .transpose()?
156 .as_ref()
157 .and_then(MaterializedTurnOutcome::interruption_ordinal)
158 {
159 interruption_event_ordinals.push(ordinal);
160 }
161 interruption_event_ordinals.sort_unstable();
162 interruption_event_ordinals.dedup();
163
164 Ok(Some(MaterializedSessionSummary {
165 session_id: session_id.to_owned(),
166 applied_event_ordinal,
167 last_activity_at_ms,
168 execution: parse_materialized_execution(&execution, running_started_at_ms)?,
169 session_title,
170 last_agent_message: last_agent_message.map(|(_, message)| message),
171 last_user_message: last_user_message.map(|(_, message)| message),
172 last_agent_message_follows_last_user,
173 agent_message_latest_content_ordinals,
174 interruption_event_ordinals,
175 }))
176}
177
178pub(super) fn first_materialized_user_message(
182 connection: &Connection,
183 session_id: &str,
184) -> Result<Option<(u64, String)>> {
185 materialized_user_message(connection, session_id, true)
186}
187
188pub(super) fn last_materialized_user_message(
189 connection: &Connection,
190 session_id: &str,
191) -> Result<Option<(u64, String)>> {
192 materialized_user_message(connection, session_id, false)
193}
194
195pub(super) fn materialized_user_message(
196 connection: &Connection,
197 session_id: &str,
198 oldest_first: bool,
199) -> Result<Option<(u64, String)>> {
200 let mut statement = connection.prepare(if oldest_first {
201 "SELECT position, body_json
202 FROM materialized_transcript_items
203 WHERE session_id = ?1
204 AND json_extract(
205 CASE
206 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
207 THEN body_json
208 ELSE '{}'
209 END,
210 '$.kind'
211 ) = 'user'
212 ORDER BY position, stable_id"
213 } else {
214 "SELECT position, body_json
215 FROM materialized_transcript_items
216 WHERE session_id = ?1
217 AND json_extract(
218 CASE
219 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
220 THEN body_json
221 ELSE '{}'
222 END,
223 '$.kind'
224 ) = 'user'
225 ORDER BY position DESC, stable_id DESC"
226 })?;
227 let rows = statement.query_map([session_id], |row| {
228 Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
229 })?;
230 for row in rows {
231 let (position, body_json) = row?;
232 let body: TranscriptBody = serde_json::from_str(&body_json)
233 .with_context(|| format!("parse materialized user message for session {session_id}"))?;
234 let TranscriptBody::User { content } = body else {
235 continue;
236 };
237 let text = mj_core::transcript::materialized_content_text(&content);
238 if !text.trim().is_empty() {
239 return Ok(Some((position, text)));
240 }
241 }
242 Ok(None)
243}
244
245pub(super) fn last_materialized_turn_start(
249 connection: &Connection,
250 session_id: &str,
251) -> Result<Option<u64>> {
252 Ok(connection
253 .query_row(
254 "SELECT position
255 FROM materialized_transcript_items
256 WHERE session_id = ?1
257 AND (
258 stable_id GLOB ?2
259 OR json_extract(
260 CASE
261 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
262 THEN body_json
263 ELSE '{}'
264 END,
265 '$.kind'
266 ) = 'user'
267 )
268 ORDER BY position DESC, stable_id DESC
269 LIMIT 1",
270 params![
271 session_id,
272 format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX)
273 ],
274 |row| row.get::<_, u64>(0),
275 )
276 .optional()?)
277}
278
279pub(super) fn last_materialized_agent_message(
280 connection: &Connection,
281 session_id: &str,
282) -> Result<Option<(u64, String)>> {
283 last_materialized_agent_message_in(connection, session_id, 0, None)
284}
285
286pub(super) fn last_materialized_agent_message_within(
293 connection: &Connection,
294 session_id: &str,
295 after_position: u64,
296 through_position: u64,
297) -> Result<Option<String>> {
298 Ok(last_materialized_agent_message_in(
299 connection,
300 session_id,
301 after_position,
302 Some(through_position),
303 )?
304 .map(|(_, text)| text))
305}
306
307pub(super) fn last_materialized_agent_message_in(
308 connection: &Connection,
309 session_id: &str,
310 after_position: u64,
311 through_position: Option<u64>,
312) -> Result<Option<(u64, String)>> {
313 let row = connection
314 .query_row(
315 "SELECT position, body_json
316 FROM materialized_transcript_items
317 WHERE session_id = ?1
318 AND position > ?2
319 AND (?3 IS NULL OR position <= ?3)
320 AND latest_content_event_ordinal IS NOT NULL
321 AND EXISTS (
322 SELECT 1 FROM json_each(
323 CASE
324 WHEN latest_content_event_ordinal IS NOT NULL
325 AND json_valid(body_json)
326 THEN body_json
327 ELSE '{}'
328 END,
329 '$.chunks'
330 ) AS chunk
331 WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
332 AND (
333 json_extract(chunk.value, '$.content.type') <> 'text'
334 OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
335 )
336 )
337 ORDER BY position DESC, stable_id DESC
338 LIMIT 1",
339 params![session_id, after_position, through_position],
340 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
341 )
342 .optional()?;
343 let Some((position, body_json)) = row else {
344 return Ok(None);
345 };
346 let body: TranscriptBody = serde_json::from_str(&body_json)
347 .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
348 let TranscriptBody::Agent { chunks, .. } = body else {
349 return Ok(None);
350 };
351 let text = mj_core::transcript::materialized_chunks_text(&chunks);
352 Ok((!text.trim().is_empty()).then_some((position, text)))
353}
354
355pub type MaterializedTurnState = (
357 MaterializedExecutionState,
358 Option<MaterializedTurn>,
359 Option<MaterializedTurnOutcome>,
360);
361
362pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
366 load_materialized_turn_outcome_from(&database_path(), session_id)
367}
368
369pub fn load_prompt_acceptance(session_id: &str, command_id: &str) -> Result<Option<u64>> {
372 let mut reader = open_reader(&database_path())?;
373 let connection = reader.transaction()?;
374 if let Some(fields) = read_materialized_session_fields(&connection, session_id)? {
375 if let Some(turn) = fields
376 .active_turn
377 .filter(|turn| turn.command_id == command_id)
378 {
379 return turn
380 .accepted_ordinal
381 .map(Some)
382 .context("accepted prompt has no acceptance ordinal");
383 }
384 if let Some(turn) = fields
385 .last_turn_outcome
386 .filter(|turn| turn.command_id == command_id)
387 {
388 return turn
389 .accepted_ordinal
390 .map(Some)
391 .context("completed prompt has no acceptance ordinal");
392 }
393 }
394 let ordinal: Option<u64> = connection.query_row(
395 "SELECT accepted_ordinal FROM materialized_queued_prompts WHERE session_id=?1 AND command_id=?2
396 UNION ALL SELECT json_extract(body, '$.accepted_ordinal') FROM session_turn_usage
397 WHERE session_id=?1 AND command_id=?2 LIMIT 1",
398 params![session_id, command_id], |row| row.get(0),
399 ).optional()?.flatten();
400 if ordinal.is_some() {
401 return Ok(ordinal);
402 }
403 let started: bool = connection.query_row(
404 "SELECT EXISTS(SELECT 1 FROM materialized_transcript_items WHERE session_id=?1 AND stable_id IN (?2, ?3))",
405 params![session_id, format!("user:{command_id}"), format!("{}{command_id}", mj_core::archive::CONTEXT_BOUNDARY_PREFIX)], |row| row.get(0),
406 )?;
407 ensure!(
408 !started,
409 "input was delivered but its acceptance ordinal is unavailable; refusing to send it twice"
410 );
411 Ok(None)
412}
413
414pub(super) fn load_materialized_turn_outcome_from(
415 path: &Path,
416 session_id: &str,
417) -> Result<Option<MaterializedTurnState>> {
418 let connection = open_reader(path)?;
419 read_materialized_turn_state(&connection, session_id)
420}
421
422pub(super) fn read_materialized_turn_state(
424 connection: &Connection,
425 session_id: &str,
426) -> Result<Option<MaterializedTurnState>> {
427 let row = connection.query_row(
428 "SELECT execution_state, running_started_at_ms, active_turn_json, last_turn_outcome_json
429 FROM materialized_sessions WHERE session_id=?1",
430 [session_id],
431 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?,
432 row.get::<_, Option<String>>(2)?, row.get::<_, Option<String>>(3)?)),
433 ).optional()?;
434 let Some((execution, started, active, last)) = row else {
435 return Ok(None);
436 };
437 Ok(Some((
438 parse_materialized_execution(&execution, started)?,
439 active
440 .as_deref()
441 .map(serde_json::from_str)
442 .transpose()
443 .with_context(|| format!("parse active turn for session {session_id}"))?,
444 last.as_deref()
445 .map(deserialize_turn_outcome)
446 .transpose()
447 .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
448 )))
449}
450
451pub fn load_materialized_finished_turn_message(session_id: &str) -> Result<Option<String>> {
462 load_materialized_finished_turn_message_from(&database_path(), session_id)
463}
464
465pub(crate) struct ChildAnswerMessages {
468 pub latest: Option<String>,
469 pub finished: Option<String>,
470}
471
472pub(crate) fn load_child_answer_messages(
473 children: &[(String, Option<(u64, u64)>)],
474) -> Result<BTreeMap<String, ChildAnswerMessages>> {
475 let mut reader = open_reader(&database_path())?;
476 let connection = reader.transaction()?;
477 children
478 .iter()
479 .map(|(id, span)| {
480 let latest = last_materialized_agent_message(&connection, id)?.map(|(_, text)| text);
481 let finished = span
482 .map(|(start, end)| {
483 last_materialized_agent_message_within(&connection, id, start, end)
484 })
485 .transpose()?
486 .flatten();
487 Ok((id.clone(), ChildAnswerMessages { latest, finished }))
488 })
489 .collect()
490}
491
492pub(super) fn load_materialized_finished_turn_message_from(
493 path: &Path,
494 session_id: &str,
495) -> Result<Option<String>> {
496 let mut reader = open_reader(path)?;
497 let connection = reader.transaction()?;
498 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
499 return Ok(None);
500 };
501 let Some(turn) = fields.last_turn_outcome else {
502 return Ok(None);
503 };
504 let Some(start_position) = turn.turn_start_position else {
505 return Ok(None);
506 };
507 last_materialized_agent_message_within(
508 &connection,
509 session_id,
510 start_position,
511 turn.completed_ordinal,
512 )
513}
514
515pub fn load_materialized_turn_summary(
523 session_id: &str,
524 turn_start_position: u64,
525 turn_completed_position: u64,
526) -> Result<TurnSummary> {
527 load_materialized_turn_summary_from(
528 &database_path(),
529 session_id,
530 turn_start_position,
531 turn_completed_position,
532 )
533}
534
535pub(super) fn load_materialized_turn_summary_from(
536 path: &Path,
537 session_id: &str,
538 turn_start_position: u64,
539 turn_completed_position: u64,
540) -> Result<TurnSummary> {
541 let mut reader = open_reader(path)?;
542 let connection = reader.transaction()?;
543 let turn_number = connection.query_row(
544 "SELECT COUNT(*)
545 FROM materialized_transcript_items
546 WHERE session_id = ?1
547 AND position <= ?3
548 AND (
549 stable_id GLOB ?2
550 OR json_extract(
551 CASE
552 WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
553 THEN body_json
554 ELSE '{}'
555 END,
556 '$.kind'
557 ) = 'user'
558 )",
559 params![
560 session_id,
561 format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
562 turn_start_position
563 ],
564 |row| row.get::<_, u64>(0),
565 )?;
566 let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
567 "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
568 FROM materialized_transcript_items
569 WHERE session_id = ?1 AND position >= ?2 AND position <= ?3",
570 params![session_id, turn_start_position, turn_completed_position],
571 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
572 )?;
573 let final_message = last_materialized_agent_message_within(
574 &connection,
575 session_id,
576 turn_start_position,
577 turn_completed_position,
578 )?;
579 let tool_calls = connection.query_row(
580 "SELECT COUNT(*)
581 FROM materialized_transcript_items
582 WHERE session_id = ?1 AND position >= ?2 AND position <= ?3
583 AND json_extract(body_json, '$.kind') = 'tool'",
584 params![session_id, turn_start_position, turn_completed_position],
585 |row| row.get::<_, u64>(0),
586 )?;
587 Ok(TurnSummary {
588 turn_number,
589 turn_started_at_ms,
590 last_changed_at_ms,
591 final_message,
592 tool_calls,
593 })
594}
595
596pub fn load_materialized_transcript_filtered(
597 session_id: &str,
598 after_seq: u64,
599 limit: usize,
600 roles: Vec<mj_core::transcript::TranscriptRole>,
601 finished_only: bool,
602) -> Result<Option<TranscriptPage>> {
603 load_materialized_transcript_filtered_from(
604 &database_path(),
605 session_id,
606 after_seq,
607 limit,
608 roles,
609 finished_only,
610 )
611}
612
613pub(super) fn load_materialized_transcript_filtered_from(
614 path: &Path,
615 session_id: &str,
616 after_seq: u64,
617 limit: usize,
618 roles: Vec<mj_core::transcript::TranscriptRole>,
619 finished_only: bool,
620) -> Result<Option<TranscriptPage>> {
621 let mut reader = open_reader(path)?;
622 let connection = reader.transaction()?;
623 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
624 return Ok(None);
625 };
626 let role_kinds: Vec<_> = if finished_only {
627 vec!["agent"]
628 } else {
629 roles.iter().map(|role| role.storage_kind()).collect()
630 };
631 let role_kinds_json = serde_json::to_string(&role_kinds)?;
632 let open_agent_seq = if finished_only {
633 connection.query_row(
634 "SELECT MIN(COALESCE(latest_content_event_ordinal, position))
635 FROM materialized_transcript_items
636 WHERE session_id = ?1
637 AND COALESCE(latest_content_event_ordinal, position) > ?2
638 AND json_extract(body_json, '$.kind') = 'agent'
639 AND json_extract(body_json, '$.streaming') = 1",
640 params![session_id, after_seq],
641 |row| row.get::<_, Option<u64>>(0),
642 )?
643 } else {
644 None
645 };
646 let mut statement = connection.prepare(
647 "WITH matches AS (
648 SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
649 FROM materialized_transcript_items WHERE session_id = ?1
650 AND COALESCE(latest_content_event_ordinal, position) > ?2
651 AND (json_array_length(?4) = 0 OR json_extract(body_json, '$.kind')
652 IN (SELECT value FROM json_each(?4)))
653 AND (?5 = 0 OR (
654 json_extract(body_json, '$.kind') = 'agent'
655 AND json_extract(body_json, '$.streaming') = 0
656 AND (?6 IS NULL OR COALESCE(latest_content_event_ordinal, position) < ?6)
657 ))
658 ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
659 SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
660 last_changed_at_ms, body_json
661 FROM matches WHERE seq <= (SELECT seq FROM boundary)
662 ORDER BY seq, stable_id",
663 )?;
664 let rows = statement
665 .query_map(
666 params![
667 session_id,
668 after_seq,
669 limit.clamp(1, 1000) as i64,
670 role_kinds_json,
671 i64::from(finished_only),
672 open_agent_seq,
673 ],
674 |row| {
675 Ok((
676 row.get::<_, String>(0)?,
677 row.get::<_, u64>(1)?,
678 row.get::<_, Option<u64>>(2)?,
679 row.get::<_, i64>(3)?,
680 row.get::<_, i64>(4)?,
681 row.get::<_, String>(5)?,
682 ))
683 },
684 )?
685 .collect::<rusqlite::Result<Vec<_>>>()?;
686 let items = rows
687 .into_iter()
688 .map(
689 |(
690 stable_id,
691 position,
692 latest_content_event_ordinal,
693 created_at_ms,
694 last_changed_at_ms,
695 body_json,
696 )| {
697 Ok(Arc::new(TranscriptItem {
698 stable_id,
699 position,
700 latest_content_event_ordinal,
701 created_at_ms,
702 last_changed_at_ms,
703 body: decode_transcript_body(&body_json, session_id)?,
704 }))
705 },
706 )
707 .collect::<Result<Vec<_>>>()?;
708 let latest_seq = connection.query_row(
709 "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
710 FROM materialized_transcript_items
711 WHERE session_id = ?1",
712 [session_id],
713 |row| row.get::<_, u64>(0),
714 )?;
715 let last_seq = items.last().map_or(after_seq, |item| item.seq());
716 let more: bool = connection.query_row(
717 "SELECT EXISTS(
718 SELECT 1 FROM materialized_transcript_items
719 WHERE session_id = ?1
720 AND COALESCE(latest_content_event_ordinal, position) > ?2
721 AND (json_array_length(?3) = 0 OR json_extract(body_json, '$.kind')
722 IN (SELECT value FROM json_each(?3)))
723 AND (?4 = 0 OR (
724 json_extract(body_json, '$.kind') = 'agent'
725 AND json_extract(body_json, '$.streaming') = 0
726 AND (?5 IS NULL OR COALESCE(latest_content_event_ordinal, position) < ?5)
727 ))
728 )",
729 params![
730 session_id,
731 last_seq,
732 role_kinds_json,
733 i64::from(finished_only),
734 open_agent_seq
735 ],
736 |row| row.get(0),
737 )?;
738 let next_after_seq = if more {
739 last_seq
740 } else if finished_only {
741 open_agent_seq.map_or_else(
742 || latest_seq.max(after_seq),
743 |open_seq| open_seq.saturating_sub(1).max(after_seq),
744 )
745 } else {
746 latest_seq.max(after_seq)
747 };
748 Ok(Some(TranscriptPage {
749 next_after_seq,
750 items,
751 latest_seq,
752 execution: fields.execution,
753 }))
754}
755
756pub(super) const RETENTION_BATCH_ITEMS: usize = 4_096;
763
764pub(super) const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
767
768pub fn compact_materialized_transcript_through(
781 session_id: &str,
782 event_frontier: u64,
783) -> Result<TranscriptRetention> {
784 let session_id = session_id.to_owned();
785 submit_database_write("compact_materialized_transcript", move |_| {
786 compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
787 })
788}
789
790pub(super) fn compact_materialized_transcript_in(
791 path: &Path,
792 session_id: &str,
793 event_frontier: u64,
794) -> Result<TranscriptRetention> {
795 let mut connection = open(path)?;
796 let candidates = {
797 let mut statement = connection.prepare(
798 "SELECT stable_id, body_json
799 FROM materialized_transcript_items
800 WHERE session_id = ?1
801 AND position <= ?2
802 AND length(body_json) > ?3
803 AND json_extract(
804 CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
805 '$.kind'
806 ) = 'tool'
807 ORDER BY position, stable_id
808 LIMIT ?4",
809 )?;
810 statement
811 .query_map(
812 params![
813 session_id,
814 event_frontier,
815 RETENTION_BODY_FLOOR_BYTES as i64,
816 RETENTION_BATCH_ITEMS as i64 + 1
817 ],
818 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
819 )?
820 .collect::<rusqlite::Result<Vec<_>>>()?
821 };
822 let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
823 let mut retention = TranscriptRetention {
824 remaining,
825 ..TranscriptRetention::default()
826 };
827 let transaction =
828 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
829 for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
830 let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
831 Ok(body) => body,
832 Err(error) => {
834 tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
835 continue;
836 }
837 };
838 if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
839 continue;
840 }
841 let compacted = serde_json::to_string(&body)
842 .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
843 if compacted.len() >= body_json.len() {
844 continue;
845 }
846 transaction.execute(
847 "UPDATE materialized_transcript_items SET body_json = ?3
848 WHERE session_id = ?1 AND stable_id = ?2",
849 params![session_id, stable_id, compacted],
850 )?;
851 retention.items += 1;
852 retention.bytes += body_json.len() - compacted.len();
853 }
854 transaction.commit()?;
855 Ok(retention)
856}
857
858pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
866
867pub fn load_materialized_actor_projection(
870 session_id: &str,
871) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
872 load_materialized_actor_projection_from(&database_path(), session_id)
873}
874
875pub(super) fn load_materialized_actor_projection_from(
876 path: &Path,
877 session_id: &str,
878) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
879 let mut reader = open_reader(path)?;
880 let connection = reader.transaction()?;
881 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
882 return Ok(None);
883 };
884 let latest_turn = last_materialized_turn_start(&connection, session_id)?;
885 let desired: Option<u64> = connection
886 .query_row(
887 "SELECT position FROM materialized_transcript_items WHERE session_id=?1
888 ORDER BY position DESC, stable_id DESC LIMIT 1 OFFSET ?2",
889 params![session_id, PROJECTION_TAIL_ITEMS - 1],
890 |row| row.get(0),
891 )
892 .optional()?;
893 let mutable: Option<u64> = connection.query_row(
896 "SELECT MIN(position) FROM materialized_transcript_items WHERE session_id=?1
897 AND (json_extract(body_json, '$.streaming')=1
898 OR (json_extract(body_json, '$.kind')='tool'
899 AND json_extract(body_json, '$.call.status') IN ('pending','in_progress')))",
900 [session_id],
901 |row| row.get(0),
902 )?;
903 let boundary = desired
904 .unwrap_or(0)
905 .min(latest_turn.unwrap_or(0))
906 .min(mutable.unwrap_or(u64::MAX));
907 let start: u64 = connection.query_row(
908 "SELECT COALESCE(MAX(position),0) FROM materialized_transcript_items
909 WHERE session_id=?1 AND position<=?2
910 AND (json_extract(body_json, '$.kind')='user' OR stable_id LIKE 'harness-turn:%')",
911 params![session_id, boundary],
912 |row| row.get(0),
913 )?;
914 let transcript = read_transcript_range(&connection, session_id, start, None, None)?;
915 let omitted_items = connection.query_row(
916 "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id=?1 AND position<?2",
917 params![session_id, start],
918 |row| row.get(0),
919 )?;
920 let window = ProjectionWindow {
921 omitted_items,
922 provisional_title: first_materialized_user_message(&connection, session_id)?
923 .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
924 latest_turn_start_position: latest_turn,
925 };
926 let materialized = MaterializedSession {
927 session_id: session_id.to_owned(),
928 applied_event_ordinal: fields.applied_event_ordinal,
929 applied_event_digest: fields.applied_event_digest,
930 last_activity_at_ms: fields.last_activity_at_ms,
931 execution: fields.execution,
932 session_title: fields.session_title,
933 configuration: fields.configuration,
934 transcript,
935 queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
936 pending_elicitations: fields.pending_elicitations,
937 active_turn: fields.active_turn,
938 last_turn_outcome: fields.last_turn_outcome,
939 };
940 materialized.validate()?;
941 Ok(Some((materialized, window)))
942}
943
944pub fn load_transcript_history(
945 session_id: &str,
946 before: Option<&mj_core::storage::TranscriptCursor>,
947 limit: usize,
948) -> Result<Option<mj_core::storage::TranscriptHistoryPage>> {
949 load_transcript_history_from(&database_path(), session_id, before, limit)
950}
951
952pub(super) fn load_transcript_history_from(
953 path: &Path,
954 session_id: &str,
955 before: Option<&mj_core::storage::TranscriptCursor>,
956 limit: usize,
957) -> Result<Option<mj_core::storage::TranscriptHistoryPage>> {
958 let mut reader = open_reader(path)?;
959 let connection = reader.transaction()?;
960 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
961 return Ok(None);
962 };
963 let limit = limit.clamp(1, 256);
964 let mut items = read_transcript_range(&connection, session_id, 0, before, Some(limit + 1))?;
965 let has_more = items.len() > limit;
966 if has_more {
967 items.remove(0);
968 }
969 let before = has_more.then(|| mj_core::storage::TranscriptCursor::of(&items[0]));
970 Ok(Some(mj_core::storage::TranscriptHistoryPage {
971 items,
972 before,
973 frontier: fields.applied_event_ordinal,
974 }))
975}
976
977fn read_transcript_range(
978 connection: &Connection,
979 session_id: &str,
980 start: u64,
981 before: Option<&mj_core::storage::TranscriptCursor>,
982 limit: Option<usize>,
983) -> Result<Vec<Arc<TranscriptItem>>> {
984 let mut statement = connection.prepare(
985 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
986 last_changed_at_ms, body_json FROM materialized_transcript_items
987 WHERE session_id=?1 AND position>=?2
988 AND (position,stable_id)<(?3,?4)
989 ORDER BY position DESC, stable_id DESC LIMIT ?5",
990 )?;
991 let rows = statement
992 .query_map(
993 params![
994 session_id,
995 start,
996 before.map_or(i64::MAX as u64, |c| c.position),
997 before.map_or("", |c| c.stable_id.as_str()),
998 limit.map_or(-1, |limit| limit as i64)
999 ],
1000 |row| {
1001 Ok((
1002 row.get::<_, String>(0)?,
1003 row.get::<_, u64>(1)?,
1004 row.get::<_, Option<u64>>(2)?,
1005 row.get::<_, i64>(3)?,
1006 row.get::<_, i64>(4)?,
1007 row.get::<_, String>(5)?,
1008 ))
1009 },
1010 )?
1011 .collect::<rusqlite::Result<Vec<_>>>()?;
1012 let mut items = rows
1013 .into_iter()
1014 .map(
1015 |(
1016 stable_id,
1017 position,
1018 latest_content_event_ordinal,
1019 created_at_ms,
1020 last_changed_at_ms,
1021 body,
1022 )| {
1023 Ok(Arc::new(TranscriptItem {
1024 stable_id,
1025 position,
1026 latest_content_event_ordinal,
1027 created_at_ms,
1028 last_changed_at_ms,
1029 body: decode_transcript_body(&body, session_id)?,
1030 }))
1031 },
1032 )
1033 .collect::<Result<Vec<_>>>()?;
1034 items.reverse();
1035 Ok(items)
1036}
1037
1038pub fn load_projection_references(
1041 projection: &MaterializedSession,
1042 events: &[mj_core::relay::RelayEvent],
1043) -> Result<Vec<Arc<TranscriptItem>>> {
1044 load_projection_references_from(&database_path(), projection, events)
1045}
1046
1047pub(super) fn load_projection_references_from(
1048 path: &Path,
1049 projection: &MaterializedSession,
1050 events: &[mj_core::relay::RelayEvent],
1051) -> Result<Vec<Arc<TranscriptItem>>> {
1052 let (mut ids, terminals) = mj_transcript::projection::historical_references(events)?;
1053 let retained = projection
1054 .transcript
1055 .iter()
1056 .map(|item| item.stable_id.as_str())
1057 .collect::<std::collections::HashSet<_>>();
1058 ids.retain(|id| !retained.contains(id.as_str()));
1059 if ids.is_empty() && terminals.is_empty() {
1060 return Ok(Vec::new());
1061 }
1062 let mut reader = open_reader(path)?;
1063 let connection = reader.transaction()?;
1064 let mut parameters = vec![projection.session_id.clone(), serde_json::to_string(&ids)?];
1067 let mut statement = connection.prepare(if terminals.is_empty() {
1068 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
1069 last_changed_at_ms, body_json FROM materialized_transcript_items
1070 WHERE session_id=?1 AND stable_id IN (SELECT value FROM json_each(?2))
1071 ORDER BY position,stable_id"
1072 } else {
1073 parameters.push(serde_json::to_string(&retained)?);
1074 parameters.push(serde_json::to_string(&terminals)?);
1075 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
1076 last_changed_at_ms, body_json FROM materialized_transcript_items
1077 WHERE session_id=?1 AND stable_id NOT IN (SELECT value FROM json_each(?3))
1078 AND (stable_id IN (SELECT value FROM json_each(?2))
1079 OR EXISTS(SELECT 1 FROM json_each(body_json, '$.terminal_refs')
1080 WHERE value IN (SELECT value FROM json_each(?4))))
1081 ORDER BY position,stable_id"
1082 })?;
1083 let rows = statement
1084 .query_map(rusqlite::params_from_iter(parameters), |row| {
1085 Ok((
1086 row.get::<_, String>(0)?,
1087 row.get::<_, u64>(1)?,
1088 row.get::<_, Option<u64>>(2)?,
1089 row.get::<_, i64>(3)?,
1090 row.get::<_, i64>(4)?,
1091 row.get::<_, String>(5)?,
1092 ))
1093 })?
1094 .collect::<rusqlite::Result<Vec<_>>>()?;
1095 rows.into_iter()
1096 .map(
1097 |(
1098 stable_id,
1099 position,
1100 latest_content_event_ordinal,
1101 created_at_ms,
1102 last_changed_at_ms,
1103 body,
1104 )| {
1105 Ok(Arc::new(TranscriptItem {
1106 stable_id,
1107 position,
1108 latest_content_event_ordinal,
1109 created_at_ms,
1110 last_changed_at_ms,
1111 body: decode_transcript_body(&body, &projection.session_id)?,
1112 }))
1113 },
1114 )
1115 .collect()
1116}
1117
1118pub fn load_materialized_projection_tail(
1128 session_id: &str,
1129 transcript_limit: usize,
1130) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
1131 load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
1132}
1133
1134pub(super) fn load_materialized_projection_tail_from(
1135 path: &Path,
1136 session_id: &str,
1137 transcript_limit: usize,
1138) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
1139 let mut reader = open_reader(path)?;
1140 let connection = reader.transaction()?;
1143 let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
1144 return Ok(None);
1145 };
1146 let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
1147 let total_items = connection.query_row(
1148 "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
1149 [session_id],
1150 |row| row.get::<_, usize>(0),
1151 )?;
1152 let window = ProjectionWindow {
1153 omitted_items: total_items.saturating_sub(transcript.len()),
1154 provisional_title: first_materialized_user_message(&connection, session_id)?
1155 .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
1156 latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
1157 };
1158 let materialized = MaterializedSession {
1159 session_id: session_id.to_owned(),
1160 applied_event_ordinal: fields.applied_event_ordinal,
1161 applied_event_digest: fields.applied_event_digest,
1162 last_activity_at_ms: fields.last_activity_at_ms,
1163 execution: fields.execution,
1164 session_title: fields.session_title,
1165 configuration: fields.configuration,
1166 transcript,
1167 queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
1168 pending_elicitations: fields.pending_elicitations,
1169 active_turn: fields.active_turn,
1170 last_turn_outcome: fields.last_turn_outcome,
1171 };
1172 materialized.validate()?;
1173 Ok(Some((materialized, window)))
1174}
1175
1176pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
1180 materialized_event_frontier_from(&database_path(), session_id)
1181}
1182
1183pub(super) fn materialized_event_frontier_from(
1184 path: &Path,
1185 session_id: &str,
1186) -> Result<Option<(u64, String)>> {
1187 Ok(open_reader(path)?
1188 .query_row(
1189 "SELECT applied_event_ordinal, applied_event_digest
1190 FROM materialized_sessions WHERE session_id = ?1",
1191 [session_id],
1192 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
1193 )
1194 .optional()?)
1195}
1196
1197pub fn replace_materialized_queued_prompts(
1201 session_id: &str,
1202 queued_prompts: &[MaterializedQueuedPrompt],
1203) -> Result<()> {
1204 let session_id = session_id.to_owned();
1205 let queued_prompts = queued_prompts.to_vec();
1206 submit_database_write("replace_materialized_queued_prompts", move |_| {
1207 replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
1208 })
1209}
1210
1211pub(super) fn replace_materialized_queued_prompts_in(
1212 path: &Path,
1213 session_id: &str,
1214 queued_prompts: &[MaterializedQueuedPrompt],
1215) -> Result<()> {
1216 let mut connection = open(path)?;
1217 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1218 if !session_exists(&tx, session_id)? {
1219 bail!("unknown session {session_id}");
1220 }
1221 replace_materialized_queue(&tx, session_id, queued_prompts)?;
1222 tx.commit()?;
1223 Ok(())
1224}
1225
1226pub fn load_transcribed_session_activity() -> Result<BTreeMap<String, Option<i64>>> {
1233 load_transcribed_session_activity_from(&database_path())
1234}
1235
1236fn load_transcribed_session_activity_from(path: &Path) -> Result<BTreeMap<String, Option<i64>>> {
1237 let connection = open_reader(path)?;
1238 let mut statement = connection.prepare(
1239 "SELECT session_id, last_activity_at_ms
1240 FROM materialized_sessions s
1241 WHERE EXISTS (
1242 SELECT 1 FROM materialized_transcript_items i
1243 WHERE i.session_id = s.session_id
1244 )",
1245 )?;
1246 let rows = statement.query_map([], |row| {
1247 Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
1248 })?;
1249 let mut activity = BTreeMap::new();
1250 for row in rows {
1251 let (session_id, last_activity_at_ms) = row?;
1252 activity.insert(session_id, last_activity_at_ms);
1253 }
1254 Ok(activity)
1255}
1256
1257pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
1261{
1262 load_materialized_queued_prompts_from(&database_path())
1263}
1264
1265pub(super) fn load_materialized_queued_prompts_from(
1266 path: &Path,
1267) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
1268 let connection = open_reader(path)?;
1269 let mut statement = connection.prepare(
1270 "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
1271 FROM materialized_queued_prompts
1272 ORDER BY session_id, ordinal",
1273 )?;
1274 let rows = statement.query_map([], |row| {
1275 Ok((
1276 row.get::<_, String>(0)?,
1277 row.get::<_, String>(1)?,
1278 row.get::<_, String>(2)?,
1279 row.get::<_, String>(3)?,
1280 row.get::<_, i64>(4)?,
1281 row.get::<_, Option<u64>>(5)?,
1282 ))
1283 })?;
1284 let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
1285 for row in rows {
1286 let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
1287 row?;
1288 let content = serde_json::from_str(&content_json).with_context(|| {
1289 format!("parse materialized queued prompt for session {session_id}")
1290 })?;
1291 let kind = serde_json::from_str(&kind_json).with_context(|| {
1292 format!("parse materialized queue entry kind for session {session_id}")
1293 })?;
1294 queues
1295 .entry(session_id)
1296 .or_default()
1297 .push(MaterializedQueuedPrompt {
1298 command_id,
1299 kind,
1300 content,
1301 queued_at_ms,
1302 accepted_ordinal,
1303 });
1304 }
1305 Ok(queues)
1306}
1307
1308pub(super) fn load_materialized_session_from(
1309 path: &Path,
1310 session_id: &str,
1311) -> Result<Option<MaterializedSession>> {
1312 let mut reader = open_reader(path)?;
1313 let connection = reader.transaction()?;
1314 load_materialized_session_with(&connection, session_id)
1315}
1316
1317pub(super) fn load_materialized_session_with(
1318 connection: &rusqlite::Transaction<'_>,
1319 session_id: &str,
1320) -> Result<Option<MaterializedSession>> {
1321 let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
1322 return Ok(None);
1323 };
1324 let materialized = MaterializedSession {
1325 session_id: session_id.to_owned(),
1326 applied_event_ordinal: fields.applied_event_ordinal,
1327 applied_event_digest: fields.applied_event_digest,
1328 last_activity_at_ms: fields.last_activity_at_ms,
1329 execution: fields.execution,
1330 session_title: fields.session_title,
1331 configuration: fields.configuration,
1332 transcript: read_materialized_transcript(connection, session_id, None)?,
1333 queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
1334 pending_elicitations: fields.pending_elicitations,
1335 active_turn: fields.active_turn,
1336 last_turn_outcome: fields.last_turn_outcome,
1337 };
1338 materialized.validate()?;
1339 Ok(Some(materialized))
1340}
1341
1342pub(super) struct MaterializedSessionFields {
1344 pub(super) applied_event_ordinal: u64,
1345 pub(super) applied_event_digest: String,
1346 pub(super) last_activity_at_ms: Option<i64>,
1347 pub(super) execution: MaterializedExecutionState,
1348 pub(super) session_title: Option<String>,
1349 pub(super) configuration: mj_core::state::SessionConfiguration,
1350 pub(super) pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
1351 pub(super) active_turn: Option<MaterializedTurn>,
1352 pub(super) last_turn_outcome: Option<MaterializedTurnOutcome>,
1353}
1354
1355pub(super) fn read_materialized_session_fields(
1356 connection: &Connection,
1357 session_id: &str,
1358) -> Result<Option<MaterializedSessionFields>> {
1359 let row = connection
1360 .query_row(
1361 "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
1362 execution_state, running_started_at_ms, session_title, configuration_json,
1363 pending_elicitations_json, active_turn_json, last_turn_outcome_json
1364 FROM materialized_sessions WHERE session_id = ?1",
1365 [session_id],
1366 |row| {
1367 Ok((
1368 row.get::<_, u64>(0)?,
1369 row.get::<_, String>(1)?,
1370 row.get::<_, Option<i64>>(2)?,
1371 row.get::<_, String>(3)?,
1372 row.get::<_, Option<i64>>(4)?,
1373 row.get::<_, Option<String>>(5)?,
1374 row.get::<_, String>(6)?,
1375 row.get::<_, String>(7)?,
1376 row.get::<_, Option<String>>(8)?,
1377 row.get::<_, Option<String>>(9)?,
1378 ))
1379 },
1380 )
1381 .optional()?;
1382 let Some((
1383 applied_event_ordinal,
1384 applied_event_digest,
1385 last_activity_at_ms,
1386 execution,
1387 running_started_at_ms,
1388 session_title,
1389 configuration_json,
1390 pending_elicitations_json,
1391 active_turn_json,
1392 last_turn_outcome_json,
1393 )) = row
1394 else {
1395 return Ok(None);
1396 };
1397 #[cfg(test)]
1398 super::tests::after_materialized_frontier_read();
1399 Ok(Some(MaterializedSessionFields {
1400 applied_event_ordinal,
1401 applied_event_digest,
1402 last_activity_at_ms,
1403 execution: parse_materialized_execution(&execution, running_started_at_ms)?,
1404 session_title,
1405 configuration: serde_json::from_str(&configuration_json).with_context(|| {
1406 format!("parse materialized configuration for session {session_id}")
1407 })?,
1408 pending_elicitations: serde_json::from_str(&pending_elicitations_json)
1409 .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
1410 active_turn: active_turn_json
1411 .as_deref()
1412 .map(serde_json::from_str)
1413 .transpose()
1414 .with_context(|| format!("parse active turn for session {session_id}"))?,
1415 last_turn_outcome: last_turn_outcome_json
1416 .as_deref()
1417 .map(deserialize_turn_outcome)
1418 .transpose()
1419 .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
1420 }))
1421}
1422
1423pub(super) fn read_materialized_transcript(
1427 connection: &Connection,
1428 session_id: &str,
1429 limit: Option<usize>,
1430) -> Result<Vec<Arc<TranscriptItem>>> {
1431 let mut statement = connection.prepare(match limit {
1432 Some(_) => {
1433 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
1434 last_changed_at_ms, body_json
1435 FROM materialized_transcript_items
1436 WHERE session_id = ?1
1437 ORDER BY position DESC, stable_id DESC
1438 LIMIT ?2"
1439 }
1440 None => {
1441 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
1442 last_changed_at_ms, body_json
1443 FROM materialized_transcript_items
1444 WHERE session_id = ?1
1445 ORDER BY position, stable_id"
1446 }
1447 })?;
1448 let read = |row: &rusqlite::Row<'_>| {
1449 Ok((
1450 row.get::<_, String>(0)?,
1451 row.get::<_, u64>(1)?,
1452 row.get::<_, Option<u64>>(2)?,
1453 row.get::<_, i64>(3)?,
1454 row.get::<_, i64>(4)?,
1455 row.get::<_, String>(5)?,
1456 ))
1457 };
1458 let rows = match limit {
1459 Some(limit) => statement
1460 .query_map(params![session_id, limit as i64], read)?
1461 .collect::<rusqlite::Result<Vec<_>>>()?,
1462 None => statement
1463 .query_map([session_id], read)?
1464 .collect::<rusqlite::Result<Vec<_>>>()?,
1465 };
1466 let mut transcript = rows
1467 .into_iter()
1468 .map(
1469 |(
1470 stable_id,
1471 position,
1472 latest_content_event_ordinal,
1473 created_at_ms,
1474 last_changed_at_ms,
1475 body_json,
1476 )| {
1477 Ok(Arc::new(TranscriptItem {
1478 stable_id,
1479 position,
1480 latest_content_event_ordinal,
1481 created_at_ms,
1482 last_changed_at_ms,
1483 body: decode_transcript_body(&body_json, session_id)?,
1484 }))
1485 },
1486 )
1487 .collect::<Result<Vec<_>>>()?;
1488 if limit.is_some() {
1489 transcript.reverse();
1492 }
1493 Ok(transcript)
1494}
1495
1496pub(super) fn read_materialized_queued_prompts(
1497 connection: &Connection,
1498 session_id: &str,
1499) -> Result<Vec<MaterializedQueuedPrompt>> {
1500 let mut statement = connection.prepare(
1501 "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
1502 FROM materialized_queued_prompts
1503 WHERE session_id = ?1
1504 ORDER BY ordinal",
1505 )?;
1506 let rows = statement
1507 .query_map([session_id], |row| {
1508 Ok((
1509 row.get::<_, String>(0)?,
1510 row.get::<_, String>(1)?,
1511 row.get::<_, String>(2)?,
1512 row.get::<_, i64>(3)?,
1513 row.get::<_, Option<u64>>(4)?,
1514 ))
1515 })?
1516 .collect::<rusqlite::Result<Vec<_>>>()?;
1517 rows.into_iter()
1518 .map(
1519 |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
1520 Ok(MaterializedQueuedPrompt {
1521 command_id,
1522 kind: serde_json::from_str(&kind_json).with_context(|| {
1523 format!("parse materialized queue entry kind for session {session_id}")
1524 })?,
1525 content: serde_json::from_str(&content_json).with_context(|| {
1526 format!("parse materialized queued prompt for session {session_id}")
1527 })?,
1528 queued_at_ms,
1529 accepted_ordinal,
1530 })
1531 },
1532 )
1533 .collect()
1534}
1535
1536pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
1540 let materialized = materialized.clone();
1541 submit_database_write("save_materialized_session", move |_| {
1542 save_materialized_session_to(&database_path(), &materialized)
1543 })
1544}
1545
1546pub(super) fn save_materialized_session_to(
1547 path: &Path,
1548 materialized: &MaterializedSession,
1549) -> Result<()> {
1550 materialized.validate()?;
1551 let mut connection = open(path)?;
1552 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1553 if !session_exists(&tx, &materialized.session_id)? {
1554 bail!("unknown session {}", materialized.session_id);
1555 }
1556 write_materialized_session(&tx, materialized)?;
1557 tx.commit()?;
1558 Ok(())
1559}
1560
1561pub struct ProjectionPage<'a> {
1566 pub(super) session_id: &'a str,
1567 pub(super) transaction: Transaction<'a>,
1568 pub(super) applied_ordinal: u64,
1569 pub(super) applied_digest: String,
1570 pub(super) dirty: bool,
1571 pub(super) pending: MaterializedSessionMutation,
1572 pub(super) pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
1573 pub(super) pending_turns: Vec<MaterializedTurnOutcome>,
1574 usage_configuration: mj_core::state::SessionConfiguration,
1577 pub(super) pending_events: Vec<(i64, ApiEventData)>,
1578}
1579
1580pub(super) struct PendingTranscriptMutation {
1581 pub(super) final_mutation: TranscriptMutation,
1582 pub(super) remove_before_upsert: bool,
1583}
1584
1585impl ProjectionPage<'_> {
1586 pub fn apply(
1590 &mut self,
1591 event_ordinal: u64,
1592 previous_event_digest: &str,
1593 event_digest: &str,
1594 mutation: &MaterializedSessionMutation,
1595 ) -> Result<ProjectionApplyOutcome> {
1596 if event_ordinal == 0 {
1597 bail!("relay event ordinal must be positive");
1598 }
1599 let chained = !previous_event_digest.is_empty();
1605 if chained {
1606 validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
1607 }
1608 validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
1609 let session_id = self.session_id;
1610 let applied = self.applied_ordinal;
1611 if event_ordinal < applied {
1612 return Ok(ProjectionApplyOutcome::AlreadyApplied);
1613 }
1614 if event_ordinal == applied {
1615 if event_digest != self.applied_digest {
1616 bail!(
1617 "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
1618 self.applied_digest
1619 );
1620 }
1621 return Ok(ProjectionApplyOutcome::AlreadyApplied);
1622 }
1623 let expected = applied
1624 .checked_add(1)
1625 .context("materialized event ordinal overflow")?;
1626 if event_ordinal != expected {
1627 bail!(
1628 "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
1629 );
1630 }
1631 if chained && previous_event_digest != self.applied_digest {
1632 bail!(
1633 "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
1634 self.applied_digest
1635 );
1636 }
1637
1638 if let Some(event) = &mutation.native_agent {
1639 native_agents::apply_native_agent_event(&self.transaction, session_id, event)?;
1640 }
1641 if let Some(activity_at_ms) = mutation.last_activity_at_ms {
1642 self.pending.last_activity_at_ms = Some(
1643 self.pending
1644 .last_activity_at_ms
1645 .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
1646 );
1647 }
1648 if let Some(execution) = mutation.execution {
1649 self.pending.execution = Some(execution);
1650 }
1651 if let Some(title) = &mutation.session_title {
1652 if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
1653 bail!("materialized session title cannot be empty");
1654 }
1655 self.pending.session_title = Some(title.clone());
1656 }
1657 if let Some(configuration) = &mutation.configuration {
1658 self.usage_configuration = configuration.clone();
1659 self.pending.configuration = Some(configuration.clone());
1660 }
1661 for item_mutation in &mutation.transcript {
1662 match item_mutation {
1663 TranscriptMutation::Upsert(item) => {
1664 item.validate(event_ordinal)?;
1665 let stable_id = item.stable_id.clone();
1666 let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
1667 PendingTranscriptMutation {
1668 final_mutation: TranscriptMutation::Upsert(item.clone()),
1669 remove_before_upsert: false,
1670 }
1671 });
1672 entry.remove_before_upsert |=
1673 matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
1674 entry.final_mutation = TranscriptMutation::Upsert(item.clone());
1675 }
1676 TranscriptMutation::Remove { stable_id } => {
1677 if stable_id.trim().is_empty() {
1678 bail!("cannot remove a transcript item with an empty stable id");
1679 }
1680 let removed = TranscriptMutation::Remove {
1681 stable_id: stable_id.clone(),
1682 };
1683 self.pending_transcript
1684 .entry(stable_id.clone())
1685 .and_modify(|entry| entry.final_mutation = removed.clone())
1686 .or_insert(PendingTranscriptMutation {
1687 final_mutation: removed,
1688 remove_before_upsert: false,
1689 });
1690 }
1691 }
1692 }
1693 if let Some(queued_prompts) = &mutation.queued_prompts {
1694 self.pending.queued_prompts = Some(queued_prompts.clone());
1695 }
1696 if let Some(pending_elicitations) = &mutation.pending_elicitations {
1697 self.pending.pending_elicitations = Some(pending_elicitations.clone());
1698 }
1699 self.pending
1700 .config_results
1701 .extend(mutation.config_results.clone());
1702 if let Some(active_turn) = &mutation.active_turn {
1703 if let Some(turn) = active_turn {
1704 super::usage::record_turn_selection(
1705 &self.transaction,
1706 session_id,
1707 &turn.command_id,
1708 &self.usage_configuration,
1709 )?;
1710 }
1711 self.pending.active_turn = Some(active_turn.clone());
1712 }
1713 if mutation.clear_turn_outcome {
1714 self.pending.clear_turn_outcome = true;
1715 self.pending.last_turn_outcome = None;
1716 }
1717 if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
1718 self.pending_turns.push(last_turn_outcome.clone());
1719 self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
1720 }
1721 if let Some(cost) = &mutation.provider_cost {
1722 self.pending.provider_cost = Some(cost.clone());
1723 }
1724 self.pending_events.extend(
1725 mutation
1726 .api_events
1727 .iter()
1728 .cloned()
1729 .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
1730 );
1731 self.applied_ordinal = event_ordinal;
1732 event_digest.clone_into(&mut self.applied_digest);
1733 self.dirty = true;
1734 Ok(ProjectionApplyOutcome::Applied)
1735 }
1736
1737 pub(super) fn flush(&mut self) -> Result<()> {
1741 if !self.dirty {
1742 return Ok(());
1743 }
1744 let tx = &self.transaction;
1745 let session_id = self.session_id;
1746 if let Some(execution) = self.pending.execution {
1747 let (state, started_at_ms) = materialized_execution_columns(execution);
1748 tx.execute(
1749 "UPDATE materialized_sessions
1750 SET execution_state = ?2, running_started_at_ms = ?3
1751 WHERE session_id = ?1",
1752 params![session_id, state, started_at_ms],
1753 )?;
1754 }
1755 if let Some(title) = &self.pending.session_title {
1756 tx.execute(
1757 "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
1758 params![session_id, title],
1759 )?;
1760 }
1761 if let Some(configuration) = &self.pending.configuration {
1762 tx.execute(
1763 "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
1764 params![session_id, serde_json::to_string(configuration)?],
1765 )?;
1766 }
1767 for pending in self.pending_transcript.values() {
1768 match &pending.final_mutation {
1769 TranscriptMutation::Upsert(item) => {
1770 if pending.remove_before_upsert {
1774 tx.execute(
1775 "DELETE FROM materialized_transcript_items
1776 WHERE session_id = ?1 AND stable_id = ?2",
1777 params![session_id, item.stable_id],
1778 )?;
1779 }
1780 upsert_transcript_item(tx, session_id, item)?;
1781 }
1782 TranscriptMutation::Remove { stable_id } => {
1783 tx.execute(
1784 "DELETE FROM materialized_transcript_items
1785 WHERE session_id = ?1 AND stable_id = ?2",
1786 params![session_id, stable_id],
1787 )?;
1788 }
1789 }
1790 }
1791 if let Some(queued_prompts) = &self.pending.queued_prompts {
1792 replace_materialized_queue(tx, session_id, queued_prompts)?;
1793 }
1794 if let Some(pending_elicitations) = &self.pending.pending_elicitations {
1795 tx.execute(
1796 "UPDATE materialized_sessions
1797 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
1798 params![session_id, serde_json::to_string(pending_elicitations)?],
1799 )?;
1800 }
1801 for (recorded_at_ms, event) in &self.pending_events {
1802 events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
1803 }
1804 for turn in &self.pending_turns {
1805 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, serialize_turn_outcome(turn)?])?;
1806 }
1807 if let Some(cost) = &self.pending.provider_cost {
1808 tx.execute(
1809 "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
1810 params![session_id, serde_json::to_string(cost)?],
1811 )?;
1812 }
1813 for (command_id, error) in &self.pending.config_results {
1814 tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
1815 }
1816 if let Some(active_turn) = &self.pending.active_turn {
1817 tx.execute(
1818 "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
1819 params![
1820 session_id,
1821 active_turn
1822 .as_ref()
1823 .map(serde_json::to_string)
1824 .transpose()?
1825 ],
1826 )?;
1827 }
1828 if self.pending.clear_turn_outcome {
1829 self.transaction.execute("UPDATE materialized_sessions SET last_turn_outcome_json = NULL WHERE session_id = ?1", [session_id])?;
1830 }
1831 if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
1832 tx.execute(
1833 "UPDATE materialized_sessions
1834 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
1835 params![session_id, serialize_turn_outcome(last_turn_outcome)?],
1836 )?;
1837 }
1838 tx.execute(
1839 "UPDATE materialized_sessions
1840 SET last_activity_at_ms = CASE
1841 WHEN ?2 IS NULL THEN last_activity_at_ms
1842 WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
1843 ELSE last_activity_at_ms
1844 END,
1845 applied_event_ordinal = ?3,
1846 applied_event_digest = ?4
1847 WHERE session_id = ?1",
1848 params![
1849 session_id,
1850 self.pending.last_activity_at_ms,
1851 self.applied_ordinal,
1852 self.applied_digest,
1853 ],
1854 )?;
1855 Ok(())
1856 }
1857}
1858
1859pub fn apply_projection_page<T>(
1864 session_id: &str,
1865 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
1866) -> Result<T>
1867where
1868 T: Send + 'static,
1869{
1870 let session_id = session_id.to_owned();
1871 submit_database_write("apply_projection_page", move |connection| {
1872 apply_projection_page_with(connection, &session_id, fill)
1873 })
1874}
1875
1876#[cfg(test)]
1877pub(super) fn apply_projection_page_to<T>(
1878 path: &Path,
1879 session_id: &str,
1880 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1881) -> Result<T> {
1882 let mut connection = open(path)?;
1883 apply_projection_page_with(&mut connection, session_id, fill)
1884}
1885
1886pub(super) fn apply_projection_page_with<T>(
1887 connection: &mut Connection,
1888 session_id: &str,
1889 fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1890) -> Result<T> {
1891 let transaction =
1892 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1893 let (applied_ordinal, applied_digest) = transaction
1894 .query_row(
1895 "SELECT applied_event_ordinal, applied_event_digest
1896 FROM materialized_sessions WHERE session_id = ?1",
1897 [session_id],
1898 |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
1899 )
1900 .optional()?
1901 .with_context(|| format!("unknown session {session_id}"))?;
1902 validate_relay_event_frontier(
1903 applied_ordinal,
1904 &applied_digest,
1905 "persisted relay event frontier",
1906 )?;
1907 let usage_configuration = read_materialized_session_fields(&transaction, session_id)?
1908 .context("projection session disappeared")?
1909 .configuration;
1910 let mut page = ProjectionPage {
1911 session_id,
1912 transaction,
1913 applied_ordinal,
1914 applied_digest,
1915 dirty: false,
1916 pending: MaterializedSessionMutation::default(),
1917 pending_transcript: BTreeMap::new(),
1918 pending_turns: Vec::new(),
1919 usage_configuration,
1920 pending_events: Vec::new(),
1921 };
1922 let filled = fill(&mut page)?;
1925 page.flush()?;
1926 page.transaction.commit()?;
1927 Ok(filled)
1928}
1929
1930pub fn apply_projection_event(
1932 session_id: &str,
1933 event_ordinal: u64,
1934 previous_event_digest: &str,
1935 event_digest: &str,
1936 mutation: &MaterializedSessionMutation,
1937) -> Result<ProjectionApplyOutcome> {
1938 let session_id = session_id.to_owned();
1939 let previous_event_digest = previous_event_digest.to_owned();
1940 let event_digest = event_digest.to_owned();
1941 let mutation = mutation.clone();
1942 submit_database_write("apply_projection_event", move |connection| {
1943 apply_projection_page_with(connection, &session_id, |page| {
1944 page.apply(
1945 event_ordinal,
1946 &previous_event_digest,
1947 &event_digest,
1948 &mutation,
1949 )
1950 })
1951 })
1952}
1953
1954#[cfg(test)]
1955pub(super) fn apply_projection_event_to(
1956 path: &Path,
1957 session_id: &str,
1958 event_ordinal: u64,
1959 previous_event_digest: &str,
1960 event_digest: &str,
1961 mutation: &MaterializedSessionMutation,
1962) -> Result<ProjectionApplyOutcome> {
1963 apply_projection_page_to(path, session_id, |page| {
1964 page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
1965 })
1966}
1967
1968pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
1971 let session_id = session_id.to_owned();
1972 submit_database_write("advance_viewed_through_event_ordinal", move |_| {
1973 advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
1974 })
1975}
1976
1977pub(super) fn advance_viewed_through_event_ordinal_to(
1978 path: &Path,
1979 session_id: &str,
1980 through: u64,
1981) -> Result<u64> {
1982 let mut connection = open(path)?;
1983 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1984 let applied = tx
1985 .query_row(
1986 "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
1987 [session_id],
1988 |row| row.get::<_, u64>(0),
1989 )
1990 .optional()?
1991 .with_context(|| format!("unknown session {session_id}"))?;
1992 if through > applied {
1993 bail!(
1994 "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
1995 );
1996 }
1997 tx.execute(
1998 "UPDATE sessions
1999 SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
2000 WHERE session_id = ?1",
2001 params![session_id, through],
2002 )?;
2003 let receipt = tx.query_row(
2004 "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
2005 [session_id],
2006 |row| row.get::<_, u64>(0),
2007 )?;
2008 tx.commit()?;
2009 Ok(receipt)
2010}
2011
2012fn decode_transcript_body(body_json: &str, session_id: &str) -> Result<TranscriptBody> {
2018 let mut body: TranscriptBody = serde_json::from_str(body_json)
2019 .with_context(|| format!("parse materialized transcript body for session {session_id}"))?;
2020 match &mut body {
2021 TranscriptBody::Agent { chunks, .. } | TranscriptBody::Thought { chunks, .. } => {
2022 mj_core::transcript::coalesce_content_chunks(chunks);
2023 }
2024 _ => {}
2025 }
2026 Ok(body)
2027}
2028
2029pub(crate) fn load_continuation_evidence(
2032 session_id: &str,
2033 ordinal: u64,
2034 digest: &str,
2035) -> Result<mj_core::continuation::ContinuationEvidence> {
2036 load_continuation_evidence_from(&database_path(), session_id, ordinal, digest)
2037}
2038
2039pub(super) fn load_continuation_evidence_from(
2040 path: &Path,
2041 session_id: &str,
2042 ordinal: u64,
2043 digest: &str,
2044) -> Result<mj_core::continuation::ContinuationEvidence> {
2045 let mut reader = open_reader(path)?;
2046 let connection = reader.transaction()?;
2047 let fields = read_materialized_session_fields(&connection, session_id)?
2048 .context("continuation projection is missing")?;
2049 anyhow::ensure!(
2050 fields.applied_event_ordinal == ordinal && fields.applied_event_digest == digest,
2051 "continuation projection changed"
2052 );
2053 let mut statement = connection.prepare(
2054 "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
2055 last_changed_at_ms, body_json
2056 FROM materialized_transcript_items WHERE session_id = ?1
2057 AND (json_extract(body_json, '$.kind') IN ('user', 'agent')
2058 OR stable_id LIKE 'context-cleared:%')
2059 ORDER BY position DESC, stable_id DESC",
2060 )?;
2061 let rows = statement.query_map([session_id], |row| {
2062 Ok((
2063 row.get::<_, String>(0)?,
2064 row.get::<_, u64>(1)?,
2065 row.get::<_, Option<u64>>(2)?,
2066 row.get::<_, i64>(3)?,
2067 row.get::<_, i64>(4)?,
2068 row.get::<_, String>(5)?,
2069 ))
2070 })?;
2071 crate::continuation::evidence_from_items(rows.map(|row| {
2072 let (
2073 stable_id,
2074 position,
2075 latest_content_event_ordinal,
2076 created_at_ms,
2077 last_changed_at_ms,
2078 body_json,
2079 ) = row?;
2080 anyhow::ensure!(
2082 body_json.len() <= 1024 * 1024,
2083 "continuation source message exceeds budget"
2084 );
2085 Ok(Arc::new(TranscriptItem {
2086 stable_id,
2087 position,
2088 latest_content_event_ordinal,
2089 created_at_ms,
2090 last_changed_at_ms,
2091 body: decode_transcript_body(&body_json, session_id)?,
2092 }))
2093 }))
2094}