Skip to main content

aion_core/
event.rs

1//! Workflow history events and their deterministic recording envelope.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    ActivityError, ActivityId, PackageVersion, Payload, RunId, ScheduleConfig, ScheduleId,
10    SearchAttributeValue, TimerId, WorkflowError, WorkflowId,
11};
12
13/// Metadata recorded with every workflow history event.
14#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
15pub struct EventEnvelope {
16    /// Monotonic sequence number within the owning workflow history.
17    pub seq: u64,
18    /// Recorded UTC timestamp for this event.
19    ///
20    /// This timestamp is the determinism source for `workflow.now`; replay must use the recorded
21    /// value rather than consulting wall-clock time.
22    pub recorded_at: DateTime<Utc>,
23    /// Workflow history that owns this event.
24    pub workflow_id: WorkflowId,
25}
26
27/// The named default task queue: the single sanctioned fallback when no explicit task queue was
28/// selected (no SDK-level selection exists yet — that is NSTQ-4) and the replay-safe decode value
29/// for [`Event::ActivityScheduled`] events recorded before the `task_queue` field existed.
30///
31/// This is the canonical task-queue default for the whole workspace. `aion_store::DEFAULT_OUTBOX_ROUTE`
32/// and `aion_server::worker::registry::DEFAULT_TASK_QUEUE` both alias/re-export this constant rather
33/// than redeclaring the literal, so a history-derived task queue and an outbox-row-derived task queue
34/// cannot drift.
35pub const DEFAULT_TASK_QUEUE: &str = "default";
36
37/// serde default for [`Event::ActivityScheduled::task_queue`]: the named [`DEFAULT_TASK_QUEUE`].
38///
39/// Used by `#[serde(default = ...)]` so an old recorded history that has no `task_queue` on its
40/// `ActivityScheduled` events decodes deterministically to `"default"`.
41fn default_task_queue() -> String {
42    String::from(DEFAULT_TASK_QUEUE)
43}
44
45/// A recorded workflow history event.
46///
47/// User data is carried as opaque [`Payload`] values, while failures use the closed workflow and
48/// activity error types from this crate.
49#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
50#[serde(tag = "type", content = "data")]
51pub enum Event {
52    /// A workflow execution started with a type name and input payload.
53    WorkflowStarted {
54        /// Recording metadata for this event.
55        envelope: EventEnvelope,
56        /// Workflow type selected by the caller.
57        workflow_type: String,
58        /// Opaque workflow input payload.
59        input: Payload,
60        /// Concrete run identifier started by this event.
61        run_id: RunId,
62        /// Parent run that continued as this run, when this start is part of a
63        /// continue-as-new chain.
64        parent_run_id: Option<RunId>,
65        /// Package version this run was resolved against at record time.
66        ///
67        /// Recovery and replay resolve workflow code from this recorded
68        /// version; they never re-resolve a "latest" version.
69        package_version: PackageVersion,
70    },
71    /// A workflow execution completed successfully; this terminal event projects to Completed.
72    WorkflowCompleted {
73        /// Recording metadata for this event.
74        envelope: EventEnvelope,
75        /// Opaque workflow result payload.
76        result: Payload,
77    },
78    /// A workflow execution failed terminally; this terminal event projects to Failed.
79    WorkflowFailed {
80        /// Recording metadata for this event.
81        envelope: EventEnvelope,
82        /// Terminal workflow failure.
83        error: WorkflowError,
84    },
85    /// A workflow execution was cancelled; this terminal event projects to Cancelled.
86    WorkflowCancelled {
87        /// Recording metadata for this event.
88        envelope: EventEnvelope,
89        /// Human-readable cancellation reason.
90        reason: String,
91    },
92    /// A workflow execution timed out; this terminal event projects to `TimedOut`.
93    WorkflowTimedOut {
94        /// Recording metadata for this event.
95        envelope: EventEnvelope,
96        /// Descriptor identifying the timeout that elapsed.
97        ///
98        /// Intentionally stringly-typed: the closed set of timeout kinds is defined by cluster AT
99        /// (timers and signals), not by the core event model.
100        timeout: String,
101    },
102    /// A workflow execution continued as a new run; this terminal event projects to
103    /// `ContinuedAsNew`.
104    WorkflowContinuedAsNew {
105        /// Recording metadata for this event.
106        envelope: EventEnvelope,
107        /// Opaque workflow input payload carried into the new run.
108        input: Payload,
109        /// Workflow type override for the new run, when migration changes the workflow type.
110        ///
111        /// When absent, the new run uses the current workflow type.
112        workflow_type: Option<String>,
113        /// Run identifier for the current run that is being continued.
114        parent_run_id: RunId,
115    },
116    /// A failed run was reopened.
117    ///
118    /// Engine-internal — never authored by workflow or SDK code. This is the
119    /// compensating event that reconciles reopen with the status-is-a-projection
120    /// invariant: under the last-lifecycle-event-wins scan it supersedes the
121    /// run's prior terminal event and returns the run to Running, exactly as a
122    /// replacement [`Event::WorkflowStarted`] does for continue-as-new. Terminal
123    /// detection is scoped to "since the last reopen point", so a run holds
124    /// exactly one terminal event per lease.
125    WorkflowReopened {
126        /// Recording metadata for this event.
127        envelope: EventEnvelope,
128        /// Run being reopened — the run that recorded the superseded terminal
129        /// event and that the reopened execution continues.
130        run_id: RunId,
131        /// Activities to re-dispatch on replay: those that ended in a terminal
132        /// failure in this run with no later successful attempt. The history
133        /// cursor treats each as a reset point so the recorded failure is
134        /// superseded and the activity resolves to live re-dispatch.
135        reopened: Vec<ActivityId>,
136    },
137    /// Workflow search attributes were updated for visibility and query projection.
138    SearchAttributesUpdated {
139        /// Recording metadata for this event.
140        envelope: EventEnvelope,
141        /// Workflow whose search attributes changed.
142        workflow_id: WorkflowId,
143        /// Updated search attributes keyed by attribute name.
144        attributes: HashMap<String, SearchAttributeValue>,
145    },
146    /// An activity was scheduled by workflow code.
147    ActivityScheduled {
148        /// Recording metadata for this event.
149        envelope: EventEnvelope,
150        /// Deterministic activity identifier derived from the scheduling sequence position.
151        activity_id: ActivityId,
152        /// Activity type selected by workflow code.
153        activity_type: String,
154        /// Opaque activity input payload.
155        input: Payload,
156        /// Pool/flavour selector this activity dispatches to within the workflow's namespace
157        /// (NSTQ-3). This is the durable source-of-truth for re-targeting the **same** task queue
158        /// on reopen/recovery, mirroring how the namespace is recovered from history but recorded
159        /// **per-activity** rather than as a workflow-level search attribute.
160        ///
161        /// Replay-safety: histories recorded before this field existed have no `task_queue` on
162        /// their `ActivityScheduled` events. Decode defaults the missing value to
163        /// [`DEFAULT_TASK_QUEUE`] (`"default"`) via `#[serde(default = ...)]`, so an old history
164        /// deterministically re-derives `task_queue = "default"` — never panics, never differs
165        /// run-to-run. The encoding of the existing fields is untouched.
166        #[serde(default = "default_task_queue")]
167        task_queue: String,
168        /// OPTIONAL node affinity this activity dispatches to (NODE-3). `None` = no affinity (the
169        /// genuine current value; SDK-level node selection is NODE-4). This is the durable
170        /// source-of-truth for re-targeting the **same** node on reopen/recovery, recorded
171        /// **per-activity** alongside `task_queue`.
172        ///
173        /// Replay-safety: histories recorded before this field existed have no `node` key on their
174        /// `ActivityScheduled` events. serde's `Option` default is `None`, so `#[serde(default)]`
175        /// decodes a missing `node` deterministically to `None` — never a sentinel, never panics,
176        /// never differs run-to-run. The encoding of the existing fields is untouched.
177        #[serde(default)]
178        node: Option<String>,
179    },
180    /// An activity worker started executing an activity attempt.
181    ActivityStarted {
182        /// Recording metadata for this event.
183        envelope: EventEnvelope,
184        /// Activity being executed.
185        activity_id: ActivityId,
186    },
187    /// An activity completed successfully.
188    ActivityCompleted {
189        /// Recording metadata for this event.
190        envelope: EventEnvelope,
191        /// Activity that produced the result.
192        activity_id: ActivityId,
193        /// Opaque activity result payload.
194        result: Payload,
195    },
196    /// An activity attempt failed.
197    ///
198    /// The `attempt` field together with [`ActivityError`]'s retryable or terminal classification
199    /// lets replay distinguish a retryable interim failure from a terminal one for the same
200    /// [`ActivityId`].
201    ActivityFailed {
202        /// Recording metadata for this event.
203        envelope: EventEnvelope,
204        /// Activity whose attempt failed.
205        activity_id: ActivityId,
206        /// Classified activity failure.
207        error: ActivityError,
208        /// One-based activity attempt number that produced this failure.
209        attempt: u32,
210    },
211    /// An activity was cancelled as an explicit cancellation outcome.
212    ActivityCancelled {
213        /// Recording metadata for this event.
214        envelope: EventEnvelope,
215        /// Activity that was cancelled.
216        activity_id: ActivityId,
217    },
218    /// A timer was scheduled to fire at a deterministic timestamp.
219    TimerStarted {
220        /// Recording metadata for this event.
221        envelope: EventEnvelope,
222        /// Timer selected by workflow code or assigned by the engine.
223        timer_id: TimerId,
224        /// UTC timestamp at which the timer becomes eligible to fire.
225        fire_at: DateTime<Utc>,
226    },
227    /// A timer fired.
228    TimerFired {
229        /// Recording metadata for this event.
230        envelope: EventEnvelope,
231        /// Timer that fired.
232        timer_id: TimerId,
233    },
234    /// A timer was cancelled as an explicit cancellation outcome.
235    TimerCancelled {
236        /// Recording metadata for this event.
237        envelope: EventEnvelope,
238        /// Timer that was cancelled.
239        timer_id: TimerId,
240    },
241    /// A `with_timeout` operation reached a durable terminal outcome.
242    WithTimeoutCompleted {
243        /// Recording metadata for this event.
244        envelope: EventEnvelope,
245        /// Timer that bounded the operation.
246        timer_id: TimerId,
247        /// Recorded timeout outcome.
248        outcome: WithTimeoutOutcome,
249        /// JSON-encoded BEAM term payload for completed operation results.
250        result: Option<Payload>,
251    },
252    /// A signal was delivered to the workflow.
253    SignalReceived {
254        /// Recording metadata for this event.
255        envelope: EventEnvelope,
256        /// Signal name selected by the sender.
257        name: String,
258        /// Opaque signal payload.
259        payload: Payload,
260    },
261    /// A signal was sent by this workflow to another workflow.
262    SignalSent {
263        /// Recording metadata for this event.
264        envelope: EventEnvelope,
265        /// Target workflow identifier selected by workflow code.
266        target_workflow_id: WorkflowId,
267        /// Signal name selected by workflow code.
268        name: String,
269        /// Opaque signal payload.
270        payload: Payload,
271    },
272    /// A child workflow was started.
273    ChildWorkflowStarted {
274        /// Recording metadata for this event.
275        envelope: EventEnvelope,
276        /// Child workflow identifier.
277        child_workflow_id: WorkflowId,
278        /// Child workflow type selected by the parent.
279        workflow_type: String,
280        /// Opaque child workflow input payload.
281        input: Payload,
282        /// Package version resolved for the child at record time.
283        ///
284        /// The crash-repair sweep and the child's own start use exactly this
285        /// recorded version, so the crash path resolves identically to the
286        /// crash-free path.
287        package_version: PackageVersion,
288    },
289    /// A child workflow completed successfully.
290    ChildWorkflowCompleted {
291        /// Recording metadata for this event.
292        envelope: EventEnvelope,
293        /// Child workflow that produced the result.
294        child_workflow_id: WorkflowId,
295        /// Opaque child workflow result payload.
296        result: Payload,
297    },
298    /// A child workflow failed terminally.
299    ChildWorkflowFailed {
300        /// Recording metadata for this event.
301        envelope: EventEnvelope,
302        /// Child workflow that failed.
303        child_workflow_id: WorkflowId,
304        /// Terminal child workflow failure.
305        error: WorkflowError,
306    },
307    /// A child workflow was cancelled as an explicit cancellation outcome.
308    ChildWorkflowCancelled {
309        /// Recording metadata for this event.
310        envelope: EventEnvelope,
311        /// Child workflow that was cancelled.
312        child_workflow_id: WorkflowId,
313    },
314    /// A schedule resource was created.
315    ScheduleCreated {
316        /// Recording metadata for this event.
317        envelope: EventEnvelope,
318        /// Schedule resource that was created.
319        schedule_id: ScheduleId,
320        /// Persisted schedule configuration.
321        config: ScheduleConfig,
322    },
323    /// A schedule resource was updated.
324    ScheduleUpdated {
325        /// Recording metadata for this event.
326        envelope: EventEnvelope,
327        /// Schedule resource that was updated.
328        schedule_id: ScheduleId,
329        /// Updated schedule configuration.
330        config: ScheduleConfig,
331    },
332    /// A schedule resource was paused.
333    SchedulePaused {
334        /// Recording metadata for this event.
335        envelope: EventEnvelope,
336        /// Schedule resource that was paused.
337        schedule_id: ScheduleId,
338    },
339    /// A paused schedule resource was resumed.
340    ScheduleResumed {
341        /// Recording metadata for this event.
342        envelope: EventEnvelope,
343        /// Schedule resource that was resumed.
344        schedule_id: ScheduleId,
345    },
346    /// A schedule resource was deleted.
347    ScheduleDeleted {
348        /// Recording metadata for this event.
349        envelope: EventEnvelope,
350        /// Schedule resource that was deleted.
351        schedule_id: ScheduleId,
352    },
353    /// A schedule tick started a workflow execution.
354    ScheduleTriggered {
355        /// Recording metadata for this event.
356        envelope: EventEnvelope,
357        /// Schedule resource that fired.
358        schedule_id: ScheduleId,
359        /// Workflow execution started by the schedule tick.
360        workflow_id: WorkflowId,
361        /// Run started by the schedule tick.
362        run_id: RunId,
363    },
364}
365
366/// Durable terminal outcome for a `with_timeout` operation.
367#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
368pub enum WithTimeoutOutcome {
369    /// The operation closure returned before the deadline.
370    OperationCompleted,
371    /// The deadline fired before the operation completed.
372    TimedOut,
373}
374
375impl Event {
376    /// Returns the envelope recorded with this event.
377    #[must_use]
378    pub const fn envelope(&self) -> &EventEnvelope {
379        match self {
380            Self::WorkflowStarted { envelope, .. }
381            | Self::WorkflowCompleted { envelope, .. }
382            | Self::WorkflowFailed { envelope, .. }
383            | Self::WorkflowCancelled { envelope, .. }
384            | Self::WorkflowTimedOut { envelope, .. }
385            | Self::WorkflowContinuedAsNew { envelope, .. }
386            | Self::WorkflowReopened { envelope, .. }
387            | Self::SearchAttributesUpdated { envelope, .. }
388            | Self::ActivityScheduled { envelope, .. }
389            | Self::ActivityStarted { envelope, .. }
390            | Self::ActivityCompleted { envelope, .. }
391            | Self::ActivityFailed { envelope, .. }
392            | Self::ActivityCancelled { envelope, .. }
393            | Self::TimerStarted { envelope, .. }
394            | Self::TimerFired { envelope, .. }
395            | Self::TimerCancelled { envelope, .. }
396            | Self::WithTimeoutCompleted { envelope, .. }
397            | Self::SignalReceived { envelope, .. }
398            | Self::SignalSent { envelope, .. }
399            | Self::ChildWorkflowStarted { envelope, .. }
400            | Self::ChildWorkflowCompleted { envelope, .. }
401            | Self::ChildWorkflowFailed { envelope, .. }
402            | Self::ChildWorkflowCancelled { envelope, .. }
403            | Self::ScheduleCreated { envelope, .. }
404            | Self::ScheduleUpdated { envelope, .. }
405            | Self::SchedulePaused { envelope, .. }
406            | Self::ScheduleResumed { envelope, .. }
407            | Self::ScheduleDeleted { envelope, .. }
408            | Self::ScheduleTriggered { envelope, .. } => envelope,
409        }
410    }
411
412    /// Returns the monotonic sequence number recorded for this event.
413    #[must_use]
414    pub const fn seq(&self) -> u64 {
415        self.envelope().seq
416    }
417
418    /// Returns the deterministic recorded timestamp for this event.
419    #[must_use]
420    pub const fn recorded_at(&self) -> &DateTime<Utc> {
421        &self.envelope().recorded_at
422    }
423
424    /// Returns the workflow history that owns this event.
425    #[must_use]
426    pub const fn workflow_id(&self) -> &WorkflowId {
427        &self.envelope().workflow_id
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use std::collections::HashMap;
434
435    use chrono::{DateTime, Utc};
436    use serde_json::json;
437
438    use super::{DEFAULT_TASK_QUEUE, Event, EventEnvelope};
439    use crate::{
440        ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
441        Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
442        WorkflowError, WorkflowId,
443    };
444
445    fn package_version() -> PackageVersion {
446        PackageVersion::new("a".repeat(64))
447    }
448
449    fn recorded_at() -> DateTime<Utc> {
450        DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
451    }
452
453    fn envelope(seq: u64) -> EventEnvelope {
454        EventEnvelope {
455            seq,
456            recorded_at: recorded_at(),
457            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
458        }
459    }
460
461    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
462        Payload::from_json(&json!({ "label": label }))
463    }
464
465    fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
466        Ok(ScheduleConfig {
467            trigger: TriggerSpec::Cron {
468                expression: String::from("0 0 * * *"),
469            },
470            overlap_policy: OverlapPolicy::Skip,
471            catch_up_policy: CatchUpPolicy::One,
472            workflow_type: String::from("checkout"),
473            input: payload(label)?,
474            search_attributes: HashMap::from([(
475                String::from("aion.namespace"),
476                crate::SearchAttributeValue::String(String::from("tenant-a")),
477            )]),
478        })
479    }
480
481    fn workflow_error(message: &str) -> WorkflowError {
482        WorkflowError {
483            message: String::from(message),
484            details: None,
485        }
486    }
487
488    fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
489        ActivityError {
490            kind,
491            message: String::from(message),
492            details: None,
493        }
494    }
495
496    fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
497        let json = serde_json::to_string(event)?;
498        let decoded = serde_json::from_str::<Event>(&json)?;
499        assert_eq!(*event, decoded);
500        Ok(())
501    }
502
503    /// NSTQ-3: a recorded `ActivityScheduled` carries its `task_queue` through the durable JSON
504    /// wire so reopen/recovery can re-target the same pool.
505    #[test]
506    fn activity_scheduled_records_and_reads_back_its_task_queue()
507    -> Result<(), Box<dyn std::error::Error>> {
508        let event = Event::ActivityScheduled {
509            envelope: envelope(6),
510            activity_id: ActivityId::from_sequence_position(6),
511            activity_type: String::from("charge-card"),
512            input: payload("activity-input")?,
513            task_queue: String::from("claude"),
514            node: None,
515        };
516
517        let json = serde_json::to_string(&event)?;
518        let decoded = serde_json::from_str::<Event>(&json)?;
519
520        match decoded {
521            Event::ActivityScheduled { task_queue, .. } => {
522                assert_eq!(
523                    task_queue, "claude",
524                    "the recorded task queue must survive the round-trip"
525                );
526            }
527            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
528        }
529        Ok(())
530    }
531
532    /// NSTQ-3 replay-safety (the load-bearing test): an OLD recorded history that has no
533    /// `task_queue` key on its `ActivityScheduled` events MUST still decode, defaulting the missing
534    /// value to the named `"default"` task queue, deterministically — never panic, never differ
535    /// run-to-run. The old wire form is the exact pre-field bytes: the current serialization with
536    /// the `task_queue` key removed.
537    #[test]
538    fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
539    -> Result<(), Box<dyn std::error::Error>> {
540        // Build a current event, serialize, then strip the `task_queue` key to reconstruct exactly
541        // what a history recorded before the field existed looks like on the wire.
542        let current = Event::ActivityScheduled {
543            envelope: envelope(6),
544            activity_id: ActivityId::from_sequence_position(6),
545            activity_type: String::from("charge-card"),
546            input: payload("activity-input")?,
547            task_queue: String::from("ignored-when-stripped"),
548            node: Some(String::from("ignored-when-stripped")),
549        };
550        let mut value = serde_json::to_value(&current)?;
551        let data = value
552            .get_mut("data")
553            .and_then(serde_json::Value::as_object_mut)
554            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
555        assert!(
556            data.remove("task_queue").is_some(),
557            "the current wire form must contain task_queue before we strip it"
558        );
559
560        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
561        // back the named default, deterministically.
562        let old_wire = serde_json::to_string(&value)?;
563        for _ in 0..4 {
564            let decoded = serde_json::from_str::<Event>(&old_wire)?;
565            match &decoded {
566                Event::ActivityScheduled { task_queue, .. } => {
567                    assert_eq!(
568                        task_queue, DEFAULT_TASK_QUEUE,
569                        "a missing task_queue must default to the named default queue"
570                    );
571                    assert_eq!(task_queue, "default");
572                }
573                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
574            }
575        }
576        Ok(())
577    }
578
579    /// NODE-3: a recorded `ActivityScheduled` carries its OPTIONAL `node` affinity through the
580    /// durable JSON wire so reopen/recovery can re-target the same node.
581    #[test]
582    fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
583    {
584        let event = Event::ActivityScheduled {
585            envelope: envelope(6),
586            activity_id: ActivityId::from_sequence_position(6),
587            activity_type: String::from("charge-card"),
588            input: payload("activity-input")?,
589            task_queue: String::from("claude"),
590            node: Some(String::from("box-7")),
591        };
592
593        let json = serde_json::to_string(&event)?;
594        let decoded = serde_json::from_str::<Event>(&json)?;
595
596        match decoded {
597            Event::ActivityScheduled { node, .. } => {
598                assert_eq!(
599                    node.as_deref(),
600                    Some("box-7"),
601                    "the recorded node affinity must survive the round-trip"
602                );
603            }
604            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
605        }
606        Ok(())
607    }
608
609    /// NODE-3 replay-safety (the load-bearing test): an OLD recorded history that has no `node` key
610    /// on its `ActivityScheduled` events MUST still decode, defaulting the missing value to `None`
611    /// (no affinity) deterministically — never a sentinel, never panic, never differ run-to-run.
612    /// The old wire form is the exact pre-field bytes: the current serialization with the `node`
613    /// key removed.
614    #[test]
615    fn activity_scheduled_decodes_old_history_without_node_as_none()
616    -> Result<(), Box<dyn std::error::Error>> {
617        // Build a current event with a node set, serialize, then strip the `node` key to
618        // reconstruct exactly what a history recorded before the field existed looks like on the
619        // wire.
620        let current = Event::ActivityScheduled {
621            envelope: envelope(6),
622            activity_id: ActivityId::from_sequence_position(6),
623            activity_type: String::from("charge-card"),
624            input: payload("activity-input")?,
625            task_queue: String::from("default"),
626            node: Some(String::from("ignored-when-stripped")),
627        };
628        let mut value = serde_json::to_value(&current)?;
629        let data = value
630            .get_mut("data")
631            .and_then(serde_json::Value::as_object_mut)
632            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
633        assert!(
634            data.remove("node").is_some(),
635            "the current wire form must contain node before we strip it"
636        );
637
638        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
639        // back `None`, deterministically.
640        let old_wire = serde_json::to_string(&value)?;
641        for _ in 0..4 {
642            let decoded = serde_json::from_str::<Event>(&old_wire)?;
643            match &decoded {
644                Event::ActivityScheduled { node, .. } => {
645                    assert_eq!(
646                        *node, None,
647                        "a missing node must default to None (no affinity)"
648                    );
649                }
650                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
651            }
652        }
653        Ok(())
654    }
655
656    #[test]
657    fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
658        let workflow_id = WorkflowId::new_v4();
659        let recorded_at = recorded_at();
660        let envelope = EventEnvelope {
661            seq: 17,
662            recorded_at,
663            workflow_id: workflow_id.clone(),
664        };
665        let event = Event::WorkflowStarted {
666            envelope,
667            workflow_type: String::from("checkout"),
668            input: payload("input")?,
669            run_id: RunId::new(uuid::Uuid::from_u128(1)),
670            parent_run_id: None,
671            package_version: package_version(),
672        };
673
674        assert_eq!(event.seq(), 17);
675        assert_eq!(event.recorded_at(), &recorded_at);
676        assert_eq!(event.workflow_id(), &workflow_id);
677        Ok(())
678    }
679
680    #[test]
681    fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
682        let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
683        let events = vec![
684            Event::WorkflowStarted {
685                envelope: envelope(1),
686                workflow_type: String::from("checkout"),
687                input: payload("workflow-input")?,
688                run_id: RunId::new(uuid::Uuid::from_u128(1)),
689                parent_run_id: None,
690                package_version: package_version(),
691            },
692            Event::WorkflowCompleted {
693                envelope: envelope(2),
694                result: payload("workflow-result")?,
695            },
696            Event::WorkflowFailed {
697                envelope: envelope(3),
698                error: workflow_error("workflow failed"),
699            },
700            Event::WorkflowCancelled {
701                envelope: envelope(4),
702                reason: String::from("caller requested cancellation"),
703            },
704            Event::WorkflowTimedOut {
705                envelope: envelope(5),
706                timeout: String::from("execution"),
707            },
708            Event::ActivityScheduled {
709                envelope: envelope(6),
710                activity_id: ActivityId::from_sequence_position(6),
711                activity_type: String::from("charge-card"),
712                input: payload("activity-input")?,
713                task_queue: String::from("claude"),
714                node: Some(String::from("box-7")),
715            },
716            Event::ActivityStarted {
717                envelope: envelope(7),
718                activity_id: ActivityId::from_sequence_position(6),
719            },
720            Event::ActivityCompleted {
721                envelope: envelope(8),
722                activity_id: ActivityId::from_sequence_position(6),
723                result: payload("activity-result")?,
724            },
725            Event::ActivityFailed {
726                envelope: envelope(9),
727                activity_id: ActivityId::from_sequence_position(6),
728                error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
729                attempt: 1,
730            },
731            Event::ActivityCancelled {
732                envelope: envelope(10),
733                activity_id: ActivityId::from_sequence_position(6),
734            },
735            Event::TimerStarted {
736                envelope: envelope(11),
737                timer_id: TimerId::anonymous(11),
738                fire_at,
739            },
740            Event::TimerFired {
741                envelope: envelope(12),
742                timer_id: TimerId::anonymous(11),
743            },
744            Event::TimerCancelled {
745                envelope: envelope(13),
746                timer_id: TimerId::named("reminder")?,
747            },
748            Event::SignalReceived {
749                envelope: envelope(14),
750                name: String::from("approve"),
751                payload: payload("signal")?,
752            },
753            Event::SignalSent {
754                envelope: envelope(15),
755                target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
756                name: String::from("approve"),
757                payload: payload("signal-sent")?,
758            },
759        ];
760
761        for event in events {
762            round_trip(&event)?;
763        }
764        Ok(())
765    }
766
767    #[test]
768    fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
769        let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
770        let events = vec![
771            Event::ChildWorkflowStarted {
772                envelope: envelope(16),
773                child_workflow_id: child_workflow_id.clone(),
774                workflow_type: String::from("fulfillment"),
775                input: payload("child-input")?,
776                package_version: package_version(),
777            },
778            Event::ChildWorkflowCompleted {
779                envelope: envelope(16),
780                child_workflow_id: child_workflow_id.clone(),
781                result: payload("child-result")?,
782            },
783            Event::ChildWorkflowFailed {
784                envelope: envelope(17),
785                child_workflow_id: child_workflow_id.clone(),
786                error: workflow_error("child failed"),
787            },
788            Event::ChildWorkflowCancelled {
789                envelope: envelope(18),
790                child_workflow_id,
791            },
792        ];
793
794        for event in events {
795            round_trip(&event)?;
796        }
797        Ok(())
798    }
799
800    #[test]
801    fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
802        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
803        let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
804        let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
805        let events = vec![
806            Event::WorkflowContinuedAsNew {
807                envelope: envelope(19),
808                input: payload("continued-input")?,
809                workflow_type: Some(String::from("checkout-v2")),
810                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
811            },
812            Event::SearchAttributesUpdated {
813                envelope: envelope(20),
814                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
815                attributes: HashMap::from([(
816                    String::from("customer_id"),
817                    SearchAttributeValue::String(String::from("cust-123")),
818                )]),
819            },
820            Event::ScheduleCreated {
821                envelope: envelope(20),
822                schedule_id: schedule_id.clone(),
823                config: schedule_config("schedule-created")?,
824            },
825            Event::ScheduleUpdated {
826                envelope: envelope(21),
827                schedule_id: schedule_id.clone(),
828                config: schedule_config("schedule-updated")?,
829            },
830            Event::SchedulePaused {
831                envelope: envelope(22),
832                schedule_id: schedule_id.clone(),
833            },
834            Event::ScheduleResumed {
835                envelope: envelope(23),
836                schedule_id: schedule_id.clone(),
837            },
838            Event::ScheduleDeleted {
839                envelope: envelope(24),
840                schedule_id: schedule_id.clone(),
841            },
842            Event::ScheduleTriggered {
843                envelope: envelope(25),
844                schedule_id,
845                workflow_id: triggered_workflow_id,
846                run_id: triggered_run_id,
847            },
848        ];
849
850        for event in events {
851            round_trip(&event)?;
852        }
853        Ok(())
854    }
855}