Skip to main content

mj_transcript/
projection.rs

1//! Controller-owned projection of the durable ACP relay stream.
2
3mod materialize;
4mod observation;
5mod session_update;
6mod terminals;
7pub use materialize::*;
8pub use observation::*;
9use session_update::*;
10use terminals::*;
11
12mod api_events;
13
14use std::collections::{BTreeMap, BTreeSet, HashMap};
15use std::sync::Arc;
16
17use agent_client_protocol::schema::{
18    MaybeUndefined,
19    v1::{
20        ContentBlock, ContentChunk, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
21        SessionUpdate, TextContent, ToolCall, ToolCallContent, ToolCallStatus,
22        ToolCallUpdateFields,
23    },
24};
25use anyhow::{Context, Result, bail};
26use serde::Deserialize;
27use serde_json::Value;
28use sha2::{Digest, Sha256};
29
30use crate::transcript::{ChatEntry, ChatRole, PlanStatus, ToolStatus, tool_call_presentation};
31use mj_core::archive::{
32    CanonicalExecutionState, CanonicalQueuedCommandKind, CanonicalQueuedPrompt,
33    CanonicalSessionSnapshot, CanonicalSessionState, CanonicalTerminalOutput,
34    CanonicalTranscriptBody, CanonicalTranscriptItem,
35};
36use mj_core::relay::{
37    RELAY_EVENT_GENESIS_DIGEST, RelayCommand, RelayCommandKind, RelayEvent, RelayObservation,
38    SequencedEvent, WorkerEvent, WorkerPhase, validate_relay_event,
39};
40use mj_core::state::{
41    MaterializedExecutionState, MaterializedQueuedPrompt, MaterializedSession, MaterializedTurn,
42    MaterializedTurnOutcome, QueuedCommandKind, TerminalOutputRecord, TranscriptBody,
43    TranscriptItem, TurnOutcomeKind, config_command_text, normalize_session_title,
44    provisional_session_title,
45};
46use mj_core::storage::{MaterializedSessionMutation, ProjectionIntegrityError, TranscriptMutation};
47use mj_core::transcript::{coalesce_content_chunks, push_content_chunk};
48
49#[derive(Debug, Clone, PartialEq)]
50pub struct ProjectedRelayEvent {
51    pub mutation: MaterializedSessionMutation,
52}
53
54/// Ephemeral lookup state for projecting a relay page. It is deliberately not
55/// part of the serialized session: the durable transcript remains canonical,
56/// while catch-up avoids rediscovering keyed items and open streams with a
57/// full transcript walk for every event.
58#[derive(Debug, Clone)]
59pub struct ProjectionIndex {
60    transcript: HashMap<String, Arc<TranscriptItem>>,
61    transcript_positions: HashMap<String, usize>,
62    open_agent_streams: BTreeSet<(u64, String)>,
63    open_thought_streams: BTreeSet<(u64, String)>,
64    terminal_referrers: HashMap<String, BTreeSet<String>>,
65}
66
67/// Explicit references that may address rows older than a live window. Keep
68/// identity interpretation beside the projector that consumes these events.
69pub fn historical_references(events: &[RelayEvent]) -> Result<(Vec<String>, Vec<String>)> {
70    let mut ids = BTreeSet::new();
71    let mut terminals = BTreeSet::new();
72    for event in events {
73        match &event.observation {
74            RelayObservation::SessionUpdate { update } => {
75                let value = match update.as_ref() {
76                    SessionUpdate::ToolCall(call) => {
77                        ids.insert(format!("tool:{}", call.tool_call_id));
78                        Some(serde_json::to_value(call)?)
79                    }
80                    SessionUpdate::ToolCallUpdate(update) => {
81                        ids.insert(format!("tool:{}", update.tool_call_id));
82                        Some(serde_json::to_value(&update.fields)?)
83                    }
84                    SessionUpdate::AgentMessageChunk(chunk) => {
85                        if let Some(id) = &chunk.message_id {
86                            ids.insert(format!("agent:{id}"));
87                        }
88                        None
89                    }
90                    SessionUpdate::AgentThoughtChunk(chunk) => {
91                        if let Some(id) = &chunk.message_id {
92                            ids.insert(format!("thought:{id}"));
93                        }
94                        None
95                    }
96                    _ => None,
97                };
98                if let Some(value) = value {
99                    terminals.extend(tool_call_terminal_ids(&value));
100                }
101            }
102            RelayObservation::TerminalOutput { terminal_id, .. } => {
103                terminals.insert(terminal_id.clone());
104            }
105            RelayObservation::CommandStarted { command_id, .. }
106            | RelayObservation::CommandCompleted { command_id, .. }
107            | RelayObservation::CommandRejected { command_id, .. }
108            | RelayObservation::CommandInterrupted { command_id, .. }
109            | RelayObservation::UserShellOutput { command_id, .. } => {
110                ids.insert(user_shell_item_id(command_id));
111                ids.insert(format!("user:{command_id}"));
112            }
113            RelayObservation::Notice { .. } => {
114                if let Some(id) = &event.command_id {
115                    ids.insert(format!("system:notice:{id}"));
116                }
117            }
118            RelayObservation::CommandQueued {
119                command:
120                    RelayCommand::DeliverMailboxEvent {
121                        event: mailbox_event,
122                    },
123                ..
124            } => {
125                if mailbox_event.message_text().is_none() {
126                    ids.insert(observation::MAILBOX_EVENTS_NOTICE_ID.to_owned());
127                }
128            }
129            RelayObservation::MailboxEventsDelivered {
130                event_keys,
131                events,
132                steering,
133                ..
134            } => {
135                ids.extend(
136                    event_keys
137                        .iter()
138                        .chain(events.iter().map(|event| &event.key))
139                        .map(|key| mj_core::transcript::message_item_id(key)),
140                );
141                if let Some(steering) = steering {
142                    ids.insert(format!("user:{}", steering.queued_prompt_id));
143                    ids.insert(format!("user:{}", steering.command_id));
144                }
145            }
146            RelayObservation::CommandQueued {
147                command: RelayCommand::MailboxWake { events },
148                ..
149            } => {
150                ids.extend(
151                    events
152                        .iter()
153                        .map(|event| mj_core::transcript::message_item_id(&event.key)),
154                );
155            }
156            _ => {}
157        }
158    }
159    for terminal in &terminals {
160        ids.insert(terminal_item_id(terminal));
161        ids.insert(fallback_terminal_tool_item_id(terminal));
162    }
163    Ok((ids.into_iter().collect(), terminals.into_iter().collect()))
164}
165
166impl ProjectionIndex {
167    pub fn new(current: &MaterializedSession) -> Self {
168        let mut index = Self {
169            transcript: HashMap::with_capacity(current.transcript.len()),
170            transcript_positions: HashMap::with_capacity(current.transcript.len()),
171            open_agent_streams: BTreeSet::new(),
172            open_thought_streams: BTreeSet::new(),
173            terminal_referrers: HashMap::new(),
174        };
175        for (position, item) in current.transcript.iter().enumerate() {
176            index.insert_at(item.clone(), position);
177        }
178        index
179    }
180
181    fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
182        self.transcript.get(stable_id)
183    }
184
185    fn position(&self, stable_id: &str) -> Option<usize> {
186        self.transcript_positions.get(stable_id).copied()
187    }
188
189    fn insert(&mut self, item: Arc<TranscriptItem>) {
190        let position = self
191            .remove(&item.stable_id)
192            .unwrap_or(self.transcript.len());
193        self.insert_at(item, position);
194    }
195
196    fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
197        let stream = (item.position, item.stable_id.clone());
198        match &item.body {
199            TranscriptBody::Agent {
200                streaming: true, ..
201            } => {
202                self.open_agent_streams.insert(stream);
203            }
204            TranscriptBody::Thought {
205                streaming: true, ..
206            } => {
207                self.open_thought_streams.insert(stream);
208            }
209            TranscriptBody::Tool {
210                call,
211                terminal_refs,
212                ..
213            } => {
214                let mut terminal_ids = tool_call_terminal_ids(call);
215                terminal_ids.extend(terminal_refs.iter().cloned());
216                for terminal_id in terminal_ids {
217                    self.terminal_referrers
218                        .entry(terminal_id)
219                        .or_default()
220                        .insert(item.stable_id.clone());
221                }
222            }
223            _ => {}
224        }
225        self.transcript_positions
226            .insert(item.stable_id.clone(), position);
227        self.transcript.insert(item.stable_id.clone(), item);
228    }
229
230    fn remove(&mut self, stable_id: &str) -> Option<usize> {
231        let item = self.transcript.remove(stable_id)?;
232        let position = self.transcript_positions.remove(stable_id);
233        debug_assert!(position.is_some());
234        let stream = (item.position, item.stable_id.clone());
235        self.open_agent_streams.remove(&stream);
236        self.open_thought_streams.remove(&stream);
237        if let TranscriptBody::Tool {
238            call,
239            terminal_refs,
240            ..
241        } = &item.body
242        {
243            let mut terminal_ids = tool_call_terminal_ids(call);
244            terminal_ids.extend(terminal_refs.iter().cloned());
245            for terminal_id in terminal_ids {
246                if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
247                    referrers.remove(stable_id);
248                    if referrers.is_empty() {
249                        self.terminal_referrers.remove(&terminal_id);
250                    }
251                }
252            }
253        }
254        position
255    }
256
257    fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
258        for (position, item) in transcript.iter().enumerate().skip(removed) {
259            self.transcript_positions
260                .insert(item.stable_id.clone(), position);
261        }
262    }
263
264    fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
265        let streams = if agent {
266            &self.open_agent_streams
267        } else {
268            &self.open_thought_streams
269        };
270        streams
271            .last()
272            .and_then(|(_, stable_id)| self.transcript.get(stable_id))
273    }
274
275    fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
276        let streams = if agent {
277            &self.open_agent_streams
278        } else {
279            &self.open_thought_streams
280        };
281        streams
282            .iter()
283            .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
284    }
285
286    fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
287        self.terminal_referrers
288            .get(terminal_id)
289            .into_iter()
290            .flatten()
291            .filter_map(|stable_id| self.transcript.get(stable_id))
292    }
293}
294
295/// Derive the minimal mutation for exactly the next relay event. This clones
296/// only logical items touched by the event; the actor-owned transcript is not
297/// copied.
298pub fn project_relay_event(
299    current: &MaterializedSession,
300    event: &RelayEvent,
301) -> Result<ProjectedRelayEvent> {
302    let index = ProjectionIndex::new(current);
303    project_relay_event_indexed(current, &index, event)
304}
305
306pub fn project_relay_event_indexed(
307    current: &MaterializedSession,
308    index: &ProjectionIndex,
309    event: &RelayEvent,
310) -> Result<ProjectedRelayEvent> {
311    validate_relay_event(
312        current.applied_event_ordinal,
313        &current.applied_event_digest,
314        event,
315    )?;
316
317    let mut mutation = MaterializedSessionMutation {
318        last_activity_at_ms: Some(event.recorded_at_ms),
319        ..MaterializedSessionMutation::default()
320    };
321    project_observation(current, index, event, &mut mutation)?;
322    mutation.api_events = api_events::derive(current, event, &mutation);
323    Ok(ProjectedRelayEvent { mutation })
324}
325
326/// Apply a mutation to the actor's sole in-memory projection after the same
327/// mutation and frontier have committed atomically in SQLite. The mutation is
328/// consumed so its committed values move into the projection instead of being
329/// copied a second time.
330pub fn apply_committed_projection_event(
331    current: &mut MaterializedSession,
332    event: &RelayEvent,
333    mutation: MaterializedSessionMutation,
334) -> Result<()> {
335    apply_committed_projection_event_inner(current, event, mutation, None)
336}
337
338pub fn apply_committed_projection_event_indexed(
339    current: &mut MaterializedSession,
340    index: &mut ProjectionIndex,
341    event: &RelayEvent,
342    mutation: MaterializedSessionMutation,
343) -> Result<()> {
344    apply_committed_projection_event_inner(current, event, mutation, Some(index))
345}
346
347fn apply_committed_projection_event_inner(
348    current: &mut MaterializedSession,
349    event: &RelayEvent,
350    mutation: MaterializedSessionMutation,
351    mut index: Option<&mut ProjectionIndex>,
352) -> Result<()> {
353    validate_relay_event(
354        current.applied_event_ordinal,
355        &current.applied_event_digest,
356        event,
357    )?;
358    if let Some(execution) = mutation.execution {
359        current.execution = execution;
360    }
361    if let Some(title) = mutation.session_title {
362        current.session_title = title;
363    }
364    if let Some(configuration) = mutation.configuration {
365        current.configuration = configuration;
366    }
367    for item_mutation in mutation.transcript {
368        match item_mutation {
369            TranscriptMutation::Upsert(item) => {
370                item.validate(event.ordinal)?;
371                let existing_position = index
372                    .as_deref()
373                    .and_then(|index| index.position(&item.stable_id));
374                let existing = if let Some(position) = existing_position {
375                    Some(current.transcript.get_mut(position).with_context(|| {
376                        format!(
377                            "transcript index position {position} for {:?} is out of bounds",
378                            item.stable_id
379                        )
380                    })?)
381                } else if index.is_none() {
382                    current
383                        .transcript
384                        .iter_mut()
385                        .find(|existing| existing.stable_id == item.stable_id)
386                } else {
387                    None
388                };
389                if let Some(existing) = existing {
390                    if existing.stable_id != item.stable_id {
391                        return Err(ProjectionIntegrityError(format!(
392                            "transcript index for {:?} points to {:?}",
393                            item.stable_id, existing.stable_id
394                        ))
395                        .into());
396                    }
397                    if existing.position != item.position
398                        || existing.created_at_ms != item.created_at_ms
399                    {
400                        return Err(ProjectionIntegrityError(format!(
401                            "transcript item {:?} changed immutable identity fields",
402                            item.stable_id
403                        ))
404                        .into());
405                    }
406                    if item.last_changed_at_ms < existing.last_changed_at_ms {
407                        return Err(ProjectionIntegrityError(format!(
408                            "transcript item {:?} moved its changed timestamp backwards",
409                            item.stable_id
410                        ))
411                        .into());
412                    }
413                    if existing
414                        .latest_content_event_ordinal
415                        .is_some_and(|existing| {
416                            item.latest_content_event_ordinal
417                                .is_none_or(|next| next < existing)
418                        })
419                    {
420                        return Err(ProjectionIntegrityError(format!(
421                            "transcript item {:?} moved its latest content ordinal backwards",
422                            item.stable_id
423                        ))
424                        .into());
425                    }
426                    // Reuse the item in place when no published snapshot shares
427                    // it; otherwise publish a fresh item so snapshots taken
428                    // earlier keep the value they were given.
429                    if let Some(owned) = Arc::get_mut(existing) {
430                        *owned = item;
431                    } else {
432                        *existing = Arc::new(item);
433                    }
434                    if let Some(index) = index.as_deref_mut() {
435                        index.insert(existing.clone());
436                    }
437                } else {
438                    let item = Arc::new(item);
439                    if let Some(index) = index.as_deref_mut() {
440                        index.insert(item.clone());
441                    }
442                    current.transcript.push(item);
443                }
444            }
445            TranscriptMutation::Remove { stable_id } => {
446                if let Some(index) = index.as_deref_mut() {
447                    if let Some(position) = index.remove(&stable_id) {
448                        let removed = current.transcript.remove(position);
449                        if removed.stable_id != stable_id {
450                            return Err(ProjectionIntegrityError(format!(
451                                "transcript index for {stable_id:?} removed {:?}",
452                                removed.stable_id
453                            ))
454                            .into());
455                        }
456                        index.reindex_after_removal(&current.transcript, position);
457                    }
458                } else {
459                    current
460                        .transcript
461                        .retain(|item| item.stable_id != stable_id);
462                }
463            }
464        }
465    }
466    if let Some(queued_prompts) = mutation.queued_prompts {
467        current.queued_prompts = queued_prompts;
468    }
469    if let Some(pending_elicitations) = mutation.pending_elicitations {
470        current.pending_elicitations = pending_elicitations;
471    }
472    if let Some(active_turn) = mutation.active_turn {
473        current.active_turn = active_turn;
474    }
475    if mutation.clear_turn_outcome {
476        current.last_turn_outcome = None;
477    }
478    if let Some(last_turn_outcome) = mutation.last_turn_outcome {
479        current.last_turn_outcome = Some(last_turn_outcome);
480    }
481    if let Some(activity) = mutation.last_activity_at_ms {
482        current.last_activity_at_ms = Some(
483            current
484                .last_activity_at_ms
485                .map_or(activity, |existing| existing.max(activity)),
486        );
487    }
488    current.applied_event_ordinal = event.ordinal;
489    current.applied_event_digest.clone_from(&event.digest);
490    Ok(())
491}
492
493#[cfg(test)]
494mod tests;
495
496/// Project a child-addressed update using the same transcript rules as a root.
497/// The owning relay has already validated ordering; child events are a sparse
498/// subsequence of that relay, so their local frontier may skip owner ordinals.
499pub fn project_native_update(
500    current: &MaterializedSession,
501    event: &RelayEvent,
502    update: &agent_client_protocol::schema::v1::SessionUpdate,
503) -> Result<MaterializedSessionMutation> {
504    let mut mutation = MaterializedSessionMutation {
505        last_activity_at_ms: Some(event.recorded_at_ms),
506        ..Default::default()
507    };
508    if let agent_client_protocol::schema::v1::SessionUpdate::UserMessageChunk(chunk) = update {
509        mutation
510            .transcript
511            .push(TranscriptMutation::Upsert(TranscriptItem {
512                stable_id: format!("native-user:{}", event.ordinal),
513                position: event.ordinal,
514                latest_content_event_ordinal: None,
515                created_at_ms: event.recorded_at_ms,
516                last_changed_at_ms: event.recorded_at_ms,
517                body: TranscriptBody::User {
518                    content: vec![serde_json::to_value(&chunk.content)?],
519                },
520            }));
521        return Ok(mutation);
522    }
523    let index = ProjectionIndex::new(current);
524    session_update::project_session_update(current, &index, event, update, &mut mutation)?;
525    Ok(mutation)
526}
527
528pub(crate) use terminals::tool_call_terminal_ids;