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