Skip to main content

aion_server/stream/
transcript_stream.rs

1//! NOI-5b transcript subscription: namespace-gated durable-tail + live-splice
2//! forward loop for one `(workflow, run, activity, attempt)` agent transcript.
3//!
4//! This is the agent-observability counterpart to [`super::cluster_stream`]'s
5//! forward loop, and a NEW ARM on the existing single subscription frame of
6//! `/events/stream` (the socket stays one-subscription-per-socket; there is no
7//! multiplexing layer). A client that wants both a workflow stream and a
8//! transcript opens two `/events/stream` sockets.
9//!
10//! # Authorization: namespace-scoped (like the per-workflow event stream)
11//!
12//! A transcript belongs to the workflow the activity runs under, so it is
13//! authorized exactly like the per-workflow event subscription: the caller must
14//! hold a grant for the transcript's `namespace` AND the target `workflow_id`
15//! must be visible in it. The gate reuses the SAME
16//! [`NamespaceGuard::scope`](crate::namespace::NamespaceGuard::scope) +
17//! [`SubscriptionScope::PerWorkflow`](crate::namespace::SubscriptionScope) path
18//! the workflow stream uses, so a caller probing a foreign or nonexistent
19//! workflow receives the guard's anti-leak `not_found`, never a transcript.
20//!
21//! # Splice contract (gap-free, no duplicate)
22//!
23//! Mirrors the workflow resume path: attach the live broadcast BEFORE reading the
24//! durable `O` tail (subscribe-then-replay), so an event that races the priming
25//! read is retained by the receiver and applied after it, deduped on `store_seq`.
26//! Ephemeral token deltas (`store_seq: None`) are forwarded live and never
27//! replayed.
28
29use aion_core::{ActivityId, RunId};
30use aion_integrations::envelope_delta::EnvelopeDeltaDecoder;
31use aion_proto::{
32    PerWorkflowSubscription, ProtoRunId, ProtoWorkflowId, StreamedActivityEvent,
33    TranscriptSubscription, WireError,
34};
35use aion_store::ActivityStreamKey;
36use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
37use futures::{SinkExt, StreamExt};
38
39use crate::activity_publisher::TranscriptStreamLagged;
40use crate::error::ServerError;
41use crate::namespace::{CallerIdentity, NamespaceOperation, SubscriptionScope, WorkflowTarget};
42use crate::state::ServerState;
43
44/// Serve a transcript subscription on an already-upgraded socket.
45///
46/// Flow: decode + namespace-gate FIRST (anti-leak: a denied caller receives one
47/// terminal wire-error frame + close, byte-identical to the workflow path); then
48/// attach the live broadcast BEFORE reading the durable `O` tail (gap-free
49/// splice); replay the durable tail; forward live events until the client closes
50/// or the subscriber lags (one typed `transcript_lagged` frame then close).
51///
52/// # Errors
53///
54/// Returns [`ServerError`] when decode/authorization fails (after the terminal
55/// frame is sent), the durable replay read fails, or the stream ends with a lag
56/// terminal frame.
57pub async fn serve_transcript_socket(
58    mut socket: WebSocket,
59    state: &ServerState,
60    caller: &CallerIdentity,
61    subscription: &TranscriptSubscription,
62) -> Result<(), ServerError> {
63    let key = match authorize_transcript(state, caller, subscription).await {
64        Ok(key) => key,
65        Err(error) => {
66            super::socket::send_wire_error(&mut socket, &error.to_wire_error()).await?;
67            return Err(error);
68        }
69    };
70
71    let publisher = state.transcript_publisher();
72    // T0: attach the live tail BEFORE reading the durable replay, so an event
73    // emitted between the replay read and the first live poll is retained by the
74    // receiver and applied after the replay (deduped on `store_seq`).
75    let mut live = publisher.subscribe(key.clone(), subscription.after_seq);
76
77    // T1 (> T0): replay the durable `O` tail from the resume cursor. `after_seq`
78    // is the highest already-applied `store_seq`; replay everything strictly
79    // after it (`None` replays the full transcript from `store_seq == 0`).
80    let from_seq = subscription
81        .after_seq
82        .map_or(0, |seq| seq.saturating_add(1));
83    let replay = publisher
84        .replay_from(&key, from_seq)
85        .await
86        .map_err(ServerError::from)?;
87    // One decoder for the life of the socket, spanning BOTH the durable replay and the live
88    // splice: a turn whose base frame arrives during replay must still resolve the deltas that
89    // arrive live afterwards. Resetting it at the splice would break every turn straddling it.
90    let mut envelopes = EnvelopeDeltaDecoder::new();
91    for mut record in replay {
92        resolve_frame(&mut envelopes, &mut record.event);
93        if send_activity_frame(&mut socket, record.event)
94            .await?
95            .is_break()
96        {
97            return Ok(());
98        }
99    }
100
101    let (mut socket_tx, mut socket_rx) = socket.split();
102    loop {
103        tokio::select! {
104            client_message = socket_rx.next() => {
105                match client_message {
106                    // Close, socket error, or any inbound frame ends the read
107                    // side: the transcript channel takes no further client
108                    // frames (one-subscription-per-socket), so an inbound frame
109                    // after subscribe is a benign close.
110                    Some(Ok(Message::Close(_))) | None => {
111                        return send_normal_close(&mut socket_tx).await;
112                    }
113                    Some(Ok(_other)) => {}
114                    Some(Err(_error)) => return Ok(()),
115                }
116            }
117            item = live.next() => {
118                match item {
119                    Some(Ok(mut event)) => {
120                        resolve_frame(&mut envelopes, &mut event);
121                        if forward_live_frame(&mut socket_tx, event).await?.is_break() {
122                            return Ok(());
123                        }
124                    }
125                    Some(Err(TranscriptStreamLagged { skipped })) => {
126                        return deliver_transcript_terminal(&mut socket_tx, skipped).await;
127                    }
128                    None => return send_normal_close(&mut socket_tx).await,
129                }
130            }
131        }
132    }
133}
134
135/// Reconstruct one frame's compacted provider envelope, recording a delta this socket cannot
136/// resolve.
137///
138/// A socket that resumes from a cursor inside a turn genuinely lacks that turn's base; the frame
139/// is forwarded with its delta document intact and the fact is noted, never guessed at.
140fn resolve_frame(envelopes: &mut EnvelopeDeltaDecoder, event: &mut aion_core::ActivityEvent) {
141    if let Some(report) = crate::transcript_resolve::resolve_event(envelopes, event) {
142        crate::transcript_resolve::note_unresolved("ws:transcript", std::slice::from_ref(&report));
143    }
144}
145
146/// Decode the transcript identifiers and namespace-gate the caller, returning the
147/// authorized `(workflow, run, activity, attempt)` stream key.
148///
149/// Reuses the per-workflow subscription scope so authorization is byte-identical
150/// to the workflow event stream: the caller must hold the namespace grant AND the
151/// workflow must be visible in it (anti-leak `not_found` otherwise).
152async fn authorize_transcript(
153    state: &ServerState,
154    caller: &CallerIdentity,
155    subscription: &TranscriptSubscription,
156) -> Result<ActivityStreamKey, ServerError> {
157    let workflow_id = decode_workflow_id(subscription.workflow_id.as_ref())?;
158    let run_id = decode_run_id(subscription.run_id.as_ref())?;
159    let activity_id = decode_activity_id(subscription)?;
160    gate_transcript_workflow(state, caller, &subscription.namespace, &workflow_id).await?;
161    Ok(ActivityStreamKey::new(
162        workflow_id,
163        run_id,
164        activity_id,
165        subscription.attempt,
166    ))
167}
168
169/// Per-workflow transcript gate shared by the WS subscription and the REST
170/// fetch/enumeration endpoints — byte-identical to the workflow event
171/// subscription's authorization: the caller must hold a grant for `namespace`
172/// AND `workflow_id` must be visible in it (anti-leak `not_found` otherwise).
173pub(crate) async fn gate_transcript_workflow(
174    state: &ServerState,
175    caller: &CallerIdentity,
176    namespace: &str,
177    workflow_id: &aion_core::WorkflowId,
178) -> Result<(), ServerError> {
179    let per_workflow = PerWorkflowSubscription {
180        namespace: namespace.to_owned(),
181        workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
182        resume_from_seq: None,
183    };
184    let target = WorkflowTarget::workflow(workflow_id);
185    let scope = SubscriptionScope::PerWorkflow(&per_workflow, target);
186    let filter = aion::EventFilter {
187        workflow_id: Some(workflow_id.clone()),
188        ..aion::EventFilter::default()
189    };
190    let operation = NamespaceOperation::subscribe(scope, &filter);
191    // Guard verdict FIRST: nothing runs for an unauthorized caller. The scoped
192    // engine handle is not needed here — transcripts read the `O` keyspace
193    // through the publisher, never the engine.
194    let scoped = state.namespace_guard().scope(caller, &operation).await?;
195    drop(scoped);
196    Ok(())
197}
198
199fn decode_workflow_id(
200    workflow_id: Option<&ProtoWorkflowId>,
201) -> Result<aion_core::WorkflowId, ServerError> {
202    workflow_id
203        .cloned()
204        .ok_or_else(|| ServerError::Wire {
205            wire: WireError::invalid_input("transcript subscription workflow_id is missing"),
206        })?
207        .try_into()
208        .map_err(|wire| ServerError::Wire { wire })
209}
210
211/// Decode the REQUIRED run axis of a transcript subscription.
212///
213/// An absent `run_id` is `invalid_input`, never a defaulted "latest run": the
214/// transcript keyspace is run-scoped, and silently resolving a generation the
215/// caller did not name is the ambiguity the axis exists to remove.
216fn decode_run_id(run_id: Option<&ProtoRunId>) -> Result<RunId, ServerError> {
217    run_id
218        .cloned()
219        .ok_or_else(|| ServerError::Wire {
220            wire: WireError::invalid_input("transcript subscription run_id is missing"),
221        })?
222        .try_into()
223        .map_err(|wire| ServerError::Wire { wire })
224}
225
226fn decode_activity_id(subscription: &TranscriptSubscription) -> Result<ActivityId, ServerError> {
227    let activity_id = subscription.activity_id.ok_or_else(|| ServerError::Wire {
228        wire: WireError::invalid_input("transcript subscription activity_id is missing"),
229    })?;
230    Ok(ActivityId::from(activity_id))
231}
232
233/// Serialize + send one transcript event on the still-unified socket (during the
234/// durable replay, before the read/write split). A send failure means the client
235/// is gone: signal a clean end.
236async fn send_activity_frame(
237    socket: &mut WebSocket,
238    event: aion_core::ActivityEvent,
239) -> Result<std::ops::ControlFlow<()>, ServerError> {
240    let frame = encode_activity_frame(&event)?;
241    if socket.send(Message::Text(frame.into())).await.is_err() {
242        return Ok(std::ops::ControlFlow::Break(()));
243    }
244    Ok(std::ops::ControlFlow::Continue(()))
245}
246
247/// Serialize + send one live transcript event on the write half of the split
248/// socket. A serialize failure sends a terminal wire-error frame and surfaces the
249/// error; a send failure is a benign client-gone end.
250async fn forward_live_frame<Tx>(
251    socket_tx: &mut Tx,
252    event: aion_core::ActivityEvent,
253) -> Result<std::ops::ControlFlow<()>, ServerError>
254where
255    Tx: futures::Sink<Message> + Unpin,
256    <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
257{
258    let frame = match encode_activity_frame(&event) {
259        Ok(frame) => frame,
260        Err(error) => {
261            super::socket::send_wire_error(socket_tx, &error.to_wire_error()).await?;
262            return Err(error);
263        }
264    };
265    if socket_tx.send(Message::Text(frame.into())).await.is_err() {
266        return Ok(std::ops::ControlFlow::Break(()));
267    }
268    Ok(std::ops::ControlFlow::Continue(()))
269}
270
271fn encode_activity_frame(event: &aion_core::ActivityEvent) -> Result<String, ServerError> {
272    let frame = StreamedActivityEvent::new(event.clone());
273    serde_json::to_string(&frame).map_err(|source| ServerError::Wire {
274        wire: WireError::backend(format!(
275            "failed to serialize transcript event frame: {source}"
276        )),
277    })
278}
279
280/// Send the typed `transcript_lagged` terminal frame + close, then surface it
281/// typed — the client re-resumes from the durable `O` tail by `store_seq`.
282async fn deliver_transcript_terminal<Tx>(
283    socket_tx: &mut Tx,
284    skipped: u64,
285) -> Result<(), ServerError>
286where
287    Tx: futures::Sink<Message> + Unpin,
288    <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
289{
290    let payload = serde_json::json!({
291        "error": { "code": "transcript_lagged", "skipped": skipped },
292    });
293    let payload = serde_json::to_string(&payload).map_err(|source| ServerError::Wire {
294        wire: WireError::backend(format!(
295            "failed to serialize transcript lag frame: {source}"
296        )),
297    })?;
298    if socket_tx.send(Message::Text(payload.into())).await.is_ok() {
299        let close = CloseFrame {
300            code: close_code::ERROR,
301            reason: "transcript_lagged".into(),
302        };
303        let close_result = socket_tx.send(Message::Close(Some(close))).await;
304        drop(close_result);
305    }
306    Err(ServerError::lagged_stream())
307}
308
309/// Finish a graceful transcript subscription end with a close-1000 frame.
310async fn send_normal_close<Tx>(socket_tx: &mut Tx) -> Result<(), ServerError>
311where
312    Tx: futures::Sink<Message> + Unpin,
313    <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
314{
315    let close = CloseFrame {
316        code: close_code::NORMAL,
317        reason: "subscription complete".into(),
318    };
319    let close_result = socket_tx.send(Message::Close(Some(close))).await;
320    drop(close_result);
321    Ok(())
322}