Skip to main content

mj_controller/database/
materialized.rs

1use super::*;
2
3/// Load a session's whole projection, transcript and all.
4///
5/// Crate-private on purpose. The cost of this call is everything that has ever
6/// happened in the conversation, and the callers that made that a visible
7/// problem — the runtime poll and the resume reply — were both outside this
8/// crate. What they wanted was [`load_materialized_projection_tail`]; what
9/// they reached for was this, because it was public and its name did not say
10/// otherwise. The remaining caller owns a live projection and genuinely needs
11/// all of it.
12pub fn load_materialized_session(session_id: &str) -> Result<Option<MaterializedSession>> {
13    load_materialized_session_from(&database_path(), session_id)
14}
15
16/// Load only the projection fields needed by dashboard session summaries.
17/// Transcript bodies for tools, plans, thoughts, and old messages stay in
18/// SQLite, which keeps dashboard startup independent of transcript size.
19pub 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
25/// Load durable activity for every materialized session so startup does not read transcripts.
26pub 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
178/// The oldest visible user message, which is where a session's provisional
179/// title comes from. It sits at the head of the transcript, so a projection
180/// loaded as a tail cannot find it by scanning; this reads it directly.
181pub(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
245/// Where the newest turn began: a user message, or the marker for a turn the
246/// harness started on its own. This is the recovery boundary, so it reads a
247/// position only and never has to decode a transcript body.
248pub(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
286/// The newest nonempty agent message inside one turn, flattened to text.
287///
288/// The turn is a span, not a starting point. A harness can put an agent
289/// message in the transcript after the turn it answered has ended — a resume
290/// notice is one — and reading everything after the turn's start would return
291/// that notice as the turn's answer.
292pub(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
355/// Execution state, the running turn, and the last finished turn's outcome.
356pub type MaterializedTurnState = (
357    MaterializedExecutionState,
358    Option<MaterializedTurn>,
359    Option<MaterializedTurnOutcome>,
360);
361
362/// Where a session stands turn by turn: what is running now, and how the last
363/// finished prompt ended. Returns `None` when the session has no projection
364/// row. The API's wait loop reads this for sessions whose actor is gone.
365pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
366    load_materialized_turn_outcome_from(&database_path(), session_id)
367}
368
369/// Reconcile a replayed input after the worker has collected its command ledger.
370/// Queued, active and historical turns are read from one projection transaction.
371pub 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
422/// Turn observation does not need configuration, forms, or transcript bodies.
423pub(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
451/// The answer a session's last finished turn ended with.
452///
453/// This is what a finished child session reports back to the agent that
454/// delegated to it, so it has to be that turn's own last agent message. The
455/// session-wide last agent message is not the same thing: a harness records
456/// messages of its own outside any turn, and a resume notice arriving after
457/// the child finished would then stand in for the child's report.
458///
459/// `None` means the session has no projection row, or no finished turn whose
460/// span is recorded, and the caller decides what to show instead.
461pub 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
465/// Read answer text only when returning a child wait. Turn bounds are from
466/// the observation that decided completion, even if another turn starts now.
467pub(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
515/// Summarize the turn that ran from `turn_start_position` to
516/// `turn_completed_position`.
517///
518/// Both bounds come from the turn's own record: a transcript item's position
519/// and a completed turn's `completed_ordinal` are the same relay ordinal, so
520/// the completion ordinal is the last position the turn can own. Anything the
521/// session records afterwards belongs to no turn, or to the next one.
522pub 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
756/// How many transcript rows one retention pass rewrites.
757///
758/// The daemon is the single database writer, so a pass that rewrote every row
759/// of a long session would stall every other write behind it. A capped pass
760/// leaves the rest for the next checkpoint, which is the next time any of it
761/// becomes redundant anyway.
762pub(super) const RETENTION_BATCH_ITEMS: usize = 4_096;
763
764/// Rows below this are already small enough that rewriting them would cost
765/// more than it reclaims.
766pub(super) const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
767
768/// Drop tool output that a verified checkpoint already holds.
769///
770/// The projection only ever grew: the only deletes were a per-item remove, a
771/// whole-session wipe, and the `sessions` cascade. One measured session reached
772/// 28,066 items and 635 MiB, of which 561 MB was tool-call content.
773///
774/// A checkpoint archive carries the complete transcript up to its event
775/// frontier, and one checkpoint per session is retained, so every item at or
776/// below `event_frontier` is durably recorded elsewhere. What stays here is
777/// what the transcript still shows: which tool ran, on what, with what result,
778/// and each edit's diffstat. See
779/// [`mj_transcript::transcript::compact_tool_call_for_retention`].
780pub 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            // A row this cannot read is a row it must not rewrite.
833            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
858/// How many transcript items a polled projection carries.
859///
860/// Every viewer of a polled projection is bounded already: the conversation
861/// pane keeps `chat::TAIL_SEED_ITEMS` (256) entries, and the browser
862/// transcript keeps 1,000 rendered lines. This is set above both, since an
863/// entry renders to at least one line, so the window is the whole of what any
864/// of them would show.
865pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
866
867/// Load the same safe window the live projector maintains, without first
868/// deserializing all of the conversation. Read metadata and bodies together.
869pub 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    // Pending tools and streams can outlive their initiating turn. Keep their
894    // turn too; the same rule is applied by ProjectionWindow::trim.
895    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
1038/// Rehydrate explicitly referenced historical tools before deriving a delta.
1039/// A window is a cache, never evidence that a durable tool does not exist.
1040pub 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    // Ordinary tool updates use the stable-ID index. Only terminal attachment
1065    // events need the JSON reverse-reference scan.
1066    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
1118/// Load a projection carrying only the end of its transcript.
1119///
1120/// The steady-state poll reloads a session's projection every time anything
1121/// about it moves. Loading the whole transcript to do that is work
1122/// proportional to everything that has ever happened in the conversation —
1123/// 635 MiB and 28,066 items on one measured session — for a view that shows
1124/// the last few hundred entries. This reads the window instead, plus the two
1125/// facts that live outside it, each with one indexed query. See
1126/// [`ProjectionWindow`].
1127pub 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    // Frontier, mutable transcript bodies, and window metadata must describe
1141    // one WAL snapshot even if the daemon commits between these queries.
1142    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
1176/// Read only the projection's event frontier. Deciding whether a stored
1177/// projection already matches an archive costs one row this way, instead of
1178/// deserializing every transcript item to compare two integers.
1179pub 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
1197/// Replace a session's durable prompt queue without touching its transcript or
1198/// event frontier. Resume uses this when it keeps the stored projection but
1199/// still has to drop the queue the archive carried.
1200pub 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
1226/// The activity watermark of every session whose projection holds at least one
1227/// transcript item, by session id.
1228///
1229/// This is a change token, not a projection: session indexing needs to know
1230/// which live conversations have moved since it last looked, and loading each
1231/// one's transcript to find out would cost the whole corpus every sync.
1232pub 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
1257/// Load only the durable prompt queues without deserializing transcript rows.
1258/// Dashboard startup uses this path so work is proportional to queued prompts,
1259/// not to the complete retained conversation history.
1260pub 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
1342/// Everything a projection holds apart from its transcript and its queue.
1343pub(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
1423/// Read a session's transcript, oldest first. `limit` reads only that many
1424/// items from the end, walking the `materialized_transcript_position` index
1425/// backwards so the read costs the rows it returns.
1426pub(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        // The bounded query walks the index backwards to bound what it reads;
1490        // every caller wants the transcript in the order it was written.
1491        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
1536/// Replace a complete projection, primarily when seeding a restored
1537/// checkpoint. Operational `SessionRecord` metadata and read receipts are not
1538/// modified.
1539pub 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
1561/// One relay page being applied inside a single write transaction. The relay
1562/// retains everything past the last acknowledgement, so a page that fails
1563/// part-way rolls back to the previous durable frontier and is simply
1564/// redelivered. Only a committed page may be acknowledged.
1565pub 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    /// Configuration changes are coalesced for the session, but selection is
1575    /// captured at each turn start before later events can overwrite it.
1576    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    /// Apply the projection effects of the next relay event to the open page.
1587    /// The event must continue the chain the page has reached so far, which is
1588    /// the persisted frontier plus every event already applied to this page.
1589    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        // A v2 event carries no chain link (empty previous digest). Its
1600        // continuity to the projection frontier is proven by ordinal
1601        // contiguity plus the attach cursor the controller validated against
1602        // the worker, not by an in-record back-reference; divergence is caught
1603        // there, before any event is applied.
1604        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    /// Persist the coalesced final state of this page. Intermediate event
1738    /// frontiers are useful only for chain validation: a page commits or rolls
1739    /// back as a unit, so writing them individually adds no recovery value.
1740    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                    // A remove followed by an upsert deliberately starts a new
1771                    // item identity. Preserve that boundary even though other
1772                    // repeated updates are coalesced to one write.
1773                    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
1859/// Apply one relay page in a single transaction. `fill` feeds the page's
1860/// events through [`ProjectionPage::apply`]; the projection changes and the
1861/// event frontier commit together only when `fill` succeeds, so callers may
1862/// acknowledge the page's last ordinal to the relay after this returns.
1863pub 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    // Dropping the page on failure rolls the whole transaction back, leaving
1923    // the projection at the frontier the relay last saw acknowledged.
1924    let filled = fill(&mut page)?;
1925    page.flush()?;
1926    page.transaction.commit()?;
1927    Ok(filled)
1928}
1929
1930/// Apply exactly one relay event, as a page of one.
1931pub 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
1968/// Advance the persisted detach/read receipt monotonically. A receipt cannot
1969/// acknowledge an event the controller projection has not durably applied.
1970pub 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
2012/// Decode a stored transcript body, merging runs of streamed text chunks.
2013///
2014/// Rows written before streamed text was merged on the way in hold one chunk
2015/// per token, which costs far more memory decoded than the text it carries.
2016/// Collapsing them here shrinks long sessions without rewriting stored JSON.
2017fn 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
2029/// Read complete authorization and recent replies in one consistent snapshot.
2030/// Runs on the continuation service's blocking pool, never a render/event loop.
2031pub(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        // Refuse exceptionally large source messages rather than clip consent.
2081        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}