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    /// The engine DISPATCHED an activity attempt to its task queue.
274    ///
275    /// **This does NOT mean a worker has taken the work.** The event is written
276    /// by the engine at dispatch time, in the SAME atomic append as its
277    /// [`Event::ActivityScheduled`] and under the same `recorded_at`, before any
278    /// worker has been selected — let alone leased the attempt. Worker selection
279    /// happens afterwards, in the server, and may wait indefinitely: a dispatch
280    /// to a task queue nobody serves records this event and then nothing, so a
281    /// run parked forever and a run a worker is actively executing have
282    /// identical history shapes and both project
283    /// [`WorkflowStatus::Running`](crate::WorkflowStatus::Running). That is not
284    /// a projection bug — history genuinely holds no terminal event — but it
285    /// means this event alone can never answer "is anyone working on it".
286    ///
287    /// Every producing seam behaves this way: the single-dispatch and in-VM
288    /// seams (`aion::runtime::nif_activity_dispatch`,
289    /// `aion::runtime::nif_activity_in_vm`), the retry delivery
290    /// (`aion::runtime::nif_activity_retry_dispatch`, which records the next
291    /// attempt's start "before it goes on the wire"), and the fan-out batch
292    /// (`aion::durability::recorder::fan_out`).
293    ///
294    /// The name is therefore wrong and the behaviour is not. A rename to
295    /// `ActivityDispatched` is PROPOSED and PENDING an owner ruling, together
296    /// with the question of whether a separate lease-time event should exist;
297    /// see `docs/design/aion-authoring/ACTIVITY-STARTED-SEMANTICS-DECISION.md`.
298    /// Deferring THIS event to lease time is recommended against there, because
299    /// it would silently change what every already-recorded `ActivityStarted`
300    /// meant.
301    ///
302    /// To ask whether an in-flight activity can still reach a worker, join its
303    /// recorded address to the live fleet — `aion_server`'s
304    /// `worker::ActivityReachability`, surfaced on `POST /workflows/describe`.
305    ActivityStarted {
306        /// Recording metadata for this event.
307        envelope: EventEnvelope,
308        /// Activity that was dispatched.
309        activity_id: ActivityId,
310        /// One-based activity attempt number this start belongs to (NOI-0).
311        ///
312        /// Matches the `attempt` on the [`Event::ActivityFailed`] / [`Event::ActivityCompleted`] /
313        /// [`Event::ActivityCancelled`] that terminates the SAME attempt, so
314        /// `(workflow, activity, attempt)` is a stable identity across the whole lifecycle — the key
315        /// the NOI dedupe/guard/session-id design is built on.
316        ///
317        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
318        /// `ActivityStarted` events. Decode defaults the missing value to
319        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
320        /// differs run-to-run. Because real attempts are one-based, `0` is a distinguishable
321        /// legacy/unknown sentinel, never a genuine attempt. The encoding of the existing fields is
322        /// untouched.
323        #[serde(default = "legacy_activity_attempt")]
324        attempt: u32,
325    },
326    /// An activity completed successfully.
327    ActivityCompleted {
328        /// Recording metadata for this event.
329        envelope: EventEnvelope,
330        /// Activity that produced the result.
331        activity_id: ActivityId,
332        /// Opaque activity result payload.
333        result: Payload,
334        /// One-based activity attempt number that produced this completion (NOI-0).
335        ///
336        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so a
337        /// completed activity carries one consistent `attempt` readable off both its start and its
338        /// terminal — the negative-control invariant NOI-0 gates on.
339        ///
340        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
341        /// `ActivityCompleted` events. Decode defaults the missing value to
342        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
343        /// differs run-to-run. The encoding of the existing fields is untouched.
344        #[serde(default = "legacy_activity_attempt")]
345        attempt: u32,
346    },
347    /// An activity attempt failed.
348    ///
349    /// The `attempt` field together with [`ActivityError`]'s retryable or terminal classification
350    /// lets replay distinguish a retryable interim failure from a terminal one for the same
351    /// [`ActivityId`].
352    ActivityFailed {
353        /// Recording metadata for this event.
354        envelope: EventEnvelope,
355        /// Activity whose attempt failed.
356        activity_id: ActivityId,
357        /// Classified activity failure.
358        error: ActivityError,
359        /// One-based activity attempt number that produced this failure.
360        attempt: u32,
361    },
362    /// An ADVISORY activity spent its whole attempt budget and failed for good
363    /// (RUNTIME-OPERATIONS.md R5).
364    ///
365    /// The warning the class promises: an advisory action is a side channel
366    /// (a heartbeat, a notification), so its exhaustion never faults the
367    /// calling step — but it must never be silent either. This event is that
368    /// visibility. It ACCOMPANIES the activity's honest terminal
369    /// [`Event::ActivityFailed`]; it never replaces it, because the activity
370    /// really did fail and history says so.
371    ///
372    /// Non-terminal for the workflow: it says nothing about the run's
373    /// outcome, so status projection deliberately ignores it.
374    ActivityAdvisoryExhausted {
375        /// Recording metadata for this event.
376        envelope: EventEnvelope,
377        /// Advisory activity whose attempt budget was spent.
378        activity_id: ActivityId,
379        /// Activity type of the exhausted advisory activity.
380        activity_type: String,
381        /// The last attempt's failure reason, verbatim — the same string the
382        /// accompanying terminal [`Event::ActivityFailed`] carries.
383        reason: String,
384        /// One-based number of the attempt that spent the budget.
385        attempt: u32,
386    },
387    /// An activity was cancelled as an explicit cancellation outcome.
388    ActivityCancelled {
389        /// Recording metadata for this event.
390        envelope: EventEnvelope,
391        /// Activity that was cancelled.
392        activity_id: ActivityId,
393        /// One-based activity attempt number that was cancelled (NOI-0).
394        ///
395        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so the
396        /// cancellation terminal is attributable to a specific attempt exactly like
397        /// [`Event::ActivityFailed`] is.
398        ///
399        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
400        /// `ActivityCancelled` events. Decode defaults the missing value to
401        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
402        /// differs run-to-run. The encoding of the existing fields is untouched.
403        #[serde(default = "legacy_activity_attempt")]
404        attempt: u32,
405    },
406    /// A timer was scheduled to fire at a deterministic timestamp.
407    TimerStarted {
408        /// Recording metadata for this event.
409        envelope: EventEnvelope,
410        /// Timer selected by workflow code or assigned by the engine.
411        timer_id: TimerId,
412        /// UTC timestamp at which the timer becomes eligible to fire.
413        fire_at: DateTime<Utc>,
414    },
415    /// A timer fired.
416    TimerFired {
417        /// Recording metadata for this event.
418        envelope: EventEnvelope,
419        /// Timer that fired.
420        timer_id: TimerId,
421    },
422    /// A timer was cancelled as an explicit cancellation outcome.
423    TimerCancelled {
424        /// Recording metadata for this event.
425        envelope: EventEnvelope,
426        /// Timer that was cancelled.
427        timer_id: TimerId,
428        /// Who retired the timer. Decides reopen behavior: a
429        /// [`TimerCancelCause::CancelTeardown`] cancellation is re-armed when the
430        /// run is reopened; a [`TimerCancelCause::WorkflowIntent`] cancellation is
431        /// permanent.
432        ///
433        /// Replay-safety: histories recorded before this field existed have no
434        /// `cause` key. Decode defaults the missing value to
435        /// [`TimerCancelCause::WorkflowIntent`] via `#[serde(default)]` — the
436        /// pre-field behavior (never resurrected), never panics, never differs
437        /// run-to-run. The encoding of the existing fields is untouched.
438        #[serde(default)]
439        cause: TimerCancelCause,
440    },
441    /// A `with_timeout` operation reached a durable terminal outcome.
442    WithTimeoutCompleted {
443        /// Recording metadata for this event.
444        envelope: EventEnvelope,
445        /// Timer that bounded the operation.
446        timer_id: TimerId,
447        /// Recorded timeout outcome.
448        outcome: WithTimeoutOutcome,
449        /// JSON-encoded BEAM term payload for completed operation results.
450        result: Option<Payload>,
451    },
452    /// A signal was delivered to the workflow.
453    SignalReceived {
454        /// Recording metadata for this event.
455        envelope: EventEnvelope,
456        /// Signal name selected by the sender.
457        name: String,
458        /// Opaque signal payload.
459        payload: Payload,
460    },
461    /// A signal was sent by this workflow to another workflow.
462    SignalSent {
463        /// Recording metadata for this event.
464        envelope: EventEnvelope,
465        /// Target workflow identifier selected by workflow code.
466        target_workflow_id: WorkflowId,
467        /// Signal name selected by workflow code.
468        name: String,
469        /// Opaque signal payload.
470        payload: Payload,
471    },
472    /// A child workflow was started.
473    ChildWorkflowStarted {
474        /// Recording metadata for this event.
475        envelope: EventEnvelope,
476        /// Child workflow identifier.
477        child_workflow_id: WorkflowId,
478        /// Child workflow type selected by the parent.
479        workflow_type: String,
480        /// Opaque child workflow input payload.
481        input: Payload,
482        /// Package version resolved for the child at record time.
483        ///
484        /// The crash-repair sweep and the child's own start use exactly this
485        /// recorded version, so the crash path resolves identically to the
486        /// crash-free path.
487        package_version: PackageVersion,
488    },
489    /// A child workflow completed successfully.
490    ChildWorkflowCompleted {
491        /// Recording metadata for this event.
492        envelope: EventEnvelope,
493        /// Child workflow that produced the result.
494        child_workflow_id: WorkflowId,
495        /// Opaque child workflow result payload.
496        result: Payload,
497    },
498    /// A child workflow failed terminally.
499    ChildWorkflowFailed {
500        /// Recording metadata for this event.
501        envelope: EventEnvelope,
502        /// Child workflow that failed.
503        child_workflow_id: WorkflowId,
504        /// Terminal child workflow failure.
505        error: WorkflowError,
506    },
507    /// A child workflow was cancelled as an explicit cancellation outcome.
508    ChildWorkflowCancelled {
509        /// Recording metadata for this event.
510        envelope: EventEnvelope,
511        /// Child workflow that was cancelled.
512        child_workflow_id: WorkflowId,
513    },
514    /// A schedule resource was created.
515    ScheduleCreated {
516        /// Recording metadata for this event.
517        envelope: EventEnvelope,
518        /// Schedule resource that was created.
519        schedule_id: ScheduleId,
520        /// Persisted schedule configuration.
521        config: ScheduleConfig,
522    },
523    /// A schedule resource was updated.
524    ScheduleUpdated {
525        /// Recording metadata for this event.
526        envelope: EventEnvelope,
527        /// Schedule resource that was updated.
528        schedule_id: ScheduleId,
529        /// Updated schedule configuration.
530        config: ScheduleConfig,
531    },
532    /// A schedule resource was paused.
533    SchedulePaused {
534        /// Recording metadata for this event.
535        envelope: EventEnvelope,
536        /// Schedule resource that was paused.
537        schedule_id: ScheduleId,
538    },
539    /// A paused schedule resource was resumed.
540    ScheduleResumed {
541        /// Recording metadata for this event.
542        envelope: EventEnvelope,
543        /// Schedule resource that was resumed.
544        schedule_id: ScheduleId,
545    },
546    /// A schedule resource was deleted.
547    ScheduleDeleted {
548        /// Recording metadata for this event.
549        envelope: EventEnvelope,
550        /// Schedule resource that was deleted.
551        schedule_id: ScheduleId,
552    },
553    /// A schedule tick started a workflow execution.
554    ScheduleTriggered {
555        /// Recording metadata for this event.
556        envelope: EventEnvelope,
557        /// Schedule resource that fired.
558        schedule_id: ScheduleId,
559        /// Workflow execution started by the schedule tick.
560        workflow_id: WorkflowId,
561        /// Run started by the schedule tick.
562        run_id: RunId,
563    },
564}
565
566/// Durable terminal outcome for a `with_timeout` operation.
567#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
568pub enum WithTimeoutOutcome {
569    /// The operation closure returned before the deadline.
570    OperationCompleted,
571    /// The deadline fired before the operation completed.
572    TimedOut,
573}
574
575/// Who retired a durable timer, recorded on [`Event::TimerCancelled`].
576///
577/// The distinction decides reopen semantics. A timer the WORKFLOW retired —
578/// an SDK `cancel_timer` call or a `with_timeout` scope settling because the
579/// racing operation won — is a business fact: reopen must never resurrect it.
580/// A timer the ENGINE retired while tearing down a cancelled run
581/// (`Engine::cancel`'s in-flight timer cleanup) is bookkeeping: the deadline
582/// itself was never reached or waived, so reopening the run re-arms it at its
583/// original `fire_at`.
584#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, Default, PartialEq, Eq)]
585pub enum TimerCancelCause {
586    /// Workflow code retired the timer (SDK cancel or a settled timeout scope).
587    ///
588    /// The serde default: histories recorded before this field existed decode
589    /// as workflow intent, preserving their pre-field never-resurrected
590    /// behavior.
591    #[default]
592    WorkflowIntent,
593    /// The engine retired the timer while cancelling its workflow run.
594    CancelTeardown,
595}
596
597impl Event {
598    /// Returns the envelope recorded with this event.
599    #[must_use]
600    pub const fn envelope(&self) -> &EventEnvelope {
601        match self {
602            Self::WorkflowStarted { envelope, .. }
603            | Self::WorkflowCompleted { envelope, .. }
604            | Self::WorkflowFailed { envelope, .. }
605            | Self::WorkflowCancelled { envelope, .. }
606            | Self::WorkflowTimedOut { envelope, .. }
607            | Self::WorkflowContinuedAsNew { envelope, .. }
608            | Self::WorkflowReopened { envelope, .. }
609            | Self::WorkflowPaused { envelope, .. }
610            | Self::WorkflowResumed { envelope, .. }
611            | Self::SearchAttributesUpdated { envelope, .. }
612            | Self::ActivityScheduled { envelope, .. }
613            | Self::ActivityStarted { envelope, .. }
614            | Self::ActivityCompleted { envelope, .. }
615            | Self::ActivityFailed { envelope, .. }
616            | Self::ActivityAdvisoryExhausted { envelope, .. }
617            | Self::ActivityCancelled { envelope, .. }
618            | Self::TimerStarted { envelope, .. }
619            | Self::TimerFired { envelope, .. }
620            | Self::TimerCancelled { envelope, .. }
621            | Self::WithTimeoutCompleted { envelope, .. }
622            | Self::SignalReceived { envelope, .. }
623            | Self::SignalSent { envelope, .. }
624            | Self::ChildWorkflowStarted { envelope, .. }
625            | Self::ChildWorkflowCompleted { envelope, .. }
626            | Self::ChildWorkflowFailed { envelope, .. }
627            | Self::ChildWorkflowCancelled { envelope, .. }
628            | Self::ScheduleCreated { envelope, .. }
629            | Self::ScheduleUpdated { envelope, .. }
630            | Self::SchedulePaused { envelope, .. }
631            | Self::ScheduleResumed { envelope, .. }
632            | Self::ScheduleDeleted { envelope, .. }
633            | Self::ScheduleTriggered { envelope, .. } => envelope,
634        }
635    }
636
637    /// Returns the monotonic sequence number recorded for this event.
638    #[must_use]
639    pub const fn seq(&self) -> u64 {
640        self.envelope().seq
641    }
642
643    /// Returns the deterministic recorded timestamp for this event.
644    #[must_use]
645    pub const fn recorded_at(&self) -> &DateTime<Utc> {
646        &self.envelope().recorded_at
647    }
648
649    /// Returns the workflow history that owns this event.
650    #[must_use]
651    pub const fn workflow_id(&self) -> &WorkflowId {
652        &self.envelope().workflow_id
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use std::collections::HashMap;
659
660    use chrono::{DateTime, Utc};
661    use serde_json::json;
662
663    use super::{
664        DEFAULT_TASK_QUEUE, Event, EventEnvelope, LEGACY_ACTIVITY_ATTEMPT, TimerCancelCause,
665    };
666    use crate::{
667        ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
668        Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
669        WorkflowError, WorkflowId,
670    };
671
672    fn package_version() -> PackageVersion {
673        PackageVersion::new("a".repeat(64))
674    }
675
676    fn recorded_at() -> DateTime<Utc> {
677        DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
678    }
679
680    fn envelope(seq: u64) -> EventEnvelope {
681        EventEnvelope {
682            seq,
683            recorded_at: recorded_at(),
684            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
685        }
686    }
687
688    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
689        Payload::from_json(&json!({ "label": label }))
690    }
691
692    fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
693        Ok(ScheduleConfig {
694            trigger: TriggerSpec::Cron {
695                expression: String::from("0 0 * * *"),
696            },
697            overlap_policy: OverlapPolicy::Skip,
698            catch_up_policy: CatchUpPolicy::One,
699            workflow_type: String::from("checkout"),
700            input: payload(label)?,
701            search_attributes: HashMap::from([(
702                String::from("aion.namespace"),
703                crate::SearchAttributeValue::String(String::from("tenant-a")),
704            )]),
705        })
706    }
707
708    fn workflow_error(message: &str) -> WorkflowError {
709        WorkflowError {
710            message: String::from(message),
711            details: None,
712        }
713    }
714
715    fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
716        ActivityError {
717            kind,
718            message: String::from(message),
719            details: None,
720        }
721    }
722
723    fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
724        let json = serde_json::to_string(event)?;
725        let decoded = serde_json::from_str::<Event>(&json)?;
726        assert_eq!(*event, decoded);
727        Ok(())
728    }
729
730    /// NSTQ-3: a recorded `ActivityScheduled` carries its `task_queue` through the durable JSON
731    /// wire so reopen/recovery can re-target the same pool.
732    #[test]
733    fn activity_scheduled_records_and_reads_back_its_task_queue()
734    -> Result<(), Box<dyn std::error::Error>> {
735        let event = Event::ActivityScheduled {
736            envelope: envelope(6),
737            activity_id: ActivityId::from_sequence_position(6),
738            activity_type: String::from("charge-card"),
739            input: payload("activity-input")?,
740            task_queue: String::from("claude"),
741            node: None,
742        };
743
744        let json = serde_json::to_string(&event)?;
745        let decoded = serde_json::from_str::<Event>(&json)?;
746
747        match decoded {
748            Event::ActivityScheduled { task_queue, .. } => {
749                assert_eq!(
750                    task_queue, "claude",
751                    "the recorded task queue must survive the round-trip"
752                );
753            }
754            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
755        }
756        Ok(())
757    }
758
759    /// NSTQ-3 replay-safety (the load-bearing test): an OLD recorded history that has no
760    /// `task_queue` key on its `ActivityScheduled` events MUST still decode, defaulting the missing
761    /// value to the named `"default"` task queue, deterministically — never panic, never differ
762    /// run-to-run. The old wire form is the exact pre-field bytes: the current serialization with
763    /// the `task_queue` key removed.
764    #[test]
765    fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
766    -> Result<(), Box<dyn std::error::Error>> {
767        // Build a current event, serialize, then strip the `task_queue` key to reconstruct exactly
768        // what a history recorded before the field existed looks like on the wire.
769        let current = Event::ActivityScheduled {
770            envelope: envelope(6),
771            activity_id: ActivityId::from_sequence_position(6),
772            activity_type: String::from("charge-card"),
773            input: payload("activity-input")?,
774            task_queue: String::from("ignored-when-stripped"),
775            node: Some(String::from("ignored-when-stripped")),
776        };
777        let mut value = serde_json::to_value(&current)?;
778        let data = value
779            .get_mut("data")
780            .and_then(serde_json::Value::as_object_mut)
781            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
782        assert!(
783            data.remove("task_queue").is_some(),
784            "the current wire form must contain task_queue before we strip it"
785        );
786
787        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
788        // back the named default, deterministically.
789        let old_wire = serde_json::to_string(&value)?;
790        for _ in 0..4 {
791            let decoded = serde_json::from_str::<Event>(&old_wire)?;
792            match &decoded {
793                Event::ActivityScheduled { task_queue, .. } => {
794                    assert_eq!(
795                        task_queue, DEFAULT_TASK_QUEUE,
796                        "a missing task_queue must default to the named default queue"
797                    );
798                    assert_eq!(task_queue, "default");
799                }
800                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
801            }
802        }
803        Ok(())
804    }
805
806    /// NODE-3: a recorded `ActivityScheduled` carries its OPTIONAL `node` affinity through the
807    /// durable JSON wire so reopen/recovery can re-target the same node.
808    #[test]
809    fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
810    {
811        let event = Event::ActivityScheduled {
812            envelope: envelope(6),
813            activity_id: ActivityId::from_sequence_position(6),
814            activity_type: String::from("charge-card"),
815            input: payload("activity-input")?,
816            task_queue: String::from("claude"),
817            node: Some(String::from("box-7")),
818        };
819
820        let json = serde_json::to_string(&event)?;
821        let decoded = serde_json::from_str::<Event>(&json)?;
822
823        match decoded {
824            Event::ActivityScheduled { node, .. } => {
825                assert_eq!(
826                    node.as_deref(),
827                    Some("box-7"),
828                    "the recorded node affinity must survive the round-trip"
829                );
830            }
831            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
832        }
833        Ok(())
834    }
835
836    /// NODE-3 replay-safety (the load-bearing test): an OLD recorded history that has no `node` key
837    /// on its `ActivityScheduled` events MUST still decode, defaulting the missing value to `None`
838    /// (no affinity) deterministically — never a sentinel, never panic, never differ run-to-run.
839    /// The old wire form is the exact pre-field bytes: the current serialization with the `node`
840    /// key removed.
841    #[test]
842    fn activity_scheduled_decodes_old_history_without_node_as_none()
843    -> Result<(), Box<dyn std::error::Error>> {
844        // Build a current event with a node set, serialize, then strip the `node` key to
845        // reconstruct exactly what a history recorded before the field existed looks like on the
846        // wire.
847        let current = Event::ActivityScheduled {
848            envelope: envelope(6),
849            activity_id: ActivityId::from_sequence_position(6),
850            activity_type: String::from("charge-card"),
851            input: payload("activity-input")?,
852            task_queue: String::from("default"),
853            node: Some(String::from("ignored-when-stripped")),
854        };
855        let mut value = serde_json::to_value(&current)?;
856        let data = value
857            .get_mut("data")
858            .and_then(serde_json::Value::as_object_mut)
859            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
860        assert!(
861            data.remove("node").is_some(),
862            "the current wire form must contain node before we strip it"
863        );
864
865        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
866        // back `None`, deterministically.
867        let old_wire = serde_json::to_string(&value)?;
868        for _ in 0..4 {
869            let decoded = serde_json::from_str::<Event>(&old_wire)?;
870            match &decoded {
871                Event::ActivityScheduled { node, .. } => {
872                    assert_eq!(
873                        *node, None,
874                        "a missing node must default to None (no affinity)"
875                    );
876                }
877                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
878            }
879        }
880        Ok(())
881    }
882
883    /// NOI-0 positive round-trip: `ActivityStarted`, `ActivityCompleted`, and `ActivityCancelled`
884    /// each carry a genuine one-based `attempt` through the durable JSON wire, so replay reads back
885    /// the same attempt that was recorded — a completed activity has one consistent attempt readable
886    /// off BOTH its start and its terminal (the invariant the NOI design keys on).
887    #[test]
888    fn activity_lifecycle_records_and_reads_back_its_attempt()
889    -> Result<(), Box<dyn std::error::Error>> {
890        let started = Event::ActivityStarted {
891            envelope: envelope(7),
892            activity_id: ActivityId::from_sequence_position(6),
893            attempt: 3,
894        };
895        let completed = Event::ActivityCompleted {
896            envelope: envelope(8),
897            activity_id: ActivityId::from_sequence_position(6),
898            result: payload("activity-result")?,
899            attempt: 3,
900        };
901        let cancelled = Event::ActivityCancelled {
902            envelope: envelope(9),
903            activity_id: ActivityId::from_sequence_position(6),
904            attempt: 3,
905        };
906
907        for event in [&started, &completed, &cancelled] {
908            round_trip(event)?;
909        }
910
911        // Read the attempt back off each decoded terminal — it must be the recorded value, not the
912        // legacy sentinel.
913        match serde_json::from_str::<Event>(&serde_json::to_string(&started)?)? {
914            Event::ActivityStarted { attempt, .. } => assert_eq!(attempt, 3),
915            other => return Err(format!("expected ActivityStarted, got {other:?}").into()),
916        }
917        match serde_json::from_str::<Event>(&serde_json::to_string(&completed)?)? {
918            Event::ActivityCompleted { attempt, .. } => assert_eq!(attempt, 3),
919            other => return Err(format!("expected ActivityCompleted, got {other:?}").into()),
920        }
921        match serde_json::from_str::<Event>(&serde_json::to_string(&cancelled)?)? {
922            Event::ActivityCancelled { attempt, .. } => assert_eq!(attempt, 3),
923            other => return Err(format!("expected ActivityCancelled, got {other:?}").into()),
924        }
925        Ok(())
926    }
927
928    /// NOI-0 replay-safety (the load-bearing negative control): an OLD recorded history that has no
929    /// `attempt` key on its `ActivityStarted` / `ActivityCompleted` / `ActivityCancelled` events MUST
930    /// still decode without panic, defaulting the missing value to the legacy sentinel
931    /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) deterministically — never differ run-to-run. Because real
932    /// attempts are one-based, `0` can never collide with a genuine attempt. The old wire form is the
933    /// exact pre-field bytes: the current serialization with the `attempt` key removed.
934    #[test]
935    fn activity_lifecycle_decodes_old_history_without_attempt_as_legacy_sentinel()
936    -> Result<(), Box<dyn std::error::Error>> {
937        // One current event per variant, each with a NON-sentinel attempt so we can prove the strip
938        // (not the value) is what drives the default on decode.
939        let started = Event::ActivityStarted {
940            envelope: envelope(7),
941            activity_id: ActivityId::from_sequence_position(6),
942            attempt: 5,
943        };
944        let completed = Event::ActivityCompleted {
945            envelope: envelope(8),
946            activity_id: ActivityId::from_sequence_position(6),
947            result: payload("activity-result")?,
948            attempt: 5,
949        };
950        let cancelled = Event::ActivityCancelled {
951            envelope: envelope(9),
952            activity_id: ActivityId::from_sequence_position(6),
953            attempt: 5,
954        };
955
956        // Strip the `attempt` key from each to reconstruct exactly what a pre-NOI-0 history looks
957        // like on the wire, then decode the stripped form repeatedly: it must succeed and always read
958        // back the legacy sentinel, deterministically.
959        for current in [&started, &completed, &cancelled] {
960            let mut value = serde_json::to_value(current)?;
961            let data = value
962                .get_mut("data")
963                .and_then(serde_json::Value::as_object_mut)
964                .ok_or("activity lifecycle event must serialize to a tagged object with `data`")?;
965            assert!(
966                data.remove("attempt").is_some(),
967                "the current wire form must contain attempt before we strip it"
968            );
969            let old_wire = serde_json::to_string(&value)?;
970            for _ in 0..4 {
971                let decoded = serde_json::from_str::<Event>(&old_wire)?;
972                let attempt = match &decoded {
973                    Event::ActivityStarted { attempt, .. }
974                    | Event::ActivityCompleted { attempt, .. }
975                    | Event::ActivityCancelled { attempt, .. } => *attempt,
976                    other => {
977                        return Err(
978                            format!("expected an activity lifecycle event, got {other:?}").into(),
979                        );
980                    }
981                };
982                assert_eq!(
983                    attempt, LEGACY_ACTIVITY_ATTEMPT,
984                    "a missing attempt must default to the legacy sentinel (0)"
985                );
986                assert_eq!(attempt, 0);
987            }
988        }
989        Ok(())
990    }
991
992    /// Replay-safety proof for the `cause` field on `TimerCancelled` (#222):
993    /// a history recorded BEFORE the field existed has no `cause` key and MUST
994    /// decode without panic, defaulting to `WorkflowIntent` — the pre-field
995    /// behavior (a reopen never resurrects it) — deterministically. The old
996    /// wire form is the exact pre-field bytes: the current serialization with
997    /// the `cause` key removed.
998    #[test]
999    fn timer_cancelled_decodes_old_history_without_cause_as_workflow_intent()
1000    -> Result<(), Box<dyn std::error::Error>> {
1001        // A NON-default cause proves the strip (not the value) drives the default.
1002        let cancelled = Event::TimerCancelled {
1003            envelope: envelope(7),
1004            timer_id: TimerId::named("deadline")?,
1005            cause: TimerCancelCause::CancelTeardown,
1006        };
1007
1008        let mut value = serde_json::to_value(&cancelled)?;
1009        let data = value
1010            .get_mut("data")
1011            .and_then(serde_json::Value::as_object_mut)
1012            .ok_or("TimerCancelled must serialize to a tagged object with `data`")?;
1013        assert!(
1014            data.remove("cause").is_some(),
1015            "the current wire form must contain cause before we strip it"
1016        );
1017        let old_wire = serde_json::to_string(&value)?;
1018        for _ in 0..4 {
1019            let decoded = serde_json::from_str::<Event>(&old_wire)?;
1020            match &decoded {
1021                Event::TimerCancelled { cause, .. } => assert_eq!(
1022                    *cause,
1023                    TimerCancelCause::WorkflowIntent,
1024                    "a missing cause must default to WorkflowIntent (never resurrected)"
1025                ),
1026                other => {
1027                    return Err(format!("expected TimerCancelled, got {other:?}").into());
1028                }
1029            }
1030        }
1031        Ok(())
1032    }
1033
1034    /// Pause/resume (#204) round-trip: the two new NON-terminal lifecycle markers
1035    /// carry plain fields and survive the durable JSON wire unchanged.
1036    #[test]
1037    fn pause_resume_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1038        let events = vec![
1039            Event::WorkflowPaused {
1040                envelope: envelope(2),
1041                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1042                reason: Some(String::from("operator hold")),
1043                operator: Some(String::from("tom")),
1044            },
1045            Event::WorkflowPaused {
1046                envelope: envelope(3),
1047                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1048                reason: None,
1049                operator: None,
1050            },
1051            Event::WorkflowResumed {
1052                envelope: envelope(4),
1053                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1054                operator: Some(String::from("tom")),
1055            },
1056        ];
1057        for event in &events {
1058            round_trip(event)?;
1059        }
1060        Ok(())
1061    }
1062
1063    /// GATE-6 back-compat: an OLD history serialized before pause/resume existed
1064    /// decodes byte-identically — it simply never contains the new variants. We
1065    /// prove the whole event enum still decodes an old-shape history with no new
1066    /// variants present (the decode round-trip test the brief requires), and that
1067    /// adding the variants did not change the encoding of any existing variant.
1068    #[test]
1069    fn old_history_without_pause_resume_decodes_unchanged() -> Result<(), Box<dyn std::error::Error>>
1070    {
1071        let started = Event::WorkflowStarted {
1072            envelope: envelope(1),
1073            workflow_type: String::from("checkout"),
1074            input: payload("input")?,
1075            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1076            parent_run_id: None,
1077            package_version: package_version(),
1078        };
1079        let completed = Event::WorkflowCompleted {
1080            envelope: envelope(2),
1081            result: payload("result")?,
1082        };
1083        // Serialize an old-shape history and decode it back: no new variant is
1084        // present, and every existing variant round-trips exactly.
1085        let history = vec![started, completed];
1086        let json = serde_json::to_string(&history)?;
1087        let decoded = serde_json::from_str::<Vec<Event>>(&json)?;
1088        assert_eq!(history, decoded);
1089        Ok(())
1090    }
1091
1092    /// #144: the start-time task queue projects from the `aion.task_queue`
1093    /// search attribute recorded by `SearchAttributesUpdated`, mirroring the
1094    /// `aion.namespace` projection. A later update overrides an earlier value.
1095    #[test]
1096    fn start_time_task_queue_projects_from_recorded_attribute()
1097    -> Result<(), Box<dyn std::error::Error>> {
1098        use super::{START_TIME_TASK_QUEUE_ATTRIBUTE, start_time_task_queue};
1099        use crate::SearchAttributeValue;
1100
1101        let events = vec![
1102            Event::WorkflowStarted {
1103                envelope: envelope(1),
1104                workflow_type: String::from("checkout"),
1105                input: payload("input")?,
1106                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1107                parent_run_id: None,
1108                package_version: package_version(),
1109            },
1110            Event::SearchAttributesUpdated {
1111                envelope: envelope(2),
1112                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1113                attributes: HashMap::from([(
1114                    START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
1115                    SearchAttributeValue::String(String::from("gpu")),
1116                )]),
1117            },
1118        ];
1119
1120        assert_eq!(start_time_task_queue(&events).as_deref(), Some("gpu"));
1121        Ok(())
1122    }
1123
1124    /// #144 back-compat: a history with no recorded `aion.task_queue` attribute
1125    /// projects `None`, so callers fall back to the named default.
1126    #[test]
1127    fn start_time_task_queue_is_none_without_the_attribute()
1128    -> Result<(), Box<dyn std::error::Error>> {
1129        use super::start_time_task_queue;
1130
1131        let events = vec![Event::WorkflowStarted {
1132            envelope: envelope(1),
1133            workflow_type: String::from("checkout"),
1134            input: payload("input")?,
1135            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1136            parent_run_id: None,
1137            package_version: package_version(),
1138        }];
1139
1140        assert_eq!(start_time_task_queue(&events), None);
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
1146        let workflow_id = WorkflowId::new_v4();
1147        let recorded_at = recorded_at();
1148        let envelope = EventEnvelope {
1149            seq: 17,
1150            recorded_at,
1151            workflow_id: workflow_id.clone(),
1152        };
1153        let event = Event::WorkflowStarted {
1154            envelope,
1155            workflow_type: String::from("checkout"),
1156            input: payload("input")?,
1157            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1158            parent_run_id: None,
1159            package_version: package_version(),
1160        };
1161
1162        assert_eq!(event.seq(), 17);
1163        assert_eq!(event.recorded_at(), &recorded_at);
1164        assert_eq!(event.workflow_id(), &workflow_id);
1165        Ok(())
1166    }
1167
1168    #[test]
1169    fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1170        let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
1171        let events = vec![
1172            Event::WorkflowStarted {
1173                envelope: envelope(1),
1174                workflow_type: String::from("checkout"),
1175                input: payload("workflow-input")?,
1176                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1177                parent_run_id: None,
1178                package_version: package_version(),
1179            },
1180            Event::WorkflowCompleted {
1181                envelope: envelope(2),
1182                result: payload("workflow-result")?,
1183            },
1184            Event::WorkflowFailed {
1185                envelope: envelope(3),
1186                error: workflow_error("workflow failed"),
1187            },
1188            Event::WorkflowCancelled {
1189                envelope: envelope(4),
1190                reason: String::from("caller requested cancellation"),
1191            },
1192            Event::WorkflowTimedOut {
1193                envelope: envelope(5),
1194                timeout: String::from("execution"),
1195            },
1196            Event::ActivityScheduled {
1197                envelope: envelope(6),
1198                activity_id: ActivityId::from_sequence_position(6),
1199                activity_type: String::from("charge-card"),
1200                input: payload("activity-input")?,
1201                task_queue: String::from("claude"),
1202                node: Some(String::from("box-7")),
1203            },
1204            Event::ActivityStarted {
1205                envelope: envelope(7),
1206                activity_id: ActivityId::from_sequence_position(6),
1207                attempt: 1,
1208            },
1209            Event::ActivityCompleted {
1210                envelope: envelope(8),
1211                activity_id: ActivityId::from_sequence_position(6),
1212                result: payload("activity-result")?,
1213                attempt: 1,
1214            },
1215            Event::ActivityFailed {
1216                envelope: envelope(9),
1217                activity_id: ActivityId::from_sequence_position(6),
1218                error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
1219                attempt: 1,
1220            },
1221            Event::ActivityCancelled {
1222                envelope: envelope(10),
1223                activity_id: ActivityId::from_sequence_position(6),
1224                attempt: 1,
1225            },
1226            Event::TimerStarted {
1227                envelope: envelope(11),
1228                timer_id: TimerId::anonymous(11),
1229                fire_at,
1230            },
1231            Event::TimerFired {
1232                envelope: envelope(12),
1233                timer_id: TimerId::anonymous(11),
1234            },
1235            Event::TimerCancelled {
1236                envelope: envelope(13),
1237                timer_id: TimerId::named("reminder")?,
1238                cause: TimerCancelCause::WorkflowIntent,
1239            },
1240            Event::SignalReceived {
1241                envelope: envelope(14),
1242                name: String::from("approve"),
1243                payload: payload("signal")?,
1244            },
1245            Event::SignalSent {
1246                envelope: envelope(15),
1247                target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
1248                name: String::from("approve"),
1249                payload: payload("signal-sent")?,
1250            },
1251        ];
1252
1253        for event in events {
1254            round_trip(&event)?;
1255        }
1256        Ok(())
1257    }
1258
1259    #[test]
1260    fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1261        let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
1262        let events = vec![
1263            Event::ChildWorkflowStarted {
1264                envelope: envelope(16),
1265                child_workflow_id: child_workflow_id.clone(),
1266                workflow_type: String::from("fulfillment"),
1267                input: payload("child-input")?,
1268                package_version: package_version(),
1269            },
1270            Event::ChildWorkflowCompleted {
1271                envelope: envelope(16),
1272                child_workflow_id: child_workflow_id.clone(),
1273                result: payload("child-result")?,
1274            },
1275            Event::ChildWorkflowFailed {
1276                envelope: envelope(17),
1277                child_workflow_id: child_workflow_id.clone(),
1278                error: workflow_error("child failed"),
1279            },
1280            Event::ChildWorkflowCancelled {
1281                envelope: envelope(18),
1282                child_workflow_id,
1283            },
1284        ];
1285
1286        for event in events {
1287            round_trip(&event)?;
1288        }
1289        Ok(())
1290    }
1291
1292    #[test]
1293    fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1294        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
1295        let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
1296        let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
1297        let events = vec![
1298            Event::WorkflowContinuedAsNew {
1299                envelope: envelope(19),
1300                input: payload("continued-input")?,
1301                workflow_type: Some(String::from("checkout-v2")),
1302                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
1303            },
1304            Event::SearchAttributesUpdated {
1305                envelope: envelope(20),
1306                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1307                attributes: HashMap::from([(
1308                    String::from("customer_id"),
1309                    SearchAttributeValue::String(String::from("cust-123")),
1310                )]),
1311            },
1312            Event::ScheduleCreated {
1313                envelope: envelope(20),
1314                schedule_id: schedule_id.clone(),
1315                config: schedule_config("schedule-created")?,
1316            },
1317            Event::ScheduleUpdated {
1318                envelope: envelope(21),
1319                schedule_id: schedule_id.clone(),
1320                config: schedule_config("schedule-updated")?,
1321            },
1322            Event::SchedulePaused {
1323                envelope: envelope(22),
1324                schedule_id: schedule_id.clone(),
1325            },
1326            Event::ScheduleResumed {
1327                envelope: envelope(23),
1328                schedule_id: schedule_id.clone(),
1329            },
1330            Event::ScheduleDeleted {
1331                envelope: envelope(24),
1332                schedule_id: schedule_id.clone(),
1333            },
1334            Event::ScheduleTriggered {
1335                envelope: envelope(25),
1336                schedule_id,
1337                workflow_id: triggered_workflow_id,
1338                run_id: triggered_run_id,
1339            },
1340        ];
1341
1342        for event in events {
1343            round_trip(&event)?;
1344        }
1345        Ok(())
1346    }
1347}