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