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(¤t)?;
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(¤t)?;
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}