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: RelayCommand::DeliverMailboxEvent { .. },
120                ..
121            } => {
122                ids.insert(observation::MAILBOX_EVENTS_NOTICE_ID.to_owned());
123            }
124            _ => {}
125        }
126    }
127    for terminal in &terminals {
128        ids.insert(terminal_item_id(terminal));
129        ids.insert(fallback_terminal_tool_item_id(terminal));
130    }
131    Ok((ids.into_iter().collect(), terminals.into_iter().collect()))
132}
133
134impl ProjectionIndex {
135    pub fn new(current: &MaterializedSession) -> Self {
136        let mut index = Self {
137            transcript: HashMap::with_capacity(current.transcript.len()),
138            transcript_positions: HashMap::with_capacity(current.transcript.len()),
139            open_agent_streams: BTreeSet::new(),
140            open_thought_streams: BTreeSet::new(),
141            terminal_referrers: HashMap::new(),
142        };
143        for (position, item) in current.transcript.iter().enumerate() {
144            index.insert_at(item.clone(), position);
145        }
146        index
147    }
148
149    fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
150        self.transcript.get(stable_id)
151    }
152
153    fn position(&self, stable_id: &str) -> Option<usize> {
154        self.transcript_positions.get(stable_id).copied()
155    }
156
157    fn insert(&mut self, item: Arc<TranscriptItem>) {
158        let position = self
159            .remove(&item.stable_id)
160            .unwrap_or(self.transcript.len());
161        self.insert_at(item, position);
162    }
163
164    fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
165        let stream = (item.position, item.stable_id.clone());
166        match &item.body {
167            TranscriptBody::Agent {
168                streaming: true, ..
169            } => {
170                self.open_agent_streams.insert(stream);
171            }
172            TranscriptBody::Thought {
173                streaming: true, ..
174            } => {
175                self.open_thought_streams.insert(stream);
176            }
177            TranscriptBody::Tool {
178                call,
179                terminal_refs,
180                ..
181            } => {
182                let mut terminal_ids = tool_call_terminal_ids(call);
183                terminal_ids.extend(terminal_refs.iter().cloned());
184                for terminal_id in terminal_ids {
185                    self.terminal_referrers
186                        .entry(terminal_id)
187                        .or_default()
188                        .insert(item.stable_id.clone());
189                }
190            }
191            _ => {}
192        }
193        self.transcript_positions
194            .insert(item.stable_id.clone(), position);
195        self.transcript.insert(item.stable_id.clone(), item);
196    }
197
198    fn remove(&mut self, stable_id: &str) -> Option<usize> {
199        let item = self.transcript.remove(stable_id)?;
200        let position = self.transcript_positions.remove(stable_id);
201        debug_assert!(position.is_some());
202        let stream = (item.position, item.stable_id.clone());
203        self.open_agent_streams.remove(&stream);
204        self.open_thought_streams.remove(&stream);
205        if let TranscriptBody::Tool {
206            call,
207            terminal_refs,
208            ..
209        } = &item.body
210        {
211            let mut terminal_ids = tool_call_terminal_ids(call);
212            terminal_ids.extend(terminal_refs.iter().cloned());
213            for terminal_id in terminal_ids {
214                if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
215                    referrers.remove(stable_id);
216                    if referrers.is_empty() {
217                        self.terminal_referrers.remove(&terminal_id);
218                    }
219                }
220            }
221        }
222        position
223    }
224
225    fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
226        for (position, item) in transcript.iter().enumerate().skip(removed) {
227            self.transcript_positions
228                .insert(item.stable_id.clone(), position);
229        }
230    }
231
232    fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
233        let streams = if agent {
234            &self.open_agent_streams
235        } else {
236            &self.open_thought_streams
237        };
238        streams
239            .last()
240            .and_then(|(_, stable_id)| self.transcript.get(stable_id))
241    }
242
243    fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
244        let streams = if agent {
245            &self.open_agent_streams
246        } else {
247            &self.open_thought_streams
248        };
249        streams
250            .iter()
251            .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
252    }
253
254    fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
255        self.terminal_referrers
256            .get(terminal_id)
257            .into_iter()
258            .flatten()
259            .filter_map(|stable_id| self.transcript.get(stable_id))
260    }
261}
262
263/// Derive the minimal mutation for exactly the next relay event. This clones
264/// only logical items touched by the event; the actor-owned transcript is not
265/// copied.
266pub fn project_relay_event(
267    current: &MaterializedSession,
268    event: &RelayEvent,
269) -> Result<ProjectedRelayEvent> {
270    let index = ProjectionIndex::new(current);
271    project_relay_event_indexed(current, &index, event)
272}
273
274pub fn project_relay_event_indexed(
275    current: &MaterializedSession,
276    index: &ProjectionIndex,
277    event: &RelayEvent,
278) -> Result<ProjectedRelayEvent> {
279    validate_relay_event(
280        current.applied_event_ordinal,
281        &current.applied_event_digest,
282        event,
283    )?;
284
285    let mut mutation = MaterializedSessionMutation {
286        last_activity_at_ms: Some(event.recorded_at_ms),
287        ..MaterializedSessionMutation::default()
288    };
289    project_observation(current, index, event, &mut mutation)?;
290    mutation.api_events = api_events::derive(current, event, &mutation);
291    Ok(ProjectedRelayEvent { mutation })
292}
293
294/// Apply a mutation to the actor's sole in-memory projection after the same
295/// mutation and frontier have committed atomically in SQLite. The mutation is
296/// consumed so its committed values move into the projection instead of being
297/// copied a second time.
298pub fn apply_committed_projection_event(
299    current: &mut MaterializedSession,
300    event: &RelayEvent,
301    mutation: MaterializedSessionMutation,
302) -> Result<()> {
303    apply_committed_projection_event_inner(current, event, mutation, None)
304}
305
306pub fn apply_committed_projection_event_indexed(
307    current: &mut MaterializedSession,
308    index: &mut ProjectionIndex,
309    event: &RelayEvent,
310    mutation: MaterializedSessionMutation,
311) -> Result<()> {
312    apply_committed_projection_event_inner(current, event, mutation, Some(index))
313}
314
315fn apply_committed_projection_event_inner(
316    current: &mut MaterializedSession,
317    event: &RelayEvent,
318    mutation: MaterializedSessionMutation,
319    mut index: Option<&mut ProjectionIndex>,
320) -> Result<()> {
321    validate_relay_event(
322        current.applied_event_ordinal,
323        &current.applied_event_digest,
324        event,
325    )?;
326    if let Some(execution) = mutation.execution {
327        current.execution = execution;
328    }
329    if let Some(title) = mutation.session_title {
330        current.session_title = title;
331    }
332    if let Some(configuration) = mutation.configuration {
333        current.configuration = configuration;
334    }
335    for item_mutation in mutation.transcript {
336        match item_mutation {
337            TranscriptMutation::Upsert(item) => {
338                item.validate(event.ordinal)?;
339                let existing_position = index
340                    .as_deref()
341                    .and_then(|index| index.position(&item.stable_id));
342                let existing = if let Some(position) = existing_position {
343                    Some(current.transcript.get_mut(position).with_context(|| {
344                        format!(
345                            "transcript index position {position} for {:?} is out of bounds",
346                            item.stable_id
347                        )
348                    })?)
349                } else if index.is_none() {
350                    current
351                        .transcript
352                        .iter_mut()
353                        .find(|existing| existing.stable_id == item.stable_id)
354                } else {
355                    None
356                };
357                if let Some(existing) = existing {
358                    if existing.stable_id != item.stable_id {
359                        return Err(ProjectionIntegrityError(format!(
360                            "transcript index for {:?} points to {:?}",
361                            item.stable_id, existing.stable_id
362                        ))
363                        .into());
364                    }
365                    if existing.position != item.position
366                        || existing.created_at_ms != item.created_at_ms
367                    {
368                        return Err(ProjectionIntegrityError(format!(
369                            "transcript item {:?} changed immutable identity fields",
370                            item.stable_id
371                        ))
372                        .into());
373                    }
374                    if item.last_changed_at_ms < existing.last_changed_at_ms {
375                        return Err(ProjectionIntegrityError(format!(
376                            "transcript item {:?} moved its changed timestamp backwards",
377                            item.stable_id
378                        ))
379                        .into());
380                    }
381                    if existing
382                        .latest_content_event_ordinal
383                        .is_some_and(|existing| {
384                            item.latest_content_event_ordinal
385                                .is_none_or(|next| next < existing)
386                        })
387                    {
388                        return Err(ProjectionIntegrityError(format!(
389                            "transcript item {:?} moved its latest content ordinal backwards",
390                            item.stable_id
391                        ))
392                        .into());
393                    }
394                    // Reuse the item in place when no published snapshot shares
395                    // it; otherwise publish a fresh item so snapshots taken
396                    // earlier keep the value they were given.
397                    if let Some(owned) = Arc::get_mut(existing) {
398                        *owned = item;
399                    } else {
400                        *existing = Arc::new(item);
401                    }
402                    if let Some(index) = index.as_deref_mut() {
403                        index.insert(existing.clone());
404                    }
405                } else {
406                    let item = Arc::new(item);
407                    if let Some(index) = index.as_deref_mut() {
408                        index.insert(item.clone());
409                    }
410                    current.transcript.push(item);
411                }
412            }
413            TranscriptMutation::Remove { stable_id } => {
414                if let Some(index) = index.as_deref_mut() {
415                    if let Some(position) = index.remove(&stable_id) {
416                        let removed = current.transcript.remove(position);
417                        if removed.stable_id != stable_id {
418                            return Err(ProjectionIntegrityError(format!(
419                                "transcript index for {stable_id:?} removed {:?}",
420                                removed.stable_id
421                            ))
422                            .into());
423                        }
424                        index.reindex_after_removal(&current.transcript, position);
425                    }
426                } else {
427                    current
428                        .transcript
429                        .retain(|item| item.stable_id != stable_id);
430                }
431            }
432        }
433    }
434    if let Some(queued_prompts) = mutation.queued_prompts {
435        current.queued_prompts = queued_prompts;
436    }
437    if let Some(pending_elicitations) = mutation.pending_elicitations {
438        current.pending_elicitations = pending_elicitations;
439    }
440    if let Some(active_turn) = mutation.active_turn {
441        current.active_turn = active_turn;
442    }
443    if mutation.clear_turn_outcome {
444        current.last_turn_outcome = None;
445    }
446    if let Some(last_turn_outcome) = mutation.last_turn_outcome {
447        current.last_turn_outcome = Some(last_turn_outcome);
448    }
449    if let Some(activity) = mutation.last_activity_at_ms {
450        current.last_activity_at_ms = Some(
451            current
452                .last_activity_at_ms
453                .map_or(activity, |existing| existing.max(activity)),
454        );
455    }
456    current.applied_event_ordinal = event.ordinal;
457    current.applied_event_digest.clone_from(&event.digest);
458    Ok(())
459}
460
461#[cfg(test)]
462mod tests;
463
464/// Project a child-addressed update using the same transcript rules as a root.
465/// The owning relay has already validated ordering; child events are a sparse
466/// subsequence of that relay, so their local frontier may skip owner ordinals.
467pub fn project_native_update(
468    current: &MaterializedSession,
469    event: &RelayEvent,
470    update: &agent_client_protocol::schema::v1::SessionUpdate,
471) -> Result<MaterializedSessionMutation> {
472    let mut mutation = MaterializedSessionMutation {
473        last_activity_at_ms: Some(event.recorded_at_ms),
474        ..Default::default()
475    };
476    if let agent_client_protocol::schema::v1::SessionUpdate::UserMessageChunk(chunk) = update {
477        mutation
478            .transcript
479            .push(TranscriptMutation::Upsert(TranscriptItem {
480                stable_id: format!("native-user:{}", event.ordinal),
481                position: event.ordinal,
482                latest_content_event_ordinal: None,
483                created_at_ms: event.recorded_at_ms,
484                last_changed_at_ms: event.recorded_at_ms,
485                body: TranscriptBody::User {
486                    content: vec![serde_json::to_value(&chunk.content)?],
487                },
488            }));
489        return Ok(mutation);
490    }
491    let index = ProjectionIndex::new(current);
492    session_update::project_session_update(current, &index, event, update, &mut mutation)?;
493    Ok(mutation)
494}
495
496pub(crate) use terminals::tool_call_terminal_ids;