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, 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;
30use aion_proto::{
31    PerWorkflowSubscription, ProtoWorkflowId, StreamedActivityEvent, TranscriptSubscription,
32    WireError,
33};
34use aion_store::ActivityStreamKey;
35use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
36use futures::{SinkExt, StreamExt};
37
38use crate::activity_publisher::TranscriptStreamLagged;
39use crate::error::ServerError;
40use crate::namespace::{CallerIdentity, NamespaceOperation, SubscriptionScope, WorkflowTarget};
41use crate::state::ServerState;
42
43/// Serve a transcript subscription on an already-upgraded socket.
44///
45/// Flow: decode + namespace-gate FIRST (anti-leak: a denied caller receives one
46/// terminal wire-error frame + close, byte-identical to the workflow path); then
47/// attach the live broadcast BEFORE reading the durable `O` tail (gap-free
48/// splice); replay the durable tail; forward live events until the client closes
49/// or the subscriber lags (one typed `transcript_lagged` frame then close).
50///
51/// # Errors
52///
53/// Returns [`ServerError`] when decode/authorization fails (after the terminal
54/// frame is sent), the durable replay read fails, or the stream ends with a lag
55/// terminal frame.
56pub async fn serve_transcript_socket(
57    mut socket: WebSocket,
58    state: &ServerState,
59    caller: &CallerIdentity,
60    subscription: &TranscriptSubscription,
61) -> Result<(), ServerError> {
62    let key = match authorize_transcript(state, caller, subscription).await {
63        Ok(key) => key,
64        Err(error) => {
65            super::socket::send_wire_error(&mut socket, &error.to_wire_error()).await?;
66            return Err(error);
67        }
68    };
69
70    let publisher = state.transcript_publisher();
71    // T0: attach the live tail BEFORE reading the durable replay, so an event
72    // emitted between the replay read and the first live poll is retained by the
73    // receiver and applied after the replay (deduped on `store_seq`).
74    let mut live = publisher.subscribe(key.clone(), subscription.after_seq);
75
76    // T1 (> T0): replay the durable `O` tail from the resume cursor. `after_seq`
77    // is the highest already-applied `store_seq`; replay everything strictly
78    // after it (`None` replays the full transcript from `store_seq == 0`).
79    let from_seq = subscription
80        .after_seq
81        .map_or(0, |seq| seq.saturating_add(1));
82    let replay = publisher
83        .replay_from(&key, from_seq)
84        .await
85        .map_err(ServerError::from)?;
86    for record in replay {
87        if send_activity_frame(&mut socket, record.event)
88            .await?
89            .is_break()
90        {
91            return Ok(());
92        }
93    }
94
95    let (mut socket_tx, mut socket_rx) = socket.split();
96    loop {
97        tokio::select! {
98            client_message = socket_rx.next() => {
99                match client_message {
100                    // Close, socket error, or any inbound frame ends the read
101                    // side: the transcript channel takes no further client
102                    // frames (one-subscription-per-socket), so an inbound frame
103                    // after subscribe is a benign close.
104                    Some(Ok(Message::Close(_))) | None => {
105                        return send_normal_close(&mut socket_tx).await;
106                    }
107                    Some(Ok(_other)) => {}
108                    Some(Err(_error)) => return Ok(()),
109                }
110            }
111            item = live.next() => {
112                match item {
113                    Some(Ok(event)) => {
114                        if forward_live_frame(&mut socket_tx, event).await?.is_break() {
115                            return Ok(());
116                        }
117                    }
118                    Some(Err(TranscriptStreamLagged { skipped })) => {
119                        return deliver_transcript_terminal(&mut socket_tx, skipped).await;
120                    }
121                    None => return send_normal_close(&mut socket_tx).await,
122                }
123            }
124        }
125    }
126}
127
128/// Decode the transcript identifiers and namespace-gate the caller, returning the
129/// authorized `(workflow, activity, attempt)` stream key.
130///
131/// Reuses the per-workflow subscription scope so authorization is byte-identical
132/// to the workflow event stream: the caller must hold the namespace grant AND the
133/// workflow must be visible in it (anti-leak `not_found` otherwise).
134async fn authorize_transcript(
135    state: &ServerState,
136    caller: &CallerIdentity,
137    subscription: &TranscriptSubscription,
138) -> Result<ActivityStreamKey, ServerError> {
139    let workflow_id = decode_workflow_id(subscription.workflow_id.as_ref())?;
140    let activity_id = decode_activity_id(subscription)?;
141    gate_transcript_workflow(state, caller, &subscription.namespace, &workflow_id).await?;
142    Ok(ActivityStreamKey::new(
143        workflow_id,
144        activity_id,
145        subscription.attempt,
146    ))
147}
148
149/// Per-workflow transcript gate shared by the WS subscription and the REST
150/// fetch/enumeration endpoints — byte-identical to the workflow event
151/// subscription's authorization: the caller must hold a grant for `namespace`
152/// AND `workflow_id` must be visible in it (anti-leak `not_found` otherwise).
153pub(crate) async fn gate_transcript_workflow(
154    state: &ServerState,
155    caller: &CallerIdentity,
156    namespace: &str,
157    workflow_id: &aion_core::WorkflowId,
158) -> Result<(), ServerError> {
159    let per_workflow = PerWorkflowSubscription {
160        namespace: namespace.to_owned(),
161        workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
162        resume_from_seq: None,
163    };
164    let target = WorkflowTarget::workflow(workflow_id);
165    let scope = SubscriptionScope::PerWorkflow(&per_workflow, target);
166    let filter = aion::EventFilter {
167        workflow_id: Some(workflow_id.clone()),
168        ..aion::EventFilter::default()
169    };
170    let operation = NamespaceOperation::subscribe(scope, &filter);
171    // Guard verdict FIRST: nothing runs for an unauthorized caller. The scoped
172    // engine handle is not needed here — transcripts read the `O` keyspace
173    // through the publisher, never the engine.
174    let scoped = state.namespace_guard().scope(caller, &operation).await?;
175    drop(scoped);
176    Ok(())
177}
178
179fn decode_workflow_id(
180    workflow_id: Option<&ProtoWorkflowId>,
181) -> Result<aion_core::WorkflowId, ServerError> {
182    workflow_id
183        .cloned()
184        .ok_or_else(|| ServerError::Wire {
185            wire: WireError::invalid_input("transcript subscription workflow_id is missing"),
186        })?
187        .try_into()
188        .map_err(|wire| ServerError::Wire { wire })
189}
190
191fn decode_activity_id(subscription: &TranscriptSubscription) -> Result<ActivityId, ServerError> {
192    let activity_id = subscription.activity_id.ok_or_else(|| ServerError::Wire {
193        wire: WireError::invalid_input("transcript subscription activity_id is missing"),
194    })?;
195    Ok(ActivityId::from(activity_id))
196}
197
198/// Serialize + send one transcript event on the still-unified socket (during the
199/// durable replay, before the read/write split). A send failure means the client
200/// is gone: signal a clean end.
201async fn send_activity_frame(
202    socket: &mut WebSocket,
203    event: aion_core::ActivityEvent,
204) -> Result<std::ops::ControlFlow<()>, ServerError> {
205    let frame = encode_activity_frame(&event)?;
206    if socket.send(Message::Text(frame.into())).await.is_err() {
207        return Ok(std::ops::ControlFlow::Break(()));
208    }
209    Ok(std::ops::ControlFlow::Continue(()))
210}
211
212/// Serialize + send one live transcript event on the write half of the split
213/// socket. A serialize failure sends a terminal wire-error frame and surfaces the
214/// error; a send failure is a benign client-gone end.
215async fn forward_live_frame<Tx>(
216    socket_tx: &mut Tx,
217    event: aion_core::ActivityEvent,
218) -> Result<std::ops::ControlFlow<()>, ServerError>
219where
220    Tx: futures::Sink<Message> + Unpin,
221    <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
222{
223    let frame = match encode_activity_frame(&event) {
224        Ok(frame) => frame,
225        Err(error) => {
226            super::socket::send_wire_error(socket_tx, &error.to_wire_error()).await?;
227            return Err(error);
228        }
229    };
230    if socket_tx.send(Message::Text(frame.into())).await.is_err() {
231        return Ok(std::ops::ControlFlow::Break(()));
232    }
233    Ok(std::ops::ControlFlow::Continue(()))
234}
235
236fn encode_activity_frame(event: &aion_core::ActivityEvent) -> Result<String, ServerError> {
237    let frame = StreamedActivityEvent::new(event.clone());
238    serde_json::to_string(&frame).map_err(|source| ServerError::Wire {
239        wire: WireError::backend(format!(
240            "failed to serialize transcript event frame: {source}"
241        )),
242    })
243}
244
245/// Send the typed `transcript_lagged` terminal frame + close, then surface it
246/// typed — the client re-resumes from the durable `O` tail by `store_seq`.
247async fn deliver_transcript_terminal<Tx>(
248    socket_tx: &mut Tx,
249    skipped: u64,
250) -> Result<(), ServerError>
251where
252    Tx: futures::Sink<Message> + Unpin,
253    <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
254{
255    let payload = serde_json::json!({
256        "error": { "code": "transcript_lagged", "skipped": skipped },
257    });
258    let payload = serde_json::to_string(&payload).map_err(|source| ServerError::Wire {
259        wire: WireError::backend(format!(
260            "failed to serialize transcript lag frame: {source}"
261        )),
262    })?;
263    if socket_tx.send(Message::Text(payload.into())).await.is_ok() {
264        let close = CloseFrame {
265            code: close_code::ERROR,
266            reason: "transcript_lagged".into(),
267        };
268        let close_result = socket_tx.send(Message::Close(Some(close))).await;
269        drop(close_result);
270    }
271    Err(ServerError::lagged_stream())
272}
273
274/// Finish a graceful transcript subscription end with a close-1000 frame.
275async fn send_normal_close<Tx>(socket_tx: &mut Tx) -> Result<(), ServerError>
276where
277    Tx: futures::Sink<Message> + Unpin,
278    <Tx as futures::Sink<Message>>::Error: std::fmt::Debug,
279{
280    let close = CloseFrame {
281        code: close_code::NORMAL,
282        reason: "subscription complete".into(),
283    };
284    let close_result = socket_tx.send(Message::Close(Some(close))).await;
285    drop(close_result);
286    Ok(())
287}