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/// Sentinel `attempt` value for activity lifecycle events decoded from a history recorded BEFORE the
46/// `attempt` field existed on [`Event::ActivityStarted`] / [`Event::ActivityCompleted`] /
47/// [`Event::ActivityCancelled`] (NOI-0).
48///
49/// Activity attempts are **one-based** everywhere they are produced (see [`Event::ActivityFailed`]'s
50/// `attempt`, which is documented "One-based activity attempt number", and the engine's
51/// `FIRST_DELIVERY_ATTEMPT = 1`). A real attempt is therefore always `>= 1`, so `0` can never collide
52/// with a genuine attempt: it is a distinguishable "legacy / unknown attempt" marker. Old histories
53/// that predate the field decode to this sentinel via `#[serde(default = "legacy_activity_attempt")]`
54/// — deterministically, never panicking, never differing run-to-run — while the compiler still forces
55/// every LIVE construction site to supply the genuine one-based attempt (there is no blanket
56/// `Default` on the variant).
57const LEGACY_ACTIVITY_ATTEMPT: u32 = 0;
58
59/// serde default for the `attempt` field on the activity lifecycle events that gained it in NOI-0.
60///
61/// Returns [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) so a history recorded before the field existed decodes
62/// deterministically to the legacy/unknown sentinel rather than failing. See
63/// [`LEGACY_ACTIVITY_ATTEMPT`] for why `0` is a safe distinguishable value under one-based attempts.
64fn legacy_activity_attempt() -> u32 {
65    LEGACY_ACTIVITY_ATTEMPT
66}
67
68/// Search attribute name that records the task queue a workflow was STARTED on.
69///
70/// The server stamps this attribute durably in the SAME atomic append as
71/// [`Event::WorkflowStarted`] (via [`Event::SearchAttributesUpdated`]) when the
72/// start request selected a task queue — mirroring the `aion.namespace`
73/// attribute that records the owning namespace. It is therefore part of
74/// RECORDED HISTORY: recovery/replay re-derive the identical value, so an
75/// activity that falls back to its workflow's start-time queue (#144) resolves
76/// to the same queue on every replay. The attribute is absent when the start
77/// did not select a queue (the legacy / "no selection anywhere" case).
78///
79/// This is the canonical name for the whole workspace;
80/// `aion_server::TASK_QUEUE_ATTRIBUTE` re-exports it rather than redeclaring the
81/// literal, so a history-derived start-time queue and the server's recorded
82/// attribute cannot drift.
83pub const START_TIME_TASK_QUEUE_ATTRIBUTE: &str = "aion.task_queue";
84
85/// The task queue a workflow was STARTED on, projected from recorded history.
86///
87/// Reads the [`START_TIME_TASK_QUEUE_ATTRIBUTE`] search attribute folded from
88/// the run's [`Event::SearchAttributesUpdated`] events (the server records it in
89/// the same append as [`Event::WorkflowStarted`]). Returns `None` when the start
90/// recorded no task-queue selection — a legacy history, or a start that left the
91/// queue unset — so callers fall back to the named [`DEFAULT_TASK_QUEUE`].
92///
93/// Because the value is read purely from recorded history (never from live or
94/// wall-clock state), it is replay-deterministic: the same history always
95/// projects the same start-time queue.
96#[must_use]
97pub fn start_time_task_queue(events: &[Event]) -> Option<String> {
98    let attributes = crate::search_attributes_from_events(events);
99    match attributes.get(START_TIME_TASK_QUEUE_ATTRIBUTE) {
100        Some(crate::SearchAttributeValue::String(queue)) => Some(queue.clone()),
101        _ => None,
102    }
103}
104
105/// A recorded workflow history event.
106///
107/// User data is carried as opaque [`Payload`] values, while failures use the closed workflow and
108/// activity error types from this crate.
109#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
110#[serde(tag = "type", content = "data")]
111pub enum Event {
112    /// A workflow execution started with a type name and input payload.
113    WorkflowStarted {
114        /// Recording metadata for this event.
115        envelope: EventEnvelope,
116        /// Workflow type selected by the caller.
117        workflow_type: String,
118        /// Opaque workflow input payload.
119        input: Payload,
120        /// Concrete run identifier started by this event.
121        run_id: RunId,
122        /// Parent run that continued as this run, when this start is part of a
123        /// continue-as-new chain.
124        parent_run_id: Option<RunId>,
125        /// Package version this run was resolved against at record time.
126        ///
127        /// Recovery and replay resolve workflow code from this recorded
128        /// version; they never re-resolve a "latest" version.
129        package_version: PackageVersion,
130    },
131    /// A workflow execution completed successfully; this terminal event projects to Completed.
132    WorkflowCompleted {
133        /// Recording metadata for this event.
134        envelope: EventEnvelope,
135        /// Opaque workflow result payload.
136        result: Payload,
137    },
138    /// A workflow execution failed terminally; this terminal event projects to Failed.
139    WorkflowFailed {
140        /// Recording metadata for this event.
141        envelope: EventEnvelope,
142        /// Terminal workflow failure.
143        error: WorkflowError,
144    },
145    /// A workflow execution was cancelled; this terminal event projects to Cancelled.
146    WorkflowCancelled {
147        /// Recording metadata for this event.
148        envelope: EventEnvelope,
149        /// Human-readable cancellation reason.
150        reason: String,
151    },
152    /// A workflow execution timed out; this terminal event projects to `TimedOut`.
153    WorkflowTimedOut {
154        /// Recording metadata for this event.
155        envelope: EventEnvelope,
156        /// Descriptor identifying the timeout that elapsed.
157        ///
158        /// Intentionally stringly-typed: the closed set of timeout kinds is defined by cluster AT
159        /// (timers and signals), not by the core event model.
160        timeout: String,
161    },
162    /// A workflow execution continued as a new run; this terminal event projects to
163    /// `ContinuedAsNew`.
164    WorkflowContinuedAsNew {
165        /// Recording metadata for this event.
166        envelope: EventEnvelope,
167        /// Opaque workflow input payload carried into the new run.
168        input: Payload,
169        /// Workflow type override for the new run, when migration changes the workflow type.
170        ///
171        /// When absent, the new run uses the current workflow type.
172        workflow_type: Option<String>,
173        /// Run identifier for the current run that is being continued.
174        parent_run_id: RunId,
175    },
176    /// A failed run was reopened.
177    ///
178    /// Engine-internal — never authored by workflow or SDK code. This is the
179    /// compensating event that reconciles reopen with the status-is-a-projection
180    /// invariant: under the last-lifecycle-event-wins scan it supersedes the
181    /// run's prior terminal event and returns the run to Running, exactly as a
182    /// replacement [`Event::WorkflowStarted`] does for continue-as-new. Terminal
183    /// detection is scoped to "since the last reopen point", so a run holds
184    /// exactly one terminal event per lease.
185    WorkflowReopened {
186        /// Recording metadata for this event.
187        envelope: EventEnvelope,
188        /// Run being reopened — the run that recorded the superseded terminal
189        /// event and that the reopened execution continues.
190        run_id: RunId,
191        /// Activities to re-dispatch on replay: those that ended in a terminal
192        /// failure in this run with no later successful attempt. The history
193        /// cursor treats each as a reset point so the recorded failure is
194        /// superseded and the activity resolves to live re-dispatch.
195        reopened: Vec<ActivityId>,
196    },
197    /// A running workflow was paused by an operator.
198    ///
199    /// Engine-internal — never authored by workflow or SDK code. A NON-terminal
200    /// lifecycle marker: under the last-lifecycle-event-wins scan it projects the
201    /// run to [`crate::WorkflowStatus::Paused`], holding new activity dispatch at
202    /// the outbox while every durable record path (timer fires, signal receipts,
203    /// drained completions) keeps recording. It is invisible to the replay cursor
204    /// (it is neither a terminal nor a run-start reset), so a paused-then-resumed
205    /// history replays byte-identically to one that was never paused.
206    WorkflowPaused {
207        /// Recording metadata for this event.
208        envelope: EventEnvelope,
209        /// Run being paused — the live, non-terminal run the operator held.
210        run_id: RunId,
211        /// Optional operator-supplied pause reason.
212        reason: Option<String>,
213        /// Optional identity of the operator who issued the pause.
214        operator: Option<String>,
215    },
216    /// A paused workflow was resumed by an operator.
217    ///
218    /// Engine-internal — never authored by workflow or SDK code. Supersedes the
219    /// run's prior [`Event::WorkflowPaused`] under the last-lifecycle-event-wins
220    /// scan, returning the run to [`crate::WorkflowStatus::Running`], and — like
221    /// [`Event::WorkflowPaused`] — is invisible to the replay cursor.
222    WorkflowResumed {
223        /// Recording metadata for this event.
224        envelope: EventEnvelope,
225        /// Run being resumed.
226        run_id: RunId,
227        /// Optional identity of the operator who issued the resume.
228        operator: Option<String>,
229    },
230    /// Workflow search attributes were updated for visibility and query projection.
231    SearchAttributesUpdated {
232        /// Recording metadata for this event.
233        envelope: EventEnvelope,
234        /// Workflow whose search attributes changed.
235        workflow_id: WorkflowId,
236        /// Updated search attributes keyed by attribute name.
237        attributes: HashMap<String, SearchAttributeValue>,
238    },
239    /// An activity was scheduled by workflow code.
240    ActivityScheduled {
241        /// Recording metadata for this event.
242        envelope: EventEnvelope,
243        /// Deterministic activity identifier derived from the scheduling sequence position.
244        activity_id: ActivityId,
245        /// Activity type selected by workflow code.
246        activity_type: String,
247        /// Opaque activity input payload.
248        input: Payload,
249        /// Pool/flavour selector this activity dispatches to within the workflow's namespace
250        /// (NSTQ-3). This is the durable source-of-truth for re-targeting the **same** task queue
251        /// on reopen/recovery, mirroring how the namespace is recovered from history but recorded
252        /// **per-activity** rather than as a workflow-level search attribute.
253        ///
254        /// Replay-safety: histories recorded before this field existed have no `task_queue` on
255        /// their `ActivityScheduled` events. Decode defaults the missing value to
256        /// [`DEFAULT_TASK_QUEUE`] (`"default"`) via `#[serde(default = ...)]`, so an old history
257        /// deterministically re-derives `task_queue = "default"` — never panics, never differs
258        /// run-to-run. The encoding of the existing fields is untouched.
259        #[serde(default = "default_task_queue")]
260        task_queue: String,
261        /// OPTIONAL node affinity this activity dispatches to (NODE-3). `None` = no affinity (the
262        /// genuine current value; SDK-level node selection is NODE-4). This is the durable
263        /// source-of-truth for re-targeting the **same** node on reopen/recovery, recorded
264        /// **per-activity** alongside `task_queue`.
265        ///
266        /// Replay-safety: histories recorded before this field existed have no `node` key on their
267        /// `ActivityScheduled` events. serde's `Option` default is `None`, so `#[serde(default)]`
268        /// decodes a missing `node` deterministically to `None` — never a sentinel, never panics,
269        /// never differs run-to-run. The encoding of the existing fields is untouched.
270        #[serde(default)]
271        node: Option<String>,
272    },
273    /// An activity worker started executing an activity attempt.
274    ActivityStarted {
275        /// Recording metadata for this event.
276        envelope: EventEnvelope,
277        /// Activity being executed.
278        activity_id: ActivityId,
279        /// One-based activity attempt number this start belongs to (NOI-0).
280        ///
281        /// Matches the `attempt` on the [`Event::ActivityFailed`] / [`Event::ActivityCompleted`] /
282        /// [`Event::ActivityCancelled`] that terminates the SAME attempt, so
283        /// `(workflow, activity, attempt)` is a stable identity across the whole lifecycle — the key
284        /// the NOI dedupe/guard/session-id design is built on.
285        ///
286        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
287        /// `ActivityStarted` events. Decode defaults the missing value to
288        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
289        /// differs run-to-run. Because real attempts are one-based, `0` is a distinguishable
290        /// legacy/unknown sentinel, never a genuine attempt. The encoding of the existing fields is
291        /// untouched.
292        #[serde(default = "legacy_activity_attempt")]
293        attempt: u32,
294    },
295    /// An activity completed successfully.
296    ActivityCompleted {
297        /// Recording metadata for this event.
298        envelope: EventEnvelope,
299        /// Activity that produced the result.
300        activity_id: ActivityId,
301        /// Opaque activity result payload.
302        result: Payload,
303        /// One-based activity attempt number that produced this completion (NOI-0).
304        ///
305        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so a
306        /// completed activity carries one consistent `attempt` readable off both its start and its
307        /// terminal — the negative-control invariant NOI-0 gates on.
308        ///
309        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
310        /// `ActivityCompleted` events. Decode defaults the missing value to
311        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
312        /// differs run-to-run. The encoding of the existing fields is untouched.
313        #[serde(default = "legacy_activity_attempt")]
314        attempt: u32,
315    },
316    /// An activity attempt failed.
317    ///
318    /// The `attempt` field together with [`ActivityError`]'s retryable or terminal classification
319    /// lets replay distinguish a retryable interim failure from a terminal one for the same
320    /// [`ActivityId`].
321    ActivityFailed {
322        /// Recording metadata for this event.
323        envelope: EventEnvelope,
324        /// Activity whose attempt failed.
325        activity_id: ActivityId,
326        /// Classified activity failure.
327        error: ActivityError,
328        /// One-based activity attempt number that produced this failure.
329        attempt: u32,
330    },
331    /// An activity was cancelled as an explicit cancellation outcome.
332    ActivityCancelled {
333        /// Recording metadata for this event.
334        envelope: EventEnvelope,
335        /// Activity that was cancelled.
336        activity_id: ActivityId,
337        /// One-based activity attempt number that was cancelled (NOI-0).
338        ///
339        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so the
340        /// cancellation terminal is attributable to a specific attempt exactly like
341        /// [`Event::ActivityFailed`] is.
342        ///
343        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
344        /// `ActivityCancelled` events. Decode defaults the missing value to
345        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
346        /// differs run-to-run. The encoding of the existing fields is untouched.
347        #[serde(default = "legacy_activity_attempt")]
348        attempt: u32,
349    },
350    /// A timer was scheduled to fire at a deterministic timestamp.
351    TimerStarted {
352        /// Recording metadata for this event.
353        envelope: EventEnvelope,
354        /// Timer selected by workflow code or assigned by the engine.
355        timer_id: TimerId,
356        /// UTC timestamp at which the timer becomes eligible to fire.
357        fire_at: DateTime<Utc>,
358    },
359    /// A timer fired.
360    TimerFired {
361        /// Recording metadata for this event.
362        envelope: EventEnvelope,
363        /// Timer that fired.
364        timer_id: TimerId,
365    },
366    /// A timer was cancelled as an explicit cancellation outcome.
367    TimerCancelled {
368        /// Recording metadata for this event.
369        envelope: EventEnvelope,
370        /// Timer that was cancelled.
371        timer_id: TimerId,
372        /// Who retired the timer. Decides reopen behavior: a
373        /// [`TimerCancelCause::CancelTeardown`] cancellation is re-armed when the
374        /// run is reopened; a [`TimerCancelCause::WorkflowIntent`] cancellation is
375        /// permanent.
376        ///
377        /// Replay-safety: histories recorded before this field existed have no
378        /// `cause` key. Decode defaults the missing value to
379        /// [`TimerCancelCause::WorkflowIntent`] via `#[serde(default)]` — the
380        /// pre-field behavior (never resurrected), never panics, never differs
381        /// run-to-run. The encoding of the existing fields is untouched.
382        #[serde(default)]
383        cause: TimerCancelCause,
384    },
385    /// A `with_timeout` operation reached a durable terminal outcome.
386    WithTimeoutCompleted {
387        /// Recording metadata for this event.
388        envelope: EventEnvelope,
389        /// Timer that bounded the operation.
390        timer_id: TimerId,
391        /// Recorded timeout outcome.
392        outcome: WithTimeoutOutcome,
393        /// JSON-encoded BEAM term payload for completed operation results.
394        result: Option<Payload>,
395    },
396    /// A signal was delivered to the workflow.
397    SignalReceived {
398        /// Recording metadata for this event.
399        envelope: EventEnvelope,
400        /// Signal name selected by the sender.
401        name: String,
402        /// Opaque signal payload.
403        payload: Payload,
404    },
405    /// A signal was sent by this workflow to another workflow.
406    SignalSent {
407        /// Recording metadata for this event.
408        envelope: EventEnvelope,
409        /// Target workflow identifier selected by workflow code.
410        target_workflow_id: WorkflowId,
411        /// Signal name selected by workflow code.
412        name: String,
413        /// Opaque signal payload.
414        payload: Payload,
415    },
416    /// A child workflow was started.
417    ChildWorkflowStarted {
418        /// Recording metadata for this event.
419        envelope: EventEnvelope,
420        /// Child workflow identifier.
421        child_workflow_id: WorkflowId,
422        /// Child workflow type selected by the parent.
423        workflow_type: String,
424        /// Opaque child workflow input payload.
425        input: Payload,
426        /// Package version resolved for the child at record time.
427        ///
428        /// The crash-repair sweep and the child's own start use exactly this
429        /// recorded version, so the crash path resolves identically to the
430        /// crash-free path.
431        package_version: PackageVersion,
432    },
433    /// A child workflow completed successfully.
434    ChildWorkflowCompleted {
435        /// Recording metadata for this event.
436        envelope: EventEnvelope,
437        /// Child workflow that produced the result.
438        child_workflow_id: WorkflowId,
439        /// Opaque child workflow result payload.
440        result: Payload,
441    },
442    /// A child workflow failed terminally.
443    ChildWorkflowFailed {
444        /// Recording metadata for this event.
445        envelope: EventEnvelope,
446        /// Child workflow that failed.
447        child_workflow_id: WorkflowId,
448        /// Terminal child workflow failure.
449        error: WorkflowError,
450    },
451    /// A child workflow was cancelled as an explicit cancellation outcome.
452    ChildWorkflowCancelled {
453        /// Recording metadata for this event.
454        envelope: EventEnvelope,
455        /// Child workflow that was cancelled.
456        child_workflow_id: WorkflowId,
457    },
458    /// A schedule resource was created.
459    ScheduleCreated {
460        /// Recording metadata for this event.
461        envelope: EventEnvelope,
462        /// Schedule resource that was created.
463        schedule_id: ScheduleId,
464        /// Persisted schedule configuration.
465        config: ScheduleConfig,
466    },
467    /// A schedule resource was updated.
468    ScheduleUpdated {
469        /// Recording metadata for this event.
470        envelope: EventEnvelope,
471        /// Schedule resource that was updated.
472        schedule_id: ScheduleId,
473        /// Updated schedule configuration.
474        config: ScheduleConfig,
475    },
476    /// A schedule resource was paused.
477    SchedulePaused {
478        /// Recording metadata for this event.
479        envelope: EventEnvelope,
480        /// Schedule resource that was paused.
481        schedule_id: ScheduleId,
482    },
483    /// A paused schedule resource was resumed.
484    ScheduleResumed {
485        /// Recording metadata for this event.
486        envelope: EventEnvelope,
487        /// Schedule resource that was resumed.
488        schedule_id: ScheduleId,
489    },
490    /// A schedule resource was deleted.
491    ScheduleDeleted {
492        /// Recording metadata for this event.
493        envelope: EventEnvelope,
494        /// Schedule resource that was deleted.
495        schedule_id: ScheduleId,
496    },
497    /// A schedule tick started a workflow execution.
498    ScheduleTriggered {
499        /// Recording metadata for this event.
500        envelope: EventEnvelope,
501        /// Schedule resource that fired.
502        schedule_id: ScheduleId,
503        /// Workflow execution started by the schedule tick.
504        workflow_id: WorkflowId,
505        /// Run started by the schedule tick.
506        run_id: RunId,
507    },
508}
509
510/// Durable terminal outcome for a `with_timeout` operation.
511#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
512pub enum WithTimeoutOutcome {
513    /// The operation closure returned before the deadline.
514    OperationCompleted,
515    /// The deadline fired before the operation completed.
516    TimedOut,
517}
518
519/// Who retired a durable timer, recorded on [`Event::TimerCancelled`].
520///
521/// The distinction decides reopen semantics. A timer the WORKFLOW retired —
522/// an SDK `cancel_timer` call or a `with_timeout` scope settling because the
523/// racing operation won — is a business fact: reopen must never resurrect it.
524/// A timer the ENGINE retired while tearing down a cancelled run
525/// (`Engine::cancel`'s in-flight timer cleanup) is bookkeeping: the deadline
526/// itself was never reached or waived, so reopening the run re-arms it at its
527/// original `fire_at`.
528#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, Default, PartialEq, Eq)]
529pub enum TimerCancelCause {
530    /// Workflow code retired the timer (SDK cancel or a settled timeout scope).
531    ///
532    /// The serde default: histories recorded before this field existed decode
533    /// as workflow intent, preserving their pre-field never-resurrected
534    /// behavior.
535    #[default]
536    WorkflowIntent,
537    /// The engine retired the timer while cancelling its workflow run.
538    CancelTeardown,
539}
540
541impl Event {
542    /// Returns the envelope recorded with this event.
543    #[must_use]
544    pub const fn envelope(&self) -> &EventEnvelope {
545        match self {
546            Self::WorkflowStarted { envelope, .. }
547            | Self::WorkflowCompleted { envelope, .. }
548            | Self::WorkflowFailed { envelope, .. }
549            | Self::WorkflowCancelled { envelope, .. }
550            | Self::WorkflowTimedOut { envelope, .. }
551            | Self::WorkflowContinuedAsNew { envelope, .. }
552            | Self::WorkflowReopened { envelope, .. }
553            | Self::WorkflowPaused { envelope, .. }
554            | Self::WorkflowResumed { envelope, .. }
555            | Self::SearchAttributesUpdated { envelope, .. }
556            | Self::ActivityScheduled { envelope, .. }
557            | Self::ActivityStarted { envelope, .. }
558            | Self::ActivityCompleted { envelope, .. }
559            | Self::ActivityFailed { envelope, .. }
560            | Self::ActivityCancelled { envelope, .. }
561            | Self::TimerStarted { envelope, .. }
562            | Self::TimerFired { envelope, .. }
563            | Self::TimerCancelled { envelope, .. }
564            | Self::WithTimeoutCompleted { envelope, .. }
565            | Self::SignalReceived { envelope, .. }
566            | Self::SignalSent { envelope, .. }
567            | Self::ChildWorkflowStarted { envelope, .. }
568            | Self::ChildWorkflowCompleted { envelope, .. }
569            | Self::ChildWorkflowFailed { envelope, .. }
570            | Self::ChildWorkflowCancelled { envelope, .. }
571            | Self::ScheduleCreated { envelope, .. }
572            | Self::ScheduleUpdated { envelope, .. }
573            | Self::SchedulePaused { envelope, .. }
574            | Self::ScheduleResumed { envelope, .. }
575            | Self::ScheduleDeleted { envelope, .. }
576            | Self::ScheduleTriggered { envelope, .. } => envelope,
577        }
578    }
579
580    /// Returns the monotonic sequence number recorded for this event.
581    #[must_use]
582    pub const fn seq(&self) -> u64 {
583        self.envelope().seq
584    }
585
586    /// Returns the deterministic recorded timestamp for this event.
587    #[must_use]
588    pub const fn recorded_at(&self) -> &DateTime<Utc> {
589        &self.envelope().recorded_at
590    }
591
592    /// Returns the workflow history that owns this event.
593    #[must_use]
594    pub const fn workflow_id(&self) -> &WorkflowId {
595        &self.envelope().workflow_id
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use std::collections::HashMap;
602
603    use chrono::{DateTime, Utc};
604    use serde_json::json;
605
606    use super::{
607        DEFAULT_TASK_QUEUE, Event, EventEnvelope, LEGACY_ACTIVITY_ATTEMPT, TimerCancelCause,
608    };
609    use crate::{
610        ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
611        Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
612        WorkflowError, WorkflowId,
613    };
614
615    fn package_version() -> PackageVersion {
616        PackageVersion::new("a".repeat(64))
617    }
618
619    fn recorded_at() -> DateTime<Utc> {
620        DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
621    }
622
623    fn envelope(seq: u64) -> EventEnvelope {
624        EventEnvelope {
625            seq,
626            recorded_at: recorded_at(),
627            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
628        }
629    }
630
631    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
632        Payload::from_json(&json!({ "label": label }))
633    }
634
635    fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
636        Ok(ScheduleConfig {
637            trigger: TriggerSpec::Cron {
638                expression: String::from("0 0 * * *"),
639            },
640            overlap_policy: OverlapPolicy::Skip,
641            catch_up_policy: CatchUpPolicy::One,
642            workflow_type: String::from("checkout"),
643            input: payload(label)?,
644            search_attributes: HashMap::from([(
645                String::from("aion.namespace"),
646                crate::SearchAttributeValue::String(String::from("tenant-a")),
647            )]),
648        })
649    }
650
651    fn workflow_error(message: &str) -> WorkflowError {
652        WorkflowError {
653            message: String::from(message),
654            details: None,
655        }
656    }
657
658    fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
659        ActivityError {
660            kind,
661            message: String::from(message),
662            details: None,
663        }
664    }
665
666    fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
667        let json = serde_json::to_string(event)?;
668        let decoded = serde_json::from_str::<Event>(&json)?;
669        assert_eq!(*event, decoded);
670        Ok(())
671    }
672
673    /// NSTQ-3: a recorded `ActivityScheduled` carries its `task_queue` through the durable JSON
674    /// wire so reopen/recovery can re-target the same pool.
675    #[test]
676    fn activity_scheduled_records_and_reads_back_its_task_queue()
677    -> Result<(), Box<dyn std::error::Error>> {
678        let event = Event::ActivityScheduled {
679            envelope: envelope(6),
680            activity_id: ActivityId::from_sequence_position(6),
681            activity_type: String::from("charge-card"),
682            input: payload("activity-input")?,
683            task_queue: String::from("claude"),
684            node: None,
685        };
686
687        let json = serde_json::to_string(&event)?;
688        let decoded = serde_json::from_str::<Event>(&json)?;
689
690        match decoded {
691            Event::ActivityScheduled { task_queue, .. } => {
692                assert_eq!(
693                    task_queue, "claude",
694                    "the recorded task queue must survive the round-trip"
695                );
696            }
697            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
698        }
699        Ok(())
700    }
701
702    /// NSTQ-3 replay-safety (the load-bearing test): an OLD recorded history that has no
703    /// `task_queue` key on its `ActivityScheduled` events MUST still decode, defaulting the missing
704    /// value to the named `"default"` task queue, deterministically — never panic, never differ
705    /// run-to-run. The old wire form is the exact pre-field bytes: the current serialization with
706    /// the `task_queue` key removed.
707    #[test]
708    fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
709    -> Result<(), Box<dyn std::error::Error>> {
710        // Build a current event, serialize, then strip the `task_queue` key to reconstruct exactly
711        // what a history recorded before the field existed looks like on the wire.
712        let current = Event::ActivityScheduled {
713            envelope: envelope(6),
714            activity_id: ActivityId::from_sequence_position(6),
715            activity_type: String::from("charge-card"),
716            input: payload("activity-input")?,
717            task_queue: String::from("ignored-when-stripped"),
718            node: Some(String::from("ignored-when-stripped")),
719        };
720        let mut value = serde_json::to_value(&current)?;
721        let data = value
722            .get_mut("data")
723            .and_then(serde_json::Value::as_object_mut)
724            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
725        assert!(
726            data.remove("task_queue").is_some(),
727            "the current wire form must contain task_queue before we strip it"
728        );
729
730        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
731        // back the named default, deterministically.
732        let old_wire = serde_json::to_string(&value)?;
733        for _ in 0..4 {
734            let decoded = serde_json::from_str::<Event>(&old_wire)?;
735            match &decoded {
736                Event::ActivityScheduled { task_queue, .. } => {
737                    assert_eq!(
738                        task_queue, DEFAULT_TASK_QUEUE,
739                        "a missing task_queue must default to the named default queue"
740                    );
741                    assert_eq!(task_queue, "default");
742                }
743                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
744            }
745        }
746        Ok(())
747    }
748
749    /// NODE-3: a recorded `ActivityScheduled` carries its OPTIONAL `node` affinity through the
750    /// durable JSON wire so reopen/recovery can re-target the same node.
751    #[test]
752    fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
753    {
754        let event = Event::ActivityScheduled {
755            envelope: envelope(6),
756            activity_id: ActivityId::from_sequence_position(6),
757            activity_type: String::from("charge-card"),
758            input: payload("activity-input")?,
759            task_queue: String::from("claude"),
760            node: Some(String::from("box-7")),
761        };
762
763        let json = serde_json::to_string(&event)?;
764        let decoded = serde_json::from_str::<Event>(&json)?;
765
766        match decoded {
767            Event::ActivityScheduled { node, .. } => {
768                assert_eq!(
769                    node.as_deref(),
770                    Some("box-7"),
771                    "the recorded node affinity must survive the round-trip"
772                );
773            }
774            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
775        }
776        Ok(())
777    }
778
779    /// NODE-3 replay-safety (the load-bearing test): an OLD recorded history that has no `node` key
780    /// on its `ActivityScheduled` events MUST still decode, defaulting the missing value to `None`
781    /// (no affinity) deterministically — never a sentinel, never panic, never differ run-to-run.
782    /// The old wire form is the exact pre-field bytes: the current serialization with the `node`
783    /// key removed.
784    #[test]
785    fn activity_scheduled_decodes_old_history_without_node_as_none()
786    -> Result<(), Box<dyn std::error::Error>> {
787        // Build a current event with a node set, serialize, then strip the `node` key to
788        // reconstruct exactly what a history recorded before the field existed looks like on the
789        // wire.
790        let current = Event::ActivityScheduled {
791            envelope: envelope(6),
792            activity_id: ActivityId::from_sequence_position(6),
793            activity_type: String::from("charge-card"),
794            input: payload("activity-input")?,
795            task_queue: String::from("default"),
796            node: Some(String::from("ignored-when-stripped")),
797        };
798        let mut value = serde_json::to_value(&current)?;
799        let data = value
800            .get_mut("data")
801            .and_then(serde_json::Value::as_object_mut)
802            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
803        assert!(
804            data.remove("node").is_some(),
805            "the current wire form must contain node before we strip it"
806        );
807
808        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
809        // back `None`, deterministically.
810        let old_wire = serde_json::to_string(&value)?;
811        for _ in 0..4 {
812            let decoded = serde_json::from_str::<Event>(&old_wire)?;
813            match &decoded {
814                Event::ActivityScheduled { node, .. } => {
815                    assert_eq!(
816                        *node, None,
817                        "a missing node must default to None (no affinity)"
818                    );
819                }
820                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
821            }
822        }
823        Ok(())
824    }
825
826    /// NOI-0 positive round-trip: `ActivityStarted`, `ActivityCompleted`, and `ActivityCancelled`
827    /// each carry a genuine one-based `attempt` through the durable JSON wire, so replay reads back
828    /// the same attempt that was recorded — a completed activity has one consistent attempt readable
829    /// off BOTH its start and its terminal (the invariant the NOI design keys on).
830    #[test]
831    fn activity_lifecycle_records_and_reads_back_its_attempt()
832    -> Result<(), Box<dyn std::error::Error>> {
833        let started = Event::ActivityStarted {
834            envelope: envelope(7),
835            activity_id: ActivityId::from_sequence_position(6),
836            attempt: 3,
837        };
838        let completed = Event::ActivityCompleted {
839            envelope: envelope(8),
840            activity_id: ActivityId::from_sequence_position(6),
841            result: payload("activity-result")?,
842            attempt: 3,
843        };
844        let cancelled = Event::ActivityCancelled {
845            envelope: envelope(9),
846            activity_id: ActivityId::from_sequence_position(6),
847            attempt: 3,
848        };
849
850        for event in [&started, &completed, &cancelled] {
851            round_trip(event)?;
852        }
853
854        // Read the attempt back off each decoded terminal — it must be the recorded value, not the
855        // legacy sentinel.
856        match serde_json::from_str::<Event>(&serde_json::to_string(&started)?)? {
857            Event::ActivityStarted { attempt, .. } => assert_eq!(attempt, 3),
858            other => return Err(format!("expected ActivityStarted, got {other:?}").into()),
859        }
860        match serde_json::from_str::<Event>(&serde_json::to_string(&completed)?)? {
861            Event::ActivityCompleted { attempt, .. } => assert_eq!(attempt, 3),
862            other => return Err(format!("expected ActivityCompleted, got {other:?}").into()),
863        }
864        match serde_json::from_str::<Event>(&serde_json::to_string(&cancelled)?)? {
865            Event::ActivityCancelled { attempt, .. } => assert_eq!(attempt, 3),
866            other => return Err(format!("expected ActivityCancelled, got {other:?}").into()),
867        }
868        Ok(())
869    }
870
871    /// NOI-0 replay-safety (the load-bearing negative control): an OLD recorded history that has no
872    /// `attempt` key on its `ActivityStarted` / `ActivityCompleted` / `ActivityCancelled` events MUST
873    /// still decode without panic, defaulting the missing value to the legacy sentinel
874    /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) deterministically — never differ run-to-run. Because real
875    /// attempts are one-based, `0` can never collide with a genuine attempt. The old wire form is the
876    /// exact pre-field bytes: the current serialization with the `attempt` key removed.
877    #[test]
878    fn activity_lifecycle_decodes_old_history_without_attempt_as_legacy_sentinel()
879    -> Result<(), Box<dyn std::error::Error>> {
880        // One current event per variant, each with a NON-sentinel attempt so we can prove the strip
881        // (not the value) is what drives the default on decode.
882        let started = Event::ActivityStarted {
883            envelope: envelope(7),
884            activity_id: ActivityId::from_sequence_position(6),
885            attempt: 5,
886        };
887        let completed = Event::ActivityCompleted {
888            envelope: envelope(8),
889            activity_id: ActivityId::from_sequence_position(6),
890            result: payload("activity-result")?,
891            attempt: 5,
892        };
893        let cancelled = Event::ActivityCancelled {
894            envelope: envelope(9),
895            activity_id: ActivityId::from_sequence_position(6),
896            attempt: 5,
897        };
898
899        // Strip the `attempt` key from each to reconstruct exactly what a pre-NOI-0 history looks
900        // like on the wire, then decode the stripped form repeatedly: it must succeed and always read
901        // back the legacy sentinel, deterministically.
902        for current in [&started, &completed, &cancelled] {
903            let mut value = serde_json::to_value(current)?;
904            let data = value
905                .get_mut("data")
906                .and_then(serde_json::Value::as_object_mut)
907                .ok_or("activity lifecycle event must serialize to a tagged object with `data`")?;
908            assert!(
909                data.remove("attempt").is_some(),
910                "the current wire form must contain attempt before we strip it"
911            );
912            let old_wire = serde_json::to_string(&value)?;
913            for _ in 0..4 {
914                let decoded = serde_json::from_str::<Event>(&old_wire)?;
915                let attempt = match &decoded {
916                    Event::ActivityStarted { attempt, .. }
917                    | Event::ActivityCompleted { attempt, .. }
918                    | Event::ActivityCancelled { attempt, .. } => *attempt,
919                    other => {
920                        return Err(
921                            format!("expected an activity lifecycle event, got {other:?}").into(),
922                        );
923                    }
924                };
925                assert_eq!(
926                    attempt, LEGACY_ACTIVITY_ATTEMPT,
927                    "a missing attempt must default to the legacy sentinel (0)"
928                );
929                assert_eq!(attempt, 0);
930            }
931        }
932        Ok(())
933    }
934
935    /// Replay-safety proof for the `cause` field on `TimerCancelled` (#222):
936    /// a history recorded BEFORE the field existed has no `cause` key and MUST
937    /// decode without panic, defaulting to `WorkflowIntent` — the pre-field
938    /// behavior (a reopen never resurrects it) — deterministically. The old
939    /// wire form is the exact pre-field bytes: the current serialization with
940    /// the `cause` key removed.
941    #[test]
942    fn timer_cancelled_decodes_old_history_without_cause_as_workflow_intent()
943    -> Result<(), Box<dyn std::error::Error>> {
944        // A NON-default cause proves the strip (not the value) drives the default.
945        let cancelled = Event::TimerCancelled {
946            envelope: envelope(7),
947            timer_id: TimerId::named("deadline")?,
948            cause: TimerCancelCause::CancelTeardown,
949        };
950
951        let mut value = serde_json::to_value(&cancelled)?;
952        let data = value
953            .get_mut("data")
954            .and_then(serde_json::Value::as_object_mut)
955            .ok_or("TimerCancelled must serialize to a tagged object with `data`")?;
956        assert!(
957            data.remove("cause").is_some(),
958            "the current wire form must contain cause before we strip it"
959        );
960        let old_wire = serde_json::to_string(&value)?;
961        for _ in 0..4 {
962            let decoded = serde_json::from_str::<Event>(&old_wire)?;
963            match &decoded {
964                Event::TimerCancelled { cause, .. } => assert_eq!(
965                    *cause,
966                    TimerCancelCause::WorkflowIntent,
967                    "a missing cause must default to WorkflowIntent (never resurrected)"
968                ),
969                other => {
970                    return Err(format!("expected TimerCancelled, got {other:?}").into());
971                }
972            }
973        }
974        Ok(())
975    }
976
977    /// Pause/resume (#204) round-trip: the two new NON-terminal lifecycle markers
978    /// carry plain fields and survive the durable JSON wire unchanged.
979    #[test]
980    fn pause_resume_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
981        let events = vec![
982            Event::WorkflowPaused {
983                envelope: envelope(2),
984                run_id: RunId::new(uuid::Uuid::from_u128(1)),
985                reason: Some(String::from("operator hold")),
986                operator: Some(String::from("tom")),
987            },
988            Event::WorkflowPaused {
989                envelope: envelope(3),
990                run_id: RunId::new(uuid::Uuid::from_u128(1)),
991                reason: None,
992                operator: None,
993            },
994            Event::WorkflowResumed {
995                envelope: envelope(4),
996                run_id: RunId::new(uuid::Uuid::from_u128(1)),
997                operator: Some(String::from("tom")),
998            },
999        ];
1000        for event in &events {
1001            round_trip(event)?;
1002        }
1003        Ok(())
1004    }
1005
1006    /// GATE-6 back-compat: an OLD history serialized before pause/resume existed
1007    /// decodes byte-identically — it simply never contains the new variants. We
1008    /// prove the whole event enum still decodes an old-shape history with no new
1009    /// variants present (the decode round-trip test the brief requires), and that
1010    /// adding the variants did not change the encoding of any existing variant.
1011    #[test]
1012    fn old_history_without_pause_resume_decodes_unchanged() -> Result<(), Box<dyn std::error::Error>>
1013    {
1014        let started = Event::WorkflowStarted {
1015            envelope: envelope(1),
1016            workflow_type: String::from("checkout"),
1017            input: payload("input")?,
1018            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1019            parent_run_id: None,
1020            package_version: package_version(),
1021        };
1022        let completed = Event::WorkflowCompleted {
1023            envelope: envelope(2),
1024            result: payload("result")?,
1025        };
1026        // Serialize an old-shape history and decode it back: no new variant is
1027        // present, and every existing variant round-trips exactly.
1028        let history = vec![started, completed];
1029        let json = serde_json::to_string(&history)?;
1030        let decoded = serde_json::from_str::<Vec<Event>>(&json)?;
1031        assert_eq!(history, decoded);
1032        Ok(())
1033    }
1034
1035    /// #144: the start-time task queue projects from the `aion.task_queue`
1036    /// search attribute recorded by `SearchAttributesUpdated`, mirroring the
1037    /// `aion.namespace` projection. A later update overrides an earlier value.
1038    #[test]
1039    fn start_time_task_queue_projects_from_recorded_attribute()
1040    -> Result<(), Box<dyn std::error::Error>> {
1041        use super::{START_TIME_TASK_QUEUE_ATTRIBUTE, start_time_task_queue};
1042        use crate::SearchAttributeValue;
1043
1044        let events = vec![
1045            Event::WorkflowStarted {
1046                envelope: envelope(1),
1047                workflow_type: String::from("checkout"),
1048                input: payload("input")?,
1049                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1050                parent_run_id: None,
1051                package_version: package_version(),
1052            },
1053            Event::SearchAttributesUpdated {
1054                envelope: envelope(2),
1055                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1056                attributes: HashMap::from([(
1057                    START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
1058                    SearchAttributeValue::String(String::from("gpu")),
1059                )]),
1060            },
1061        ];
1062
1063        assert_eq!(start_time_task_queue(&events).as_deref(), Some("gpu"));
1064        Ok(())
1065    }
1066
1067    /// #144 back-compat: a history with no recorded `aion.task_queue` attribute
1068    /// projects `None`, so callers fall back to the named default.
1069    #[test]
1070    fn start_time_task_queue_is_none_without_the_attribute()
1071    -> Result<(), Box<dyn std::error::Error>> {
1072        use super::start_time_task_queue;
1073
1074        let events = vec![Event::WorkflowStarted {
1075            envelope: envelope(1),
1076            workflow_type: String::from("checkout"),
1077            input: payload("input")?,
1078            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1079            parent_run_id: None,
1080            package_version: package_version(),
1081        }];
1082
1083        assert_eq!(start_time_task_queue(&events), None);
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
1089        let workflow_id = WorkflowId::new_v4();
1090        let recorded_at = recorded_at();
1091        let envelope = EventEnvelope {
1092            seq: 17,
1093            recorded_at,
1094            workflow_id: workflow_id.clone(),
1095        };
1096        let event = Event::WorkflowStarted {
1097            envelope,
1098            workflow_type: String::from("checkout"),
1099            input: payload("input")?,
1100            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1101            parent_run_id: None,
1102            package_version: package_version(),
1103        };
1104
1105        assert_eq!(event.seq(), 17);
1106        assert_eq!(event.recorded_at(), &recorded_at);
1107        assert_eq!(event.workflow_id(), &workflow_id);
1108        Ok(())
1109    }
1110
1111    #[test]
1112    fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1113        let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
1114        let events = vec![
1115            Event::WorkflowStarted {
1116                envelope: envelope(1),
1117                workflow_type: String::from("checkout"),
1118                input: payload("workflow-input")?,
1119                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1120                parent_run_id: None,
1121                package_version: package_version(),
1122            },
1123            Event::WorkflowCompleted {
1124                envelope: envelope(2),
1125                result: payload("workflow-result")?,
1126            },
1127            Event::WorkflowFailed {
1128                envelope: envelope(3),
1129                error: workflow_error("workflow failed"),
1130            },
1131            Event::WorkflowCancelled {
1132                envelope: envelope(4),
1133                reason: String::from("caller requested cancellation"),
1134            },
1135            Event::WorkflowTimedOut {
1136                envelope: envelope(5),
1137                timeout: String::from("execution"),
1138            },
1139            Event::ActivityScheduled {
1140                envelope: envelope(6),
1141                activity_id: ActivityId::from_sequence_position(6),
1142                activity_type: String::from("charge-card"),
1143                input: payload("activity-input")?,
1144                task_queue: String::from("claude"),
1145                node: Some(String::from("box-7")),
1146            },
1147            Event::ActivityStarted {
1148                envelope: envelope(7),
1149                activity_id: ActivityId::from_sequence_position(6),
1150                attempt: 1,
1151            },
1152            Event::ActivityCompleted {
1153                envelope: envelope(8),
1154                activity_id: ActivityId::from_sequence_position(6),
1155                result: payload("activity-result")?,
1156                attempt: 1,
1157            },
1158            Event::ActivityFailed {
1159                envelope: envelope(9),
1160                activity_id: ActivityId::from_sequence_position(6),
1161                error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
1162                attempt: 1,
1163            },
1164            Event::ActivityCancelled {
1165                envelope: envelope(10),
1166                activity_id: ActivityId::from_sequence_position(6),
1167                attempt: 1,
1168            },
1169            Event::TimerStarted {
1170                envelope: envelope(11),
1171                timer_id: TimerId::anonymous(11),
1172                fire_at,
1173            },
1174            Event::TimerFired {
1175                envelope: envelope(12),
1176                timer_id: TimerId::anonymous(11),
1177            },
1178            Event::TimerCancelled {
1179                envelope: envelope(13),
1180                timer_id: TimerId::named("reminder")?,
1181                cause: TimerCancelCause::WorkflowIntent,
1182            },
1183            Event::SignalReceived {
1184                envelope: envelope(14),
1185                name: String::from("approve"),
1186                payload: payload("signal")?,
1187            },
1188            Event::SignalSent {
1189                envelope: envelope(15),
1190                target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
1191                name: String::from("approve"),
1192                payload: payload("signal-sent")?,
1193            },
1194        ];
1195
1196        for event in events {
1197            round_trip(&event)?;
1198        }
1199        Ok(())
1200    }
1201
1202    #[test]
1203    fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1204        let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
1205        let events = vec![
1206            Event::ChildWorkflowStarted {
1207                envelope: envelope(16),
1208                child_workflow_id: child_workflow_id.clone(),
1209                workflow_type: String::from("fulfillment"),
1210                input: payload("child-input")?,
1211                package_version: package_version(),
1212            },
1213            Event::ChildWorkflowCompleted {
1214                envelope: envelope(16),
1215                child_workflow_id: child_workflow_id.clone(),
1216                result: payload("child-result")?,
1217            },
1218            Event::ChildWorkflowFailed {
1219                envelope: envelope(17),
1220                child_workflow_id: child_workflow_id.clone(),
1221                error: workflow_error("child failed"),
1222            },
1223            Event::ChildWorkflowCancelled {
1224                envelope: envelope(18),
1225                child_workflow_id,
1226            },
1227        ];
1228
1229        for event in events {
1230            round_trip(&event)?;
1231        }
1232        Ok(())
1233    }
1234
1235    #[test]
1236    fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1237        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
1238        let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
1239        let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
1240        let events = vec![
1241            Event::WorkflowContinuedAsNew {
1242                envelope: envelope(19),
1243                input: payload("continued-input")?,
1244                workflow_type: Some(String::from("checkout-v2")),
1245                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
1246            },
1247            Event::SearchAttributesUpdated {
1248                envelope: envelope(20),
1249                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1250                attributes: HashMap::from([(
1251                    String::from("customer_id"),
1252                    SearchAttributeValue::String(String::from("cust-123")),
1253                )]),
1254            },
1255            Event::ScheduleCreated {
1256                envelope: envelope(20),
1257                schedule_id: schedule_id.clone(),
1258                config: schedule_config("schedule-created")?,
1259            },
1260            Event::ScheduleUpdated {
1261                envelope: envelope(21),
1262                schedule_id: schedule_id.clone(),
1263                config: schedule_config("schedule-updated")?,
1264            },
1265            Event::SchedulePaused {
1266                envelope: envelope(22),
1267                schedule_id: schedule_id.clone(),
1268            },
1269            Event::ScheduleResumed {
1270                envelope: envelope(23),
1271                schedule_id: schedule_id.clone(),
1272            },
1273            Event::ScheduleDeleted {
1274                envelope: envelope(24),
1275                schedule_id: schedule_id.clone(),
1276            },
1277            Event::ScheduleTriggered {
1278                envelope: envelope(25),
1279                schedule_id,
1280                workflow_id: triggered_workflow_id,
1281                run_id: triggered_run_id,
1282            },
1283        ];
1284
1285        for event in events {
1286            round_trip(&event)?;
1287        }
1288        Ok(())
1289    }
1290}