Skip to main content

aion_server/api/
ws_subscription.rs

1//! WebSocket subscription request reading and JSON decoding.
2//!
3//! The first client frame on `/events/stream` is a JSON `SubscriptionRequest`.
4//! This module owns the tolerant decode of that frame: the canonical proto
5//! serde shape is accepted first, then the documented hand-written shapes
6//! (`per_workflow` / `filtered` / `firehose`, optionally wrapped in
7//! `{"subscription": ...}`).
8
9use aion_proto::{
10    ClusterSubscription, FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription,
11    ProtoActivityId, ProtoWorkflowId, SubscriptionRequest, TranscriptSubscription, WireError,
12    subscription_request,
13};
14use axum::extract::ws::{Message, WebSocket};
15use serde_json::{Map, Value};
16
17use crate::error::ServerError;
18
19/// Read the first subscription frame from an accepted WebSocket.
20///
21/// Ping/pong frames are ignored while waiting. A malformed frame or a socket
22/// error is a decode failure the caller reports as one terminal error frame.
23///
24/// A clean close *before* any subscribe frame arrives — either an explicit
25/// `Close` frame or the stream ending (`recv` returns `None`) — is NOT an error:
26/// it is the normal lifecycle of a client that disconnects before subscribing
27/// (e.g. a React `StrictMode` double-mount whose first socket is torn down before
28/// it can send, or a page navigated away during the handshake). It is returned
29/// as `Ok(None)` so the caller ends the connection gracefully without logging a
30/// spurious warning or trying to write an error frame to an already-closing
31/// socket.
32///
33/// # Errors
34///
35/// Returns [`ServerError::Wire`] (`invalid_input`) only when the request frame
36/// is present but cannot be read or decoded.
37pub async fn read_subscription_request(
38    socket: &mut WebSocket,
39) -> Result<Option<SubscriptionRequest>, ServerError> {
40    loop {
41        // Stream ended before any subscribe frame: a benign pre-subscribe close.
42        let Some(message) = socket.recv().await else {
43            return Ok(None);
44        };
45        let message = message.map_err(|source| {
46            WireError::invalid_input(format!(
47                "failed to read websocket subscription request: {source}"
48            ))
49        })?;
50
51        match message {
52            Message::Text(text) => return decode_subscription_request(text.as_bytes()).map(Some),
53            Message::Binary(bytes) => return decode_subscription_request(&bytes).map(Some),
54            Message::Ping(_) | Message::Pong(_) => {}
55            // Explicit clean close before subscribing: benign, not an error.
56            Message::Close(_) => return Ok(None),
57        }
58    }
59}
60
61/// Decode a subscription request from raw frame bytes.
62///
63/// # Errors
64///
65/// Returns [`ServerError::Wire`] (`invalid_input`) when the bytes are not a
66/// recognizable subscription JSON object.
67pub fn decode_subscription_request(bytes: &[u8]) -> Result<SubscriptionRequest, ServerError> {
68    let value = serde_json::from_slice::<Value>(bytes).map_err(|source| {
69        WireError::invalid_input(format!("invalid websocket subscription JSON: {source}"))
70    })?;
71    decode_subscription_value(&value)
72}
73
74fn decode_subscription_value(value: &Value) -> Result<SubscriptionRequest, ServerError> {
75    if let Ok(request) = serde_json::from_value::<SubscriptionRequest>(value.clone()) {
76        if request.subscription.is_some() {
77            return Ok(request);
78        }
79    }
80
81    let subscription = value.get("subscription").unwrap_or(value);
82    let Some(subscription) = subscription.as_object() else {
83        return Err(
84            WireError::invalid_input("websocket subscription must be a JSON object").into(),
85        );
86    };
87
88    if let Some(value) = subscription.get("per_workflow") {
89        return Ok(SubscriptionRequest {
90            subscription: Some(subscription_request::Subscription::PerWorkflow(
91                decode_per_workflow_subscription(value)?,
92            )),
93        });
94    }
95    if let Some(value) = subscription.get("filtered") {
96        return Ok(SubscriptionRequest {
97            subscription: Some(subscription_request::Subscription::Filtered(
98                decode_filtered_subscription(value)?,
99            )),
100        });
101    }
102    if let Some(value) = subscription.get("firehose") {
103        return Ok(SubscriptionRequest {
104            subscription: Some(subscription_request::Subscription::Firehose(
105                decode_firehose_subscription(value)?,
106            )),
107        });
108    }
109    if let Some(value) = subscription.get("cluster") {
110        return Ok(SubscriptionRequest {
111            subscription: Some(subscription_request::Subscription::Cluster(
112                decode_cluster_subscription(value)?,
113            )),
114        });
115    }
116    if let Some(value) = subscription.get("transcript") {
117        return Ok(SubscriptionRequest {
118            subscription: Some(subscription_request::Subscription::Transcript(
119                decode_transcript_subscription(value)?,
120            )),
121        });
122    }
123
124    Err(WireError::invalid_input(
125        "websocket subscription must contain per_workflow, filtered, firehose, cluster, or transcript",
126    )
127    .into())
128}
129
130fn decode_per_workflow_subscription(value: &Value) -> Result<PerWorkflowSubscription, ServerError> {
131    let object = subscription_object(value, "per-workflow")?;
132    Ok(PerWorkflowSubscription {
133        namespace: required_string(object, "namespace", "per-workflow subscription")?.to_owned(),
134        workflow_id: Some(decode_workflow_id_value(
135            object.get("workflow_id").ok_or_else(|| {
136                WireError::invalid_input("per-workflow subscription requires workflow_id")
137            })?,
138        )?),
139        resume_from_seq: decode_resume_from_seq(object)?,
140    })
141}
142
143/// Decode the optional resume cursor. Presence only: range validation against
144/// the recorded history head happens after the namespace guard verdict, in
145/// `stream::resume`, so decoding can never leak existence information.
146fn decode_resume_from_seq(object: &Map<String, Value>) -> Result<Option<u64>, ServerError> {
147    match object.get("resume_from_seq") {
148        None | Some(Value::Null) => Ok(None),
149        Some(value) => value.as_u64().map(Some).ok_or_else(|| {
150            WireError::invalid_input(
151                "per-workflow subscription resume_from_seq must be an unsigned integer",
152            )
153            .into()
154        }),
155    }
156}
157
158fn decode_filtered_subscription(value: &Value) -> Result<FilteredSubscription, ServerError> {
159    let object = subscription_object(value, "filtered")?;
160    let status = match object.get("status") {
161        Some(Value::String(status)) => Some(decode_status_name(status)?),
162        Some(Value::Number(status)) => status.as_i64().and_then(|value| i32::try_from(value).ok()),
163        Some(Value::Null) | None => None,
164        Some(_other) => None,
165    };
166    Ok(FilteredSubscription {
167        namespace: required_string(object, "namespace", "filtered subscription")?.to_owned(),
168        workflow_type: object
169            .get("workflow_type")
170            .and_then(Value::as_str)
171            .map(str::to_owned),
172        status,
173        namespace_selector: object
174            .get("namespace_selector")
175            .and_then(Value::as_str)
176            .map(str::to_owned),
177    })
178}
179
180/// Decode the WS3 cluster subscription. The only field is the optional
181/// `after_seq` resume cursor (presence-only; `0`/absent both request the full
182/// in-flight backlog). The cluster subscription carries no namespace — it is
183/// deployment-scoped and authorized by the caller's deploy grant.
184fn decode_cluster_subscription(value: &Value) -> Result<ClusterSubscription, ServerError> {
185    // An empty object `{}` is valid (after_seq defaults to 0). A bare absent
186    // value object is also accepted.
187    let after_seq = match value.as_object().and_then(|object| object.get("after_seq")) {
188        None | Some(Value::Null) => 0,
189        Some(seq) => seq.as_u64().ok_or_else(|| {
190            WireError::invalid_input("cluster subscription after_seq must be an unsigned integer")
191        })?,
192    };
193    Ok(ClusterSubscription { after_seq })
194}
195
196/// Decode the NOI-5b transcript subscription. Namespace-scoped like the
197/// per-workflow arm: it carries `namespace` + `workflow_id`, plus the
198/// `run_id`/`activity_id`/`attempt` axes that pin the `O`-keyspace stream, and
199/// an optional `after_seq` resume cursor (presence-only; range/validity is
200/// irrelevant since `store_seq` is a durable read that returns an empty tail
201/// past the head).
202///
203/// `run_id` is required for the same reason `workflow_id` is: without it the
204/// remaining axes do not name a stream, because a continue-as-new chain restarts
205/// its activity ordinals and attempt numbers in every generation.
206fn decode_transcript_subscription(value: &Value) -> Result<TranscriptSubscription, ServerError> {
207    let object = subscription_object(value, "transcript")?;
208    let workflow_id = decode_workflow_id_value(object.get("workflow_id").ok_or_else(|| {
209        WireError::invalid_input("transcript subscription requires workflow_id")
210    })?)?;
211    let run_id = decode_run_id_value(
212        object
213            .get("run_id")
214            .ok_or_else(|| WireError::invalid_input("transcript subscription requires run_id"))?,
215    )?;
216    let activity_id = decode_activity_id_value(object.get("activity_id").ok_or_else(|| {
217        WireError::invalid_input("transcript subscription requires activity_id")
218    })?)?;
219    let attempt = decode_attempt(object)?;
220    Ok(TranscriptSubscription {
221        namespace: required_string(object, "namespace", "transcript subscription")?.to_owned(),
222        workflow_id: Some(workflow_id),
223        run_id: Some(run_id),
224        activity_id: Some(activity_id),
225        attempt,
226        after_seq: decode_after_seq(object)?,
227    })
228}
229
230/// Decode the transcript `run_id`: either a bare UUID string or the structured
231/// `{ "uuid": "…" }` proto shape, matching how `workflow_id` is accepted.
232fn decode_run_id_value(value: &Value) -> Result<aion_proto::ProtoRunId, ServerError> {
233    if let Some(uuid) = value.as_str() {
234        return Ok(aion_proto::ProtoRunId {
235            uuid: uuid.to_owned(),
236        });
237    }
238    serde_json::from_value::<aion_proto::ProtoRunId>(value.clone()).map_err(|source| {
239        WireError::invalid_input(format!("invalid transcript subscription run_id: {source}")).into()
240    })
241}
242
243/// Decode the transcript `activity_id`: either a bare unsigned sequence position
244/// or the structured `{ "sequence_position": n }` proto shape.
245fn decode_activity_id_value(value: &Value) -> Result<ProtoActivityId, ServerError> {
246    if let Some(sequence_position) = value.as_u64() {
247        return Ok(ProtoActivityId { sequence_position });
248    }
249    serde_json::from_value::<ProtoActivityId>(value.clone()).map_err(|source| {
250        WireError::invalid_input(format!(
251            "invalid transcript subscription activity_id: {source}"
252        ))
253        .into()
254    })
255}
256
257/// Decode the required transcript `attempt` axis as a `u32`.
258fn decode_attempt(object: &Map<String, Value>) -> Result<u32, ServerError> {
259    match object.get("attempt") {
260        // Absent attempt defaults to 0 (the first attempt's stream), matching the
261        // envelope's default attempt for a not-yet-retried activity.
262        None | Some(Value::Null) => Ok(0),
263        Some(value) => value
264            .as_u64()
265            .and_then(|attempt| u32::try_from(attempt).ok())
266            .ok_or_else(|| {
267                WireError::invalid_input(
268                    "transcript subscription attempt must be an unsigned 32-bit integer",
269                )
270                .into()
271            }),
272    }
273}
274
275/// Decode the optional transcript resume cursor `after_seq` (presence-only).
276fn decode_after_seq(object: &Map<String, Value>) -> Result<Option<u64>, ServerError> {
277    match object.get("after_seq") {
278        None | Some(Value::Null) => Ok(None),
279        Some(value) => value.as_u64().map(Some).ok_or_else(|| {
280            WireError::invalid_input(
281                "transcript subscription after_seq must be an unsigned integer",
282            )
283            .into()
284        }),
285    }
286}
287
288fn decode_firehose_subscription(value: &Value) -> Result<FirehoseSubscription, ServerError> {
289    let object = subscription_object(value, "firehose")?;
290    let namespace = object
291        .get("namespace")
292        .or_else(|| object.get("namespace_selector"))
293        .and_then(Value::as_str)
294        .ok_or_else(|| WireError::invalid_input("firehose subscription requires namespace"))?;
295    Ok(FirehoseSubscription {
296        namespace: namespace.to_owned(),
297    })
298}
299
300fn subscription_object<'a>(
301    value: &'a Value,
302    subscription_name: &str,
303) -> Result<&'a Map<String, Value>, ServerError> {
304    value.as_object().ok_or_else(|| {
305        WireError::invalid_input(format!(
306            "{subscription_name} subscription must be a JSON object"
307        ))
308        .into()
309    })
310}
311
312fn required_string<'a>(
313    object: &'a Map<String, Value>,
314    key: &str,
315    context: &str,
316) -> Result<&'a str, ServerError> {
317    object
318        .get(key)
319        .and_then(Value::as_str)
320        .ok_or_else(|| WireError::invalid_input(format!("{context} requires {key}")).into())
321}
322
323fn decode_workflow_id_value(value: &Value) -> Result<ProtoWorkflowId, ServerError> {
324    if let Some(uuid) = value.as_str() {
325        return Ok(ProtoWorkflowId {
326            uuid: uuid.to_owned(),
327        });
328    }
329    serde_json::from_value::<ProtoWorkflowId>(value.clone()).map_err(|source| {
330        WireError::invalid_input(format!(
331            "invalid per-workflow subscription workflow_id: {source}"
332        ))
333        .into()
334    })
335}
336
337fn decode_status_name(status: &str) -> Result<i32, ServerError> {
338    match status {
339        "running" | "Running" => Ok(aion_proto::ProtoWorkflowStatus::Running as i32),
340        "completed" | "Completed" => Ok(aion_proto::ProtoWorkflowStatus::Completed as i32),
341        "failed" | "Failed" => Ok(aion_proto::ProtoWorkflowStatus::Failed as i32),
342        "cancelled" | "Cancelled" | "canceled" | "Canceled" => {
343            Ok(aion_proto::ProtoWorkflowStatus::Cancelled as i32)
344        }
345        "timed_out" | "TimedOut" => Ok(aion_proto::ProtoWorkflowStatus::TimedOut as i32),
346        "continued_as_new" | "ContinuedAsNew" => {
347            Ok(aion_proto::ProtoWorkflowStatus::ContinuedAsNew as i32)
348        }
349        "paused" | "Paused" => Ok(aion_proto::ProtoWorkflowStatus::Paused as i32),
350        other => Err(WireError::invalid_input(format!(
351            "invalid workflow status in websocket subscription: {other}"
352        ))
353        .into()),
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use aion_proto::{WireErrorCode, subscription_request};
360    use serde_json::json;
361
362    use super::decode_subscription_request;
363    use crate::error::ServerError;
364
365    fn decode(value: &serde_json::Value) -> Result<aion_proto::SubscriptionRequest, ServerError> {
366        decode_subscription_request(value.to_string().as_bytes())
367    }
368
369    fn per_workflow(
370        request: aion_proto::SubscriptionRequest,
371    ) -> Result<aion_proto::PerWorkflowSubscription, Box<dyn std::error::Error>> {
372        match request.subscription {
373            Some(subscription_request::Subscription::PerWorkflow(subscription)) => Ok(subscription),
374            other => Err(format!("expected a per-workflow subscription, got {other:?}").into()),
375        }
376    }
377
378    #[test]
379    fn per_workflow_resume_from_seq_is_decoded() -> Result<(), Box<dyn std::error::Error>> {
380        let workflow_id = uuid::Uuid::from_u128(7).to_string();
381        let request = decode(&json!({
382            "per_workflow": {
383                "namespace": "tenant-a",
384                "workflow_id": workflow_id,
385                "resume_from_seq": 42,
386            }
387        }))?;
388
389        assert_eq!(per_workflow(request)?.resume_from_seq, Some(42));
390        Ok(())
391    }
392
393    #[test]
394    fn per_workflow_resume_from_seq_zero_passes_decode_for_post_guard_validation()
395    -> Result<(), Box<dyn std::error::Error>> {
396        // Decode is presence-only; the 0-is-invalid range check belongs after
397        // the namespace guard so probes can never distinguish existence.
398        let request = decode(&json!({
399            "per_workflow": {
400                "namespace": "tenant-a",
401                "workflow_id": uuid::Uuid::from_u128(7).to_string(),
402                "resume_from_seq": 0,
403            }
404        }))?;
405
406        assert_eq!(per_workflow(request)?.resume_from_seq, Some(0));
407        Ok(())
408    }
409
410    #[test]
411    fn per_workflow_resume_from_seq_absent_or_null_is_none()
412    -> Result<(), Box<dyn std::error::Error>> {
413        let workflow_id = uuid::Uuid::from_u128(7).to_string();
414        let absent = decode(&json!({
415            "per_workflow": { "namespace": "tenant-a", "workflow_id": workflow_id }
416        }))?;
417        let null = decode(&json!({
418            "per_workflow": {
419                "namespace": "tenant-a",
420                "workflow_id": workflow_id,
421                "resume_from_seq": null,
422            }
423        }))?;
424
425        assert_eq!(per_workflow(absent)?.resume_from_seq, None);
426        assert_eq!(per_workflow(null)?.resume_from_seq, None);
427        Ok(())
428    }
429
430    #[test]
431    fn per_workflow_resume_from_seq_rejects_non_unsigned_values() {
432        for bad in [json!(-1), json!(1.5), json!("7")] {
433            let result = decode(&json!({
434                "per_workflow": {
435                    "namespace": "tenant-a",
436                    "workflow_id": uuid::Uuid::from_u128(7).to_string(),
437                    "resume_from_seq": bad,
438                }
439            }));
440            let error = result.err().map(|error| error.to_wire_error());
441            assert_eq!(
442                error.as_ref().map(|error| error.code),
443                Some(WireErrorCode::InvalidInput),
444                "expected invalid_input, got {error:?}"
445            );
446        }
447    }
448
449    #[test]
450    fn wrapped_subscription_shape_is_accepted() -> Result<(), Box<dyn std::error::Error>> {
451        let request = decode(&json!({
452            "subscription": {
453                "per_workflow": {
454                    "namespace": "tenant-a",
455                    "workflow_id": { "uuid": uuid::Uuid::from_u128(7).to_string() },
456                    "resume_from_seq": 3,
457                }
458            }
459        }))?;
460
461        let subscription = per_workflow(request)?;
462        assert_eq!(subscription.namespace, "tenant-a");
463        assert_eq!(subscription.resume_from_seq, Some(3));
464        Ok(())
465    }
466
467    #[test]
468    fn filtered_and_firehose_shapes_still_decode() -> Result<(), Box<dyn std::error::Error>> {
469        let filtered = decode(&json!({
470            "filtered": { "namespace": "tenant-a", "status": "Completed" }
471        }))?;
472        let firehose = decode(&json!({ "firehose": { "namespace": "tenant-a" } }))?;
473
474        assert!(matches!(
475            filtered.subscription,
476            Some(subscription_request::Subscription::Filtered(_))
477        ));
478        assert!(matches!(
479            firehose.subscription,
480            Some(subscription_request::Subscription::Firehose(_))
481        ));
482        Ok(())
483    }
484
485    /// NOI-5b: the transcript arm decodes its namespace + workflow/run/activity/
486    /// attempt axes and the optional `after_seq` cursor. A bare unsigned
487    /// `activity_id` and an absent `attempt` (defaulting to 0) are both accepted;
488    /// the run is accepted in the same bare-UUID form `workflow_id` is.
489    #[test]
490    fn transcript_subscription_decodes_axes_and_cursor() -> Result<(), Box<dyn std::error::Error>> {
491        let workflow_id = uuid::Uuid::from_u128(7).to_string();
492        let run_id = uuid::Uuid::from_u128(0x11).to_string();
493        let request = decode(&json!({
494            "transcript": {
495                "namespace": "tenant-a",
496                "workflow_id": workflow_id,
497                "run_id": run_id,
498                "activity_id": 3,
499                "attempt": 2,
500                "after_seq": 5,
501            }
502        }))?;
503        let Some(subscription_request::Subscription::Transcript(transcript)) = request.subscription
504        else {
505            return Err("expected a transcript subscription".into());
506        };
507        assert_eq!(transcript.namespace, "tenant-a");
508        assert_eq!(transcript.run_id.map(|id| id.uuid), Some(run_id));
509        assert_eq!(
510            transcript.activity_id.map(|id| id.sequence_position),
511            Some(3)
512        );
513        assert_eq!(transcript.attempt, 2);
514        assert_eq!(transcript.after_seq, Some(5));
515        Ok(())
516    }
517
518    /// The run axis is REQUIRED: a transcript subscription that omits it is
519    /// `invalid_input`, never a subscription silently resolved against some
520    /// generation of the server's choosing. On a continue-as-new chain the
521    /// remaining axes do not name a stream at all.
522    #[test]
523    fn transcript_subscription_requires_run_id() {
524        let error = decode(&json!({
525            "transcript": {
526                "namespace": "tenant-a",
527                "workflow_id": uuid::Uuid::from_u128(7).to_string(),
528                "activity_id": 3,
529                "attempt": 2,
530            }
531        }))
532        .err()
533        .map(|error| error.to_wire_error());
534        assert_eq!(
535            error.map(|error| error.code),
536            Some(WireErrorCode::InvalidInput)
537        );
538    }
539
540    #[test]
541    fn transcript_subscription_defaults_attempt_and_cursor()
542    -> Result<(), Box<dyn std::error::Error>> {
543        let request = decode(&json!({
544            "transcript": {
545                "namespace": "tenant-a",
546                "workflow_id": uuid::Uuid::from_u128(7).to_string(),
547                "run_id": uuid::Uuid::from_u128(0x11).to_string(),
548                "activity_id": 3,
549            }
550        }))?;
551        let Some(subscription_request::Subscription::Transcript(transcript)) = request.subscription
552        else {
553            return Err("expected a transcript subscription".into());
554        };
555        assert_eq!(transcript.attempt, 0, "absent attempt defaults to 0");
556        assert_eq!(
557            transcript.after_seq, None,
558            "absent after_seq is a fresh subscriber"
559        );
560        Ok(())
561    }
562
563    #[test]
564    fn transcript_subscription_requires_activity_id() {
565        let error = decode(&json!({
566            "transcript": {
567                "namespace": "tenant-a",
568                "workflow_id": uuid::Uuid::from_u128(7).to_string(),
569                "run_id": uuid::Uuid::from_u128(0x11).to_string(),
570            }
571        }))
572        .err()
573        .map(|error| error.to_wire_error());
574        assert_eq!(
575            error.map(|error| error.code),
576            Some(WireErrorCode::InvalidInput)
577        );
578    }
579
580    #[test]
581    fn unknown_subscription_shape_is_invalid_input() {
582        let error = decode(&json!({ "mystery": {} }))
583            .err()
584            .map(|error| error.to_wire_error());
585        assert_eq!(
586            error.map(|error| error.code),
587            Some(WireErrorCode::InvalidInput)
588        );
589    }
590}