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