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