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
25pub(super) fn load_materialized_session_summary_from(
26    path: &Path,
27    session_id: &str,
28) -> Result<Option<MaterializedSessionSummary>> {
29    let connection = open_reader(path)?;
30    let row = connection
31        .query_row(
32            "SELECT applied_event_ordinal, last_activity_at_ms, execution_state,
33                    running_started_at_ms, session_title
34             FROM materialized_sessions WHERE session_id = ?1",
35            [session_id],
36            |row| {
37                Ok((
38                    row.get::<_, u64>(0)?,
39                    row.get::<_, Option<i64>>(1)?,
40                    row.get::<_, String>(2)?,
41                    row.get::<_, Option<i64>>(3)?,
42                    row.get::<_, Option<String>>(4)?,
43                ))
44            },
45        )
46        .optional()?;
47    let Some((
48        applied_event_ordinal,
49        last_activity_at_ms,
50        execution,
51        running_started_at_ms,
52        session_title,
53    )) = row
54    else {
55        return Ok(None);
56    };
57
58    let last_user_message = last_materialized_user_message(&connection, session_id)?;
59    let last_agent_message = last_materialized_agent_message(&connection, session_id)?;
60    let last_agent_message_follows_last_user =
61        last_agent_message
62            .as_ref()
63            .is_some_and(|(agent_position, _)| {
64                last_user_message
65                    .as_ref()
66                    .is_none_or(|(user_position, _)| agent_position > user_position)
67            });
68    let mut ordinal_statement = connection.prepare(
69        "SELECT latest_content_event_ordinal
70         FROM materialized_transcript_items
71         WHERE session_id = ?1
72           AND latest_content_event_ordinal IS NOT NULL
73           AND EXISTS (
74               SELECT 1 FROM json_each(
75                   CASE
76                       WHEN latest_content_event_ordinal IS NOT NULL
77                           AND json_valid(body_json)
78                       THEN body_json
79                       ELSE '{}'
80                   END,
81                   '$.chunks'
82               ) AS chunk
83               WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
84                 AND (
85                     json_extract(chunk.value, '$.content.type') <> 'text'
86                     OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
87                 )
88           )
89         ORDER BY position, stable_id",
90    )?;
91    let agent_message_latest_content_ordinals = ordinal_statement
92        .query_map([session_id], |row| row.get::<_, u64>(0))?
93        .collect::<rusqlite::Result<Vec<_>>>()?;
94    let restart_pattern = format!("{}*", mj_core::transcript::SESSION_RESTART_ITEM_PREFIX);
95    let mut restart_statement = connection.prepare(
96        "SELECT position
97         FROM materialized_transcript_items
98         WHERE session_id = ?1 AND stable_id GLOB ?2
99         ORDER BY position, stable_id",
100    )?;
101    let session_restart_event_ordinals = restart_statement
102        .query_map((session_id, restart_pattern), |row| row.get::<_, u64>(0))?
103        .collect::<rusqlite::Result<Vec<_>>>()?;
104
105    Ok(Some(MaterializedSessionSummary {
106        session_id: session_id.to_owned(),
107        applied_event_ordinal,
108        last_activity_at_ms,
109        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
110        session_title,
111        last_agent_message: last_agent_message.map(|(_, message)| message),
112        last_user_message: last_user_message.map(|(_, message)| message),
113        last_agent_message_follows_last_user,
114        agent_message_latest_content_ordinals,
115        session_restart_event_ordinals,
116    }))
117}
118
119/// The oldest visible user message, which is where a session's provisional
120/// title comes from. It sits at the head of the transcript, so a projection
121/// loaded as a tail cannot find it by scanning; this reads it directly.
122pub(super) fn first_materialized_user_message(
123    connection: &Connection,
124    session_id: &str,
125) -> Result<Option<(u64, String)>> {
126    materialized_user_message(connection, session_id, true)
127}
128
129pub(super) fn last_materialized_user_message(
130    connection: &Connection,
131    session_id: &str,
132) -> Result<Option<(u64, String)>> {
133    materialized_user_message(connection, session_id, false)
134}
135
136pub(super) fn materialized_user_message(
137    connection: &Connection,
138    session_id: &str,
139    oldest_first: bool,
140) -> Result<Option<(u64, String)>> {
141    let mut statement = connection.prepare(if oldest_first {
142        "SELECT position, body_json
143         FROM materialized_transcript_items
144         WHERE session_id = ?1
145           AND json_extract(
146               CASE
147                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
148                   THEN body_json
149                   ELSE '{}'
150               END,
151               '$.kind'
152           ) = 'user'
153         ORDER BY position, stable_id"
154    } else {
155        "SELECT position, body_json
156         FROM materialized_transcript_items
157         WHERE session_id = ?1
158           AND json_extract(
159               CASE
160                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
161                   THEN body_json
162                   ELSE '{}'
163               END,
164               '$.kind'
165           ) = 'user'
166         ORDER BY position DESC, stable_id DESC"
167    })?;
168    let rows = statement.query_map([session_id], |row| {
169        Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
170    })?;
171    for row in rows {
172        let (position, body_json) = row?;
173        let body: TranscriptBody = serde_json::from_str(&body_json)
174            .with_context(|| format!("parse materialized user message for session {session_id}"))?;
175        let TranscriptBody::User { content } = body else {
176            continue;
177        };
178        let text = mj_core::transcript::materialized_content_text(&content);
179        if !text.trim().is_empty() {
180            return Ok(Some((position, text)));
181        }
182    }
183    Ok(None)
184}
185
186/// Where the newest turn began: a user message, or the marker for a turn the
187/// harness started on its own. This is the recovery boundary, so it reads a
188/// position only and never has to decode a transcript body.
189pub(super) fn last_materialized_turn_start(
190    connection: &Connection,
191    session_id: &str,
192) -> Result<Option<u64>> {
193    Ok(connection
194        .query_row(
195            "SELECT position
196             FROM materialized_transcript_items
197             WHERE session_id = ?1
198               AND (
199                   stable_id GLOB ?2
200                   OR json_extract(
201                       CASE
202                           WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
203                           THEN body_json
204                           ELSE '{}'
205                       END,
206                       '$.kind'
207                   ) = 'user'
208               )
209             ORDER BY position DESC, stable_id DESC
210             LIMIT 1",
211            params![
212                session_id,
213                format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX)
214            ],
215            |row| row.get::<_, u64>(0),
216        )
217        .optional()?)
218}
219
220pub(super) fn last_materialized_agent_message(
221    connection: &Connection,
222    session_id: &str,
223) -> Result<Option<(u64, String)>> {
224    last_materialized_agent_message_in(connection, session_id, 0, None)
225}
226
227/// The newest nonempty agent message inside one turn, flattened to text.
228///
229/// The turn is a span, not a starting point. A harness can put an agent
230/// message in the transcript after the turn it answered has ended — a resume
231/// notice is one — and reading everything after the turn's start would return
232/// that notice as the turn's answer.
233pub(super) fn last_materialized_agent_message_within(
234    connection: &Connection,
235    session_id: &str,
236    after_position: u64,
237    through_position: u64,
238) -> Result<Option<String>> {
239    Ok(last_materialized_agent_message_in(
240        connection,
241        session_id,
242        after_position,
243        Some(through_position),
244    )?
245    .map(|(_, text)| text))
246}
247
248pub(super) fn last_materialized_agent_message_in(
249    connection: &Connection,
250    session_id: &str,
251    after_position: u64,
252    through_position: Option<u64>,
253) -> Result<Option<(u64, String)>> {
254    let row = connection
255        .query_row(
256            "SELECT position, body_json
257             FROM materialized_transcript_items
258             WHERE session_id = ?1
259               AND position > ?2
260               AND (?3 IS NULL OR position <= ?3)
261               AND latest_content_event_ordinal IS NOT NULL
262               AND EXISTS (
263                   SELECT 1 FROM json_each(
264                       CASE
265                           WHEN latest_content_event_ordinal IS NOT NULL
266                               AND json_valid(body_json)
267                           THEN body_json
268                           ELSE '{}'
269                       END,
270                       '$.chunks'
271                   ) AS chunk
272                   WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
273                     AND (
274                         json_extract(chunk.value, '$.content.type') <> 'text'
275                         OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
276                     )
277               )
278             ORDER BY position DESC, stable_id DESC
279             LIMIT 1",
280            params![session_id, after_position, through_position],
281            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
282        )
283        .optional()?;
284    let Some((position, body_json)) = row else {
285        return Ok(None);
286    };
287    let body: TranscriptBody = serde_json::from_str(&body_json)
288        .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
289    let TranscriptBody::Agent { chunks, .. } = body else {
290        return Ok(None);
291    };
292    let text = mj_core::transcript::materialized_chunks_text(&chunks);
293    Ok((!text.trim().is_empty()).then_some((position, text)))
294}
295
296/// Execution state, the running turn, and the last finished turn's outcome.
297pub type MaterializedTurnState = (
298    MaterializedExecutionState,
299    Option<MaterializedTurn>,
300    Option<MaterializedTurnOutcome>,
301);
302
303/// Where a session stands turn by turn: what is running now, and how the last
304/// finished prompt ended. Returns `None` when the session has no projection
305/// row. The API's wait loop reads this for sessions whose actor is gone.
306pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
307    load_materialized_turn_outcome_from(&database_path(), session_id)
308}
309
310pub(super) fn load_materialized_turn_outcome_from(
311    path: &Path,
312    session_id: &str,
313) -> Result<Option<MaterializedTurnState>> {
314    let connection = open_reader(path)?;
315    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
316        return Ok(None);
317    };
318    Ok(Some((
319        fields.execution,
320        fields.active_turn,
321        fields.last_turn_outcome,
322    )))
323}
324
325/// The answer a session's last finished turn ended with.
326///
327/// This is what a finished child session reports back to the agent that
328/// delegated to it, so it has to be that turn's own last agent message. The
329/// session-wide last agent message is not the same thing: a harness records
330/// messages of its own outside any turn, and a resume notice arriving after
331/// the child finished would then stand in for the child's report.
332///
333/// `None` means the session has no projection row, or no finished turn whose
334/// span is recorded, and the caller decides what to show instead.
335pub fn load_materialized_finished_turn_message(session_id: &str) -> Result<Option<String>> {
336    load_materialized_finished_turn_message_from(&database_path(), session_id)
337}
338
339pub(super) fn load_materialized_finished_turn_message_from(
340    path: &Path,
341    session_id: &str,
342) -> Result<Option<String>> {
343    let connection = open_reader(path)?;
344    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
345        return Ok(None);
346    };
347    let Some(turn) = fields.last_turn_outcome else {
348        return Ok(None);
349    };
350    let Some(start_position) = turn.turn_start_position else {
351        return Ok(None);
352    };
353    last_materialized_agent_message_within(
354        &connection,
355        session_id,
356        start_position,
357        turn.completed_ordinal,
358    )
359}
360
361/// Summarize the turn that ran from `turn_start_position` to
362/// `turn_completed_position`.
363///
364/// Both bounds come from the turn's own record: a transcript item's position
365/// and a completed turn's `completed_ordinal` are the same relay ordinal, so
366/// the completion ordinal is the last position the turn can own. Anything the
367/// session records afterwards belongs to no turn, or to the next one.
368pub fn load_materialized_turn_summary(
369    session_id: &str,
370    turn_start_position: u64,
371    turn_completed_position: u64,
372) -> Result<TurnSummary> {
373    load_materialized_turn_summary_from(
374        &database_path(),
375        session_id,
376        turn_start_position,
377        turn_completed_position,
378    )
379}
380
381pub(super) fn load_materialized_turn_summary_from(
382    path: &Path,
383    session_id: &str,
384    turn_start_position: u64,
385    turn_completed_position: u64,
386) -> Result<TurnSummary> {
387    let connection = open_reader(path)?;
388    let turn_number = connection.query_row(
389        "SELECT COUNT(*)
390         FROM materialized_transcript_items
391         WHERE session_id = ?1
392           AND position <= ?3
393           AND (
394               stable_id GLOB ?2
395               OR json_extract(
396                   CASE
397                       WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
398                       THEN body_json
399                       ELSE '{}'
400                   END,
401                   '$.kind'
402               ) = 'user'
403           )",
404        params![
405            session_id,
406            format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
407            turn_start_position
408        ],
409        |row| row.get::<_, u64>(0),
410    )?;
411    let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
412        "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
413         FROM materialized_transcript_items
414         WHERE session_id = ?1 AND position >= ?2 AND position <= ?3",
415        params![session_id, turn_start_position, turn_completed_position],
416        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
417    )?;
418    let final_message = last_materialized_agent_message_within(
419        &connection,
420        session_id,
421        turn_start_position,
422        turn_completed_position,
423    )?;
424    Ok(TurnSummary {
425        turn_number,
426        turn_started_at_ms,
427        last_changed_at_ms,
428        final_message,
429    })
430}
431
432pub fn load_materialized_transcript_filtered(
433    session_id: &str,
434    after_seq: u64,
435    limit: usize,
436    role: Option<mj_core::transcript::TranscriptRole>,
437) -> Result<Option<TranscriptPage>> {
438    load_materialized_transcript_filtered_from(&database_path(), session_id, after_seq, limit, role)
439}
440
441pub(super) fn load_materialized_transcript_filtered_from(
442    path: &Path,
443    session_id: &str,
444    after_seq: u64,
445    limit: usize,
446    role: Option<mj_core::transcript::TranscriptRole>,
447) -> Result<Option<TranscriptPage>> {
448    let mut reader = open_reader(path)?;
449    let connection = reader.transaction()?;
450    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
451        return Ok(None);
452    };
453    let role = role.map(|r| r.storage_kind());
454    let mut statement = connection.prepare(
455        "WITH matches AS (
456             SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
457             FROM materialized_transcript_items WHERE session_id = ?1
458             AND COALESCE(latest_content_event_ordinal, position) > ?2
459             AND (?4 IS NULL OR json_extract(body_json, '$.kind') = ?4)
460         ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
461         SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
462                last_changed_at_ms, body_json
463         FROM matches WHERE seq <= (SELECT seq FROM boundary)
464         ORDER BY seq, stable_id",
465    )?;
466    let rows = statement
467        .query_map(
468            params![session_id, after_seq, limit.clamp(1, 1000) as i64, role],
469            |row| {
470                Ok((
471                    row.get::<_, String>(0)?,
472                    row.get::<_, u64>(1)?,
473                    row.get::<_, Option<u64>>(2)?,
474                    row.get::<_, i64>(3)?,
475                    row.get::<_, i64>(4)?,
476                    row.get::<_, String>(5)?,
477                ))
478            },
479        )?
480        .collect::<rusqlite::Result<Vec<_>>>()?;
481    let items = rows
482        .into_iter()
483        .map(
484            |(
485                stable_id,
486                position,
487                latest_content_event_ordinal,
488                created_at_ms,
489                last_changed_at_ms,
490                body_json,
491            )| {
492                Ok(Arc::new(TranscriptItem {
493                    stable_id,
494                    position,
495                    latest_content_event_ordinal,
496                    created_at_ms,
497                    last_changed_at_ms,
498                    body: decode_transcript_body(&body_json, session_id)?,
499                }))
500            },
501        )
502        .collect::<Result<Vec<_>>>()?;
503    let latest_seq = connection.query_row(
504        "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
505         FROM materialized_transcript_items
506         WHERE session_id = ?1",
507        [session_id],
508        |row| row.get::<_, u64>(0),
509    )?;
510    let last_seq = items.last().map_or(after_seq, |item| item.seq());
511    let more: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM materialized_transcript_items WHERE session_id = ?1 AND COALESCE(latest_content_event_ordinal, position) > ?2 AND (?3 IS NULL OR json_extract(body_json, '$.kind') = ?3))", params![session_id, last_seq, role], |r| r.get(0))?;
512    Ok(Some(TranscriptPage {
513        next_after_seq: if more {
514            last_seq
515        } else {
516            latest_seq.max(after_seq)
517        },
518        items,
519        latest_seq,
520        execution: fields.execution,
521    }))
522}
523
524/// How many transcript rows one retention pass rewrites.
525///
526/// The daemon is the single database writer, so a pass that rewrote every row
527/// of a long session would stall every other write behind it. A capped pass
528/// leaves the rest for the next checkpoint, which is the next time any of it
529/// becomes redundant anyway.
530pub(super) const RETENTION_BATCH_ITEMS: usize = 4_096;
531
532/// Rows below this are already small enough that rewriting them would cost
533/// more than it reclaims.
534pub(super) const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
535
536/// Drop tool output that a verified checkpoint already holds.
537///
538/// The projection only ever grew: the only deletes were a per-item remove, a
539/// whole-session wipe, and the `sessions` cascade. One measured session reached
540/// 28,066 items and 635 MiB, of which 561 MB was tool-call content.
541///
542/// A checkpoint archive carries the complete transcript up to its event
543/// frontier, and one checkpoint per session is retained, so every item at or
544/// below `event_frontier` is durably recorded elsewhere. What stays here is
545/// what the transcript still shows: which tool ran, on what, with what result,
546/// and each edit's diffstat. See
547/// [`mj_transcript::transcript::compact_tool_call_for_retention`].
548pub fn compact_materialized_transcript_through(
549    session_id: &str,
550    event_frontier: u64,
551) -> Result<TranscriptRetention> {
552    let session_id = session_id.to_owned();
553    submit_database_write("compact_materialized_transcript", move |_| {
554        compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
555    })
556}
557
558pub(super) fn compact_materialized_transcript_in(
559    path: &Path,
560    session_id: &str,
561    event_frontier: u64,
562) -> Result<TranscriptRetention> {
563    let mut connection = open(path)?;
564    let candidates = {
565        let mut statement = connection.prepare(
566            "SELECT stable_id, body_json
567             FROM materialized_transcript_items
568             WHERE session_id = ?1
569               AND position <= ?2
570               AND length(body_json) > ?3
571               AND json_extract(
572                   CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
573                   '$.kind'
574               ) = 'tool'
575             ORDER BY position, stable_id
576             LIMIT ?4",
577        )?;
578        statement
579            .query_map(
580                params![
581                    session_id,
582                    event_frontier,
583                    RETENTION_BODY_FLOOR_BYTES as i64,
584                    RETENTION_BATCH_ITEMS as i64 + 1
585                ],
586                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
587            )?
588            .collect::<rusqlite::Result<Vec<_>>>()?
589    };
590    let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
591    let mut retention = TranscriptRetention {
592        remaining,
593        ..TranscriptRetention::default()
594    };
595    let transaction =
596        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
597    for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
598        let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
599            Ok(body) => body,
600            // A row this cannot read is a row it must not rewrite.
601            Err(error) => {
602                tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
603                continue;
604            }
605        };
606        if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
607            continue;
608        }
609        let compacted = serde_json::to_string(&body)
610            .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
611        if compacted.len() >= body_json.len() {
612            continue;
613        }
614        transaction.execute(
615            "UPDATE materialized_transcript_items SET body_json = ?3
616             WHERE session_id = ?1 AND stable_id = ?2",
617            params![session_id, stable_id, compacted],
618        )?;
619        retention.items += 1;
620        retention.bytes += body_json.len() - compacted.len();
621    }
622    transaction.commit()?;
623    Ok(retention)
624}
625
626/// How many transcript items a polled projection carries.
627///
628/// Every viewer of a polled projection is bounded already: the conversation
629/// pane keeps `chat::TAIL_SEED_ITEMS` (256) entries, and the browser
630/// transcript keeps 1,000 rendered lines. This is set above both, since an
631/// entry renders to at least one line, so the window is the whole of what any
632/// of them would show.
633pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
634
635/// Load a projection carrying only the end of its transcript.
636///
637/// The steady-state poll reloads a session's projection every time anything
638/// about it moves. Loading the whole transcript to do that is work
639/// proportional to everything that has ever happened in the conversation —
640/// 635 MiB and 28,066 items on one measured session — for a view that shows
641/// the last few hundred entries. This reads the window instead, plus the two
642/// facts that live outside it, each with one indexed query. See
643/// [`ProjectionWindow`].
644pub fn load_materialized_projection_tail(
645    session_id: &str,
646    transcript_limit: usize,
647) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
648    load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
649}
650
651pub(super) fn load_materialized_projection_tail_from(
652    path: &Path,
653    session_id: &str,
654    transcript_limit: usize,
655) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
656    let connection = open_reader(path)?;
657    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
658        return Ok(None);
659    };
660    let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
661    let total_items = connection.query_row(
662        "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
663        [session_id],
664        |row| row.get::<_, usize>(0),
665    )?;
666    let window = ProjectionWindow {
667        omitted_items: total_items.saturating_sub(transcript.len()),
668        provisional_title: first_materialized_user_message(&connection, session_id)?
669            .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
670        latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
671    };
672    let materialized = MaterializedSession {
673        session_id: session_id.to_owned(),
674        applied_event_ordinal: fields.applied_event_ordinal,
675        applied_event_digest: fields.applied_event_digest,
676        last_activity_at_ms: fields.last_activity_at_ms,
677        execution: fields.execution,
678        session_title: fields.session_title,
679        configuration: fields.configuration,
680        transcript,
681        queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
682        pending_elicitations: fields.pending_elicitations,
683        active_turn: fields.active_turn,
684        last_turn_outcome: fields.last_turn_outcome,
685    };
686    materialized.validate()?;
687    Ok(Some((materialized, window)))
688}
689
690/// Read only the projection's event frontier. Deciding whether a stored
691/// projection already matches an archive costs one row this way, instead of
692/// deserializing every transcript item to compare two integers.
693pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
694    materialized_event_frontier_from(&database_path(), session_id)
695}
696
697pub(super) fn materialized_event_frontier_from(
698    path: &Path,
699    session_id: &str,
700) -> Result<Option<(u64, String)>> {
701    Ok(open_reader(path)?
702        .query_row(
703            "SELECT applied_event_ordinal, applied_event_digest
704             FROM materialized_sessions WHERE session_id = ?1",
705            [session_id],
706            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
707        )
708        .optional()?)
709}
710
711/// Replace a session's durable prompt queue without touching its transcript or
712/// event frontier. Resume uses this when it keeps the stored projection but
713/// still has to drop the queue the archive carried.
714pub fn replace_materialized_queued_prompts(
715    session_id: &str,
716    queued_prompts: &[MaterializedQueuedPrompt],
717) -> Result<()> {
718    let session_id = session_id.to_owned();
719    let queued_prompts = queued_prompts.to_vec();
720    submit_database_write("replace_materialized_queued_prompts", move |_| {
721        replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
722    })
723}
724
725pub(super) fn replace_materialized_queued_prompts_in(
726    path: &Path,
727    session_id: &str,
728    queued_prompts: &[MaterializedQueuedPrompt],
729) -> Result<()> {
730    let mut connection = open(path)?;
731    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
732    if !session_exists(&tx, session_id)? {
733        bail!("unknown session {session_id}");
734    }
735    replace_materialized_queue(&tx, session_id, queued_prompts)?;
736    tx.commit()?;
737    Ok(())
738}
739
740/// The activity watermark of every session whose projection holds at least one
741/// transcript item, by session id.
742///
743/// This is a change token, not a projection: session indexing needs to know
744/// which live conversations have moved since it last looked, and loading each
745/// one's transcript to find out would cost the whole corpus every sync.
746pub fn load_transcribed_session_activity() -> Result<BTreeMap<String, Option<i64>>> {
747    load_transcribed_session_activity_from(&database_path())
748}
749
750fn load_transcribed_session_activity_from(path: &Path) -> Result<BTreeMap<String, Option<i64>>> {
751    let connection = open_reader(path)?;
752    let mut statement = connection.prepare(
753        "SELECT session_id, last_activity_at_ms
754         FROM materialized_sessions s
755         WHERE EXISTS (
756             SELECT 1 FROM materialized_transcript_items i
757             WHERE i.session_id = s.session_id
758         )",
759    )?;
760    let rows = statement.query_map([], |row| {
761        Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
762    })?;
763    let mut activity = BTreeMap::new();
764    for row in rows {
765        let (session_id, last_activity_at_ms) = row?;
766        activity.insert(session_id, last_activity_at_ms);
767    }
768    Ok(activity)
769}
770
771/// Load only the durable prompt queues without deserializing transcript rows.
772/// Dashboard startup uses this path so work is proportional to queued prompts,
773/// not to the complete retained conversation history.
774pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
775{
776    load_materialized_queued_prompts_from(&database_path())
777}
778
779pub(super) fn load_materialized_queued_prompts_from(
780    path: &Path,
781) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
782    let connection = open_reader(path)?;
783    let mut statement = connection.prepare(
784        "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
785         FROM materialized_queued_prompts
786         ORDER BY session_id, ordinal",
787    )?;
788    let rows = statement.query_map([], |row| {
789        Ok((
790            row.get::<_, String>(0)?,
791            row.get::<_, String>(1)?,
792            row.get::<_, String>(2)?,
793            row.get::<_, String>(3)?,
794            row.get::<_, i64>(4)?,
795            row.get::<_, Option<u64>>(5)?,
796        ))
797    })?;
798    let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
799    for row in rows {
800        let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
801            row?;
802        let content = serde_json::from_str(&content_json).with_context(|| {
803            format!("parse materialized queued prompt for session {session_id}")
804        })?;
805        let kind = serde_json::from_str(&kind_json).with_context(|| {
806            format!("parse materialized queue entry kind for session {session_id}")
807        })?;
808        queues
809            .entry(session_id)
810            .or_default()
811            .push(MaterializedQueuedPrompt {
812                command_id,
813                kind,
814                content,
815                queued_at_ms,
816                accepted_ordinal,
817            });
818    }
819    Ok(queues)
820}
821
822pub(super) fn load_materialized_session_from(
823    path: &Path,
824    session_id: &str,
825) -> Result<Option<MaterializedSession>> {
826    let connection = open_reader(path)?;
827    load_materialized_session_with(&connection, session_id)
828}
829
830pub(super) fn load_materialized_session_with(
831    connection: &Connection,
832    session_id: &str,
833) -> Result<Option<MaterializedSession>> {
834    let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
835        return Ok(None);
836    };
837    let materialized = MaterializedSession {
838        session_id: session_id.to_owned(),
839        applied_event_ordinal: fields.applied_event_ordinal,
840        applied_event_digest: fields.applied_event_digest,
841        last_activity_at_ms: fields.last_activity_at_ms,
842        execution: fields.execution,
843        session_title: fields.session_title,
844        configuration: fields.configuration,
845        transcript: read_materialized_transcript(connection, session_id, None)?,
846        queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
847        pending_elicitations: fields.pending_elicitations,
848        active_turn: fields.active_turn,
849        last_turn_outcome: fields.last_turn_outcome,
850    };
851    materialized.validate()?;
852    Ok(Some(materialized))
853}
854
855/// Everything a projection holds apart from its transcript and its queue.
856pub(super) struct MaterializedSessionFields {
857    pub(super) applied_event_ordinal: u64,
858    pub(super) applied_event_digest: String,
859    pub(super) last_activity_at_ms: Option<i64>,
860    pub(super) execution: MaterializedExecutionState,
861    pub(super) session_title: Option<String>,
862    pub(super) configuration: BTreeMap<String, serde_json::Value>,
863    pub(super) pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
864    pub(super) active_turn: Option<MaterializedTurn>,
865    pub(super) last_turn_outcome: Option<MaterializedTurnOutcome>,
866}
867
868pub(super) fn read_materialized_session_fields(
869    connection: &Connection,
870    session_id: &str,
871) -> Result<Option<MaterializedSessionFields>> {
872    let row = connection
873        .query_row(
874            "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
875                    execution_state, running_started_at_ms, session_title, configuration_json,
876                    pending_elicitations_json, active_turn_json, last_turn_outcome_json
877             FROM materialized_sessions WHERE session_id = ?1",
878            [session_id],
879            |row| {
880                Ok((
881                    row.get::<_, u64>(0)?,
882                    row.get::<_, String>(1)?,
883                    row.get::<_, Option<i64>>(2)?,
884                    row.get::<_, String>(3)?,
885                    row.get::<_, Option<i64>>(4)?,
886                    row.get::<_, Option<String>>(5)?,
887                    row.get::<_, String>(6)?,
888                    row.get::<_, String>(7)?,
889                    row.get::<_, Option<String>>(8)?,
890                    row.get::<_, Option<String>>(9)?,
891                ))
892            },
893        )
894        .optional()?;
895    let Some((
896        applied_event_ordinal,
897        applied_event_digest,
898        last_activity_at_ms,
899        execution,
900        running_started_at_ms,
901        session_title,
902        configuration_json,
903        pending_elicitations_json,
904        active_turn_json,
905        last_turn_outcome_json,
906    )) = row
907    else {
908        return Ok(None);
909    };
910    Ok(Some(MaterializedSessionFields {
911        applied_event_ordinal,
912        applied_event_digest,
913        last_activity_at_ms,
914        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
915        session_title,
916        configuration: serde_json::from_str(&configuration_json).with_context(|| {
917            format!("parse materialized configuration for session {session_id}")
918        })?,
919        pending_elicitations: serde_json::from_str(&pending_elicitations_json)
920            .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
921        active_turn: active_turn_json
922            .as_deref()
923            .map(serde_json::from_str)
924            .transpose()
925            .with_context(|| format!("parse active turn for session {session_id}"))?,
926        last_turn_outcome: last_turn_outcome_json
927            .as_deref()
928            .map(serde_json::from_str)
929            .transpose()
930            .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
931    }))
932}
933
934/// Read a session's transcript, oldest first. `limit` reads only that many
935/// items from the end, walking the `materialized_transcript_position` index
936/// backwards so the read costs the rows it returns.
937pub(super) fn read_materialized_transcript(
938    connection: &Connection,
939    session_id: &str,
940    limit: Option<usize>,
941) -> Result<Vec<Arc<TranscriptItem>>> {
942    let mut statement = connection.prepare(match limit {
943        Some(_) => {
944            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
945                    last_changed_at_ms, body_json
946             FROM materialized_transcript_items
947             WHERE session_id = ?1
948             ORDER BY position DESC, stable_id DESC
949             LIMIT ?2"
950        }
951        None => {
952            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
953                    last_changed_at_ms, body_json
954             FROM materialized_transcript_items
955             WHERE session_id = ?1
956             ORDER BY position, stable_id"
957        }
958    })?;
959    let read = |row: &rusqlite::Row<'_>| {
960        Ok((
961            row.get::<_, String>(0)?,
962            row.get::<_, u64>(1)?,
963            row.get::<_, Option<u64>>(2)?,
964            row.get::<_, i64>(3)?,
965            row.get::<_, i64>(4)?,
966            row.get::<_, String>(5)?,
967        ))
968    };
969    let rows = match limit {
970        Some(limit) => statement
971            .query_map(params![session_id, limit as i64], read)?
972            .collect::<rusqlite::Result<Vec<_>>>()?,
973        None => statement
974            .query_map([session_id], read)?
975            .collect::<rusqlite::Result<Vec<_>>>()?,
976    };
977    let mut transcript = rows
978        .into_iter()
979        .map(
980            |(
981                stable_id,
982                position,
983                latest_content_event_ordinal,
984                created_at_ms,
985                last_changed_at_ms,
986                body_json,
987            )| {
988                Ok(Arc::new(TranscriptItem {
989                    stable_id,
990                    position,
991                    latest_content_event_ordinal,
992                    created_at_ms,
993                    last_changed_at_ms,
994                    body: decode_transcript_body(&body_json, session_id)?,
995                }))
996            },
997        )
998        .collect::<Result<Vec<_>>>()?;
999    if limit.is_some() {
1000        // The bounded query walks the index backwards to bound what it reads;
1001        // every caller wants the transcript in the order it was written.
1002        transcript.reverse();
1003    }
1004    Ok(transcript)
1005}
1006
1007pub(super) fn read_materialized_queued_prompts(
1008    connection: &Connection,
1009    session_id: &str,
1010) -> Result<Vec<MaterializedQueuedPrompt>> {
1011    let mut statement = connection.prepare(
1012        "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
1013         FROM materialized_queued_prompts
1014         WHERE session_id = ?1
1015         ORDER BY ordinal",
1016    )?;
1017    let rows = statement
1018        .query_map([session_id], |row| {
1019            Ok((
1020                row.get::<_, String>(0)?,
1021                row.get::<_, String>(1)?,
1022                row.get::<_, String>(2)?,
1023                row.get::<_, i64>(3)?,
1024                row.get::<_, Option<u64>>(4)?,
1025            ))
1026        })?
1027        .collect::<rusqlite::Result<Vec<_>>>()?;
1028    rows.into_iter()
1029        .map(
1030            |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
1031                Ok(MaterializedQueuedPrompt {
1032                    command_id,
1033                    kind: serde_json::from_str(&kind_json).with_context(|| {
1034                        format!("parse materialized queue entry kind for session {session_id}")
1035                    })?,
1036                    content: serde_json::from_str(&content_json).with_context(|| {
1037                        format!("parse materialized queued prompt for session {session_id}")
1038                    })?,
1039                    queued_at_ms,
1040                    accepted_ordinal,
1041                })
1042            },
1043        )
1044        .collect()
1045}
1046
1047/// Replace a complete projection, primarily when seeding a restored
1048/// checkpoint. Operational `SessionRecord` metadata and read receipts are not
1049/// modified.
1050pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
1051    let materialized = materialized.clone();
1052    submit_database_write("save_materialized_session", move |_| {
1053        save_materialized_session_to(&database_path(), &materialized)
1054    })
1055}
1056
1057pub(super) fn save_materialized_session_to(
1058    path: &Path,
1059    materialized: &MaterializedSession,
1060) -> Result<()> {
1061    materialized.validate()?;
1062    let mut connection = open(path)?;
1063    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1064    if !session_exists(&tx, &materialized.session_id)? {
1065        bail!("unknown session {}", materialized.session_id);
1066    }
1067    write_materialized_session(&tx, materialized)?;
1068    tx.commit()?;
1069    Ok(())
1070}
1071
1072/// One relay page being applied inside a single write transaction. The relay
1073/// retains everything past the last acknowledgement, so a page that fails
1074/// part-way rolls back to the previous durable frontier and is simply
1075/// redelivered. Only a committed page may be acknowledged.
1076pub struct ProjectionPage<'a> {
1077    pub(super) session_id: &'a str,
1078    pub(super) transaction: Transaction<'a>,
1079    pub(super) applied_ordinal: u64,
1080    pub(super) applied_digest: String,
1081    pub(super) dirty: bool,
1082    pub(super) pending: MaterializedSessionMutation,
1083    pub(super) pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
1084    pub(super) pending_turns: Vec<MaterializedTurnOutcome>,
1085    pub(super) pending_events: Vec<(i64, ApiEventData)>,
1086}
1087
1088pub(super) struct PendingTranscriptMutation {
1089    pub(super) final_mutation: TranscriptMutation,
1090    pub(super) remove_before_upsert: bool,
1091}
1092
1093impl ProjectionPage<'_> {
1094    /// Apply the projection effects of the next relay event to the open page.
1095    /// The event must continue the chain the page has reached so far, which is
1096    /// the persisted frontier plus every event already applied to this page.
1097    pub fn apply(
1098        &mut self,
1099        event_ordinal: u64,
1100        previous_event_digest: &str,
1101        event_digest: &str,
1102        mutation: &MaterializedSessionMutation,
1103    ) -> Result<ProjectionApplyOutcome> {
1104        if event_ordinal == 0 {
1105            bail!("relay event ordinal must be positive");
1106        }
1107        // A v2 event carries no chain link (empty previous digest). Its
1108        // continuity to the projection frontier is proven by ordinal
1109        // contiguity plus the attach cursor the controller validated against
1110        // the worker, not by an in-record back-reference; divergence is caught
1111        // there, before any event is applied.
1112        let chained = !previous_event_digest.is_empty();
1113        if chained {
1114            validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
1115        }
1116        validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
1117        let session_id = self.session_id;
1118        let applied = self.applied_ordinal;
1119        if event_ordinal < applied {
1120            return Ok(ProjectionApplyOutcome::AlreadyApplied);
1121        }
1122        if event_ordinal == applied {
1123            if event_digest != self.applied_digest {
1124                bail!(
1125                    "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
1126                    self.applied_digest
1127                );
1128            }
1129            return Ok(ProjectionApplyOutcome::AlreadyApplied);
1130        }
1131        let expected = applied
1132            .checked_add(1)
1133            .context("materialized event ordinal overflow")?;
1134        if event_ordinal != expected {
1135            bail!(
1136                "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
1137            );
1138        }
1139        if chained && previous_event_digest != self.applied_digest {
1140            bail!(
1141                "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
1142                self.applied_digest
1143            );
1144        }
1145
1146        if let Some(activity_at_ms) = mutation.last_activity_at_ms {
1147            self.pending.last_activity_at_ms = Some(
1148                self.pending
1149                    .last_activity_at_ms
1150                    .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
1151            );
1152        }
1153        if let Some(execution) = mutation.execution {
1154            self.pending.execution = Some(execution);
1155        }
1156        if let Some(title) = &mutation.session_title {
1157            if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
1158                bail!("materialized session title cannot be empty");
1159            }
1160            self.pending.session_title = Some(title.clone());
1161        }
1162        if let Some(configuration) = &mutation.configuration {
1163            self.pending.configuration = Some(configuration.clone());
1164        }
1165        for item_mutation in &mutation.transcript {
1166            match item_mutation {
1167                TranscriptMutation::Upsert(item) => {
1168                    item.validate(event_ordinal)?;
1169                    let stable_id = item.stable_id.clone();
1170                    let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
1171                        PendingTranscriptMutation {
1172                            final_mutation: TranscriptMutation::Upsert(item.clone()),
1173                            remove_before_upsert: false,
1174                        }
1175                    });
1176                    entry.remove_before_upsert |=
1177                        matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
1178                    entry.final_mutation = TranscriptMutation::Upsert(item.clone());
1179                }
1180                TranscriptMutation::Remove { stable_id } => {
1181                    if stable_id.trim().is_empty() {
1182                        bail!("cannot remove a transcript item with an empty stable id");
1183                    }
1184                    let removed = TranscriptMutation::Remove {
1185                        stable_id: stable_id.clone(),
1186                    };
1187                    self.pending_transcript
1188                        .entry(stable_id.clone())
1189                        .and_modify(|entry| entry.final_mutation = removed.clone())
1190                        .or_insert(PendingTranscriptMutation {
1191                            final_mutation: removed,
1192                            remove_before_upsert: false,
1193                        });
1194                }
1195            }
1196        }
1197        if let Some(queued_prompts) = &mutation.queued_prompts {
1198            self.pending.queued_prompts = Some(queued_prompts.clone());
1199        }
1200        if let Some(pending_elicitations) = &mutation.pending_elicitations {
1201            self.pending.pending_elicitations = Some(pending_elicitations.clone());
1202        }
1203        self.pending
1204            .config_results
1205            .extend(mutation.config_results.clone());
1206        if let Some(active_turn) = &mutation.active_turn {
1207            self.pending.active_turn = Some(active_turn.clone());
1208        }
1209        if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
1210            self.pending_turns.push(last_turn_outcome.clone());
1211            self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
1212        }
1213        if let Some(cost) = &mutation.provider_cost {
1214            self.pending.provider_cost = Some(cost.clone());
1215        }
1216        self.pending_events.extend(
1217            mutation
1218                .api_events
1219                .iter()
1220                .cloned()
1221                .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
1222        );
1223        self.applied_ordinal = event_ordinal;
1224        event_digest.clone_into(&mut self.applied_digest);
1225        self.dirty = true;
1226        Ok(ProjectionApplyOutcome::Applied)
1227    }
1228
1229    /// Persist the coalesced final state of this page. Intermediate event
1230    /// frontiers are useful only for chain validation: a page commits or rolls
1231    /// back as a unit, so writing them individually adds no recovery value.
1232    pub(super) fn flush(&mut self) -> Result<()> {
1233        if !self.dirty {
1234            return Ok(());
1235        }
1236        let tx = &self.transaction;
1237        let session_id = self.session_id;
1238        if let Some(execution) = self.pending.execution {
1239            let (state, started_at_ms) = materialized_execution_columns(execution);
1240            tx.execute(
1241                "UPDATE materialized_sessions
1242                 SET execution_state = ?2, running_started_at_ms = ?3
1243                 WHERE session_id = ?1",
1244                params![session_id, state, started_at_ms],
1245            )?;
1246        }
1247        if let Some(title) = &self.pending.session_title {
1248            tx.execute(
1249                "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
1250                params![session_id, title],
1251            )?;
1252        }
1253        if let Some(configuration) = &self.pending.configuration {
1254            tx.execute(
1255                "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
1256                params![session_id, serde_json::to_string(configuration)?],
1257            )?;
1258        }
1259        for pending in self.pending_transcript.values() {
1260            match &pending.final_mutation {
1261                TranscriptMutation::Upsert(item) => {
1262                    // A remove followed by an upsert deliberately starts a new
1263                    // item identity. Preserve that boundary even though other
1264                    // repeated updates are coalesced to one write.
1265                    if pending.remove_before_upsert {
1266                        tx.execute(
1267                            "DELETE FROM materialized_transcript_items
1268                             WHERE session_id = ?1 AND stable_id = ?2",
1269                            params![session_id, item.stable_id],
1270                        )?;
1271                    }
1272                    upsert_transcript_item(tx, session_id, item)?;
1273                }
1274                TranscriptMutation::Remove { stable_id } => {
1275                    tx.execute(
1276                        "DELETE FROM materialized_transcript_items
1277                         WHERE session_id = ?1 AND stable_id = ?2",
1278                        params![session_id, stable_id],
1279                    )?;
1280                }
1281            }
1282        }
1283        if let Some(queued_prompts) = &self.pending.queued_prompts {
1284            replace_materialized_queue(tx, session_id, queued_prompts)?;
1285        }
1286        if let Some(pending_elicitations) = &self.pending.pending_elicitations {
1287            tx.execute(
1288                "UPDATE materialized_sessions
1289                 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
1290                params![session_id, serde_json::to_string(pending_elicitations)?],
1291            )?;
1292        }
1293        for (recorded_at_ms, event) in &self.pending_events {
1294            events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
1295        }
1296        for turn in &self.pending_turns {
1297            tx.execute("INSERT OR REPLACE INTO session_turn_usage(session_id, command_id, completed_ordinal, turn_start_position, body) VALUES (?1, ?2, ?3, ?4, ?5)", params![session_id, turn.command_id, turn.completed_ordinal, turn.turn_start_position, serde_json::to_string(turn)?])?;
1298        }
1299        if let Some(cost) = &self.pending.provider_cost {
1300            tx.execute(
1301                "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
1302                params![session_id, serde_json::to_string(cost)?],
1303            )?;
1304        }
1305        for (command_id, error) in &self.pending.config_results {
1306            tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
1307        }
1308        if let Some(active_turn) = &self.pending.active_turn {
1309            tx.execute(
1310                "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
1311                params![
1312                    session_id,
1313                    active_turn
1314                        .as_ref()
1315                        .map(serde_json::to_string)
1316                        .transpose()?
1317                ],
1318            )?;
1319        }
1320        if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
1321            tx.execute(
1322                "UPDATE materialized_sessions
1323                 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
1324                params![session_id, serde_json::to_string(last_turn_outcome)?],
1325            )?;
1326        }
1327        tx.execute(
1328            "UPDATE materialized_sessions
1329             SET last_activity_at_ms = CASE
1330                     WHEN ?2 IS NULL THEN last_activity_at_ms
1331                     WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
1332                     ELSE last_activity_at_ms
1333                 END,
1334                 applied_event_ordinal = ?3,
1335                 applied_event_digest = ?4
1336             WHERE session_id = ?1",
1337            params![
1338                session_id,
1339                self.pending.last_activity_at_ms,
1340                self.applied_ordinal,
1341                self.applied_digest,
1342            ],
1343        )?;
1344        Ok(())
1345    }
1346}
1347
1348/// Apply one relay page in a single transaction. `fill` feeds the page's
1349/// events through [`ProjectionPage::apply`]; the projection changes and the
1350/// event frontier commit together only when `fill` succeeds, so callers may
1351/// acknowledge the page's last ordinal to the relay after this returns.
1352pub fn apply_projection_page<T>(
1353    session_id: &str,
1354    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
1355) -> Result<T>
1356where
1357    T: Send + 'static,
1358{
1359    let session_id = session_id.to_owned();
1360    submit_database_write("apply_projection_page", move |connection| {
1361        apply_projection_page_with(connection, &session_id, fill)
1362    })
1363}
1364
1365#[cfg(test)]
1366pub(super) fn apply_projection_page_to<T>(
1367    path: &Path,
1368    session_id: &str,
1369    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1370) -> Result<T> {
1371    let mut connection = open(path)?;
1372    apply_projection_page_with(&mut connection, session_id, fill)
1373}
1374
1375pub(super) fn apply_projection_page_with<T>(
1376    connection: &mut Connection,
1377    session_id: &str,
1378    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1379) -> Result<T> {
1380    let transaction =
1381        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1382    let (applied_ordinal, applied_digest) = transaction
1383        .query_row(
1384            "SELECT applied_event_ordinal, applied_event_digest
1385             FROM materialized_sessions WHERE session_id = ?1",
1386            [session_id],
1387            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
1388        )
1389        .optional()?
1390        .with_context(|| format!("unknown session {session_id}"))?;
1391    validate_relay_event_frontier(
1392        applied_ordinal,
1393        &applied_digest,
1394        "persisted relay event frontier",
1395    )?;
1396    let mut page = ProjectionPage {
1397        session_id,
1398        transaction,
1399        applied_ordinal,
1400        applied_digest,
1401        dirty: false,
1402        pending: MaterializedSessionMutation::default(),
1403        pending_transcript: BTreeMap::new(),
1404        pending_turns: Vec::new(),
1405        pending_events: Vec::new(),
1406    };
1407    // Dropping the page on failure rolls the whole transaction back, leaving
1408    // the projection at the frontier the relay last saw acknowledged.
1409    let filled = fill(&mut page)?;
1410    page.flush()?;
1411    page.transaction.commit()?;
1412    Ok(filled)
1413}
1414
1415/// Apply exactly one relay event, as a page of one.
1416pub fn apply_projection_event(
1417    session_id: &str,
1418    event_ordinal: u64,
1419    previous_event_digest: &str,
1420    event_digest: &str,
1421    mutation: &MaterializedSessionMutation,
1422) -> Result<ProjectionApplyOutcome> {
1423    let session_id = session_id.to_owned();
1424    let previous_event_digest = previous_event_digest.to_owned();
1425    let event_digest = event_digest.to_owned();
1426    let mutation = mutation.clone();
1427    submit_database_write("apply_projection_event", move |connection| {
1428        apply_projection_page_with(connection, &session_id, |page| {
1429            page.apply(
1430                event_ordinal,
1431                &previous_event_digest,
1432                &event_digest,
1433                &mutation,
1434            )
1435        })
1436    })
1437}
1438
1439#[cfg(test)]
1440pub(super) fn apply_projection_event_to(
1441    path: &Path,
1442    session_id: &str,
1443    event_ordinal: u64,
1444    previous_event_digest: &str,
1445    event_digest: &str,
1446    mutation: &MaterializedSessionMutation,
1447) -> Result<ProjectionApplyOutcome> {
1448    apply_projection_page_to(path, session_id, |page| {
1449        page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
1450    })
1451}
1452
1453/// Advance the persisted detach/read receipt monotonically. A receipt cannot
1454/// acknowledge an event the controller projection has not durably applied.
1455pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
1456    let session_id = session_id.to_owned();
1457    submit_database_write("advance_viewed_through_event_ordinal", move |_| {
1458        advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
1459    })
1460}
1461
1462pub(super) fn advance_viewed_through_event_ordinal_to(
1463    path: &Path,
1464    session_id: &str,
1465    through: u64,
1466) -> Result<u64> {
1467    let mut connection = open(path)?;
1468    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1469    let applied = tx
1470        .query_row(
1471            "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
1472            [session_id],
1473            |row| row.get::<_, u64>(0),
1474        )
1475        .optional()?
1476        .with_context(|| format!("unknown session {session_id}"))?;
1477    if through > applied {
1478        bail!(
1479            "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
1480        );
1481    }
1482    tx.execute(
1483        "UPDATE sessions
1484         SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
1485         WHERE session_id = ?1",
1486        params![session_id, through],
1487    )?;
1488    let receipt = tx.query_row(
1489        "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
1490        [session_id],
1491        |row| row.get::<_, u64>(0),
1492    )?;
1493    tx.commit()?;
1494    Ok(receipt)
1495}
1496
1497/// Decode a stored transcript body, merging runs of streamed text chunks.
1498///
1499/// Rows written before streamed text was merged on the way in hold one chunk
1500/// per token, which costs far more memory decoded than the text it carries.
1501/// Collapsing them here shrinks long sessions without rewriting stored JSON.
1502fn decode_transcript_body(body_json: &str, session_id: &str) -> Result<TranscriptBody> {
1503    let mut body: TranscriptBody = serde_json::from_str(body_json)
1504        .with_context(|| format!("parse materialized transcript body for session {session_id}"))?;
1505    match &mut body {
1506        TranscriptBody::Agent { chunks, .. } | TranscriptBody::Thought { chunks, .. } => {
1507            mj_core::transcript::coalesce_content_chunks(chunks);
1508        }
1509        _ => {}
1510    }
1511    Ok(body)
1512}