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/// Search attribute name that carries a workflow's operator-facing display
106/// name (#211).
107///
108/// The display name is a LABEL over the workflow's UUID identity, never an
109/// address. Stated exactly, because the label rule is about ACTS and not about
110/// whether a name may appear on the wire:
111///
112/// 1. **No operation acts on a name.** Every act-verb — start, signal, query,
113/// cancel, pause, resume, reopen, rename, intervene — takes a `WorkflowId`
114/// (and optionally a `RunId`). None of them accepts a name as its target.
115/// 2. **Name-shaped FILTERING returns lists and acts on nothing.** This
116/// attribute is an ordinary search attribute, so it is filterable like any
117/// other: a visibility predicate on it yields candidate summaries. That is
118/// the first half of a picker (name in, candidates out, an operator
119/// chooses, a UUID acts), and it is deliberately supported.
120/// 3. **The composition is prohibited.** No SDK, CLI verb, MCP tool, or
121/// convenience function may compose name-filter → take-first → act. That
122/// composition re-imports addressing one layer up: the system, not the
123/// caller, would be choosing the referent. The CHOOSING STEP STAYS WITH THE
124/// CALLER, BY CONTRACT. A future "find by name" helper RETURNS CANDIDATES
125/// AND STOPS.
126///
127/// The sweep predicate, written down so the next audit checks the right thing
128/// rather than re-deriving it: *for every verb, HTTP route, RPC, MCP tool, and
129/// SDK function that CHANGES state, the target is identified by a UUID; a
130/// display name appears only as a value being SET, never as the selector, and
131/// no single call turns a name into an act.* The MCP tool catalogue enforces
132/// this mechanically (`aion_server::mcp::catalog` — see its
133/// `name_is_not_an_address_tests`); the other surfaces are Rust types rather
134/// than data and are checked by reading against that predicate.
135///
136/// It is recorded durably as an ordinary [`Event::SearchAttributesUpdated`]
137/// search attribute — stamped in the same atomic append as
138/// [`Event::WorkflowStarted`] when the start named one, and re-recorded by
139/// every rename — so history keeps every name the workflow has worn and
140/// replay/failover re-derive the current one.
141///
142/// The recorded attribute carries NO run id. A rename is addressed to a
143/// workflow AND a run, but what it records is a workflow-level attribute, and
144/// every reader folds it over the whole history (see [`display_name`]). So a
145/// continue-as-new successor inherits the name its predecessor was wearing, and
146/// describing any run of the workflow reads back the same, current name.
147///
148/// This is the canonical name for the whole workspace;
149/// `aion_server::DISPLAY_NAME_ATTRIBUTE` re-exports it rather than redeclaring
150/// the literal, so the projector below and the server's recorded attribute
151/// cannot drift.
152pub const DISPLAY_NAME_ATTRIBUTE: &str = "aion.display_name";
153
154/// The workflow's current display name, projected from recorded history.
155///
156/// Reads the [`DISPLAY_NAME_ATTRIBUTE`] search attribute folded over EVERY
157/// [`Event::SearchAttributesUpdated`] in `events`, last write wins — so a
158/// rename's later update supersedes the start-time name while history keeps
159/// both. Returns `None` when no name was ever recorded (the unnamed workflow
160/// renders as its bare UUID).
161///
162/// The fold is per WORKFLOW, not per run: it is not run-scoped the way
163/// [`crate::failure_projection`] and [`crate::current_lease_terminal`] are, and
164/// the recorded attribute carries no run id to scope it by. Hand it a whole
165/// workflow history and the newest recorded name wins whichever run recorded
166/// it — so a continue-as-new successor reads back the name its predecessor was
167/// wearing until something renames it.
168///
169/// Because the value is read purely from recorded history (never from live or
170/// wall-clock state), it is replay-deterministic: the same history always
171/// projects the same display name.
172#[must_use]
173pub fn display_name(events: &[Event]) -> Option<String> {
174 display_name_from_attributes(&crate::search_attributes_from_events(events))
175}
176
177/// The display name carried by an already-folded search-attribute map.
178///
179/// The same projection as [`display_name`], for readers that hold the folded
180/// map rather than the events — the visibility projection's
181/// `search_attributes`, which the store keeps refreshed on every append. Both
182/// entry points share this one lookup so the attribute name and the
183/// String-typed expectation cannot drift between the history reader and the
184/// visibility reader. The map is itself folded over a whole workflow history,
185/// so this reads the same per-workflow label with the same last-write-wins
186/// rule.
187#[must_use]
188pub fn display_name_from_attributes<S: std::hash::BuildHasher>(
189 attributes: &std::collections::HashMap<String, crate::SearchAttributeValue, S>,
190) -> Option<String> {
191 match attributes.get(DISPLAY_NAME_ATTRIBUTE) {
192 Some(crate::SearchAttributeValue::String(name)) => Some(name.clone()),
193 _ => None,
194 }
195}
196
197/// A recorded workflow history event.
198///
199/// User data is carried as opaque [`Payload`] values, while failures use the closed workflow and
200/// activity error types from this crate.
201#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
202#[serde(tag = "type", content = "data")]
203pub enum Event {
204 /// A workflow execution started with a type name and input payload.
205 WorkflowStarted {
206 /// Recording metadata for this event.
207 envelope: EventEnvelope,
208 /// Workflow type selected by the caller.
209 workflow_type: String,
210 /// Opaque workflow input payload.
211 input: Payload,
212 /// Concrete run identifier started by this event.
213 run_id: RunId,
214 /// Parent run that continued as this run, when this start is part of a
215 /// continue-as-new chain.
216 parent_run_id: Option<RunId>,
217 /// Package version this run was resolved against at record time.
218 ///
219 /// Recovery and replay resolve workflow code from this recorded
220 /// version; they never re-resolve a "latest" version.
221 package_version: PackageVersion,
222 },
223 /// A workflow execution completed successfully; this terminal event projects to Completed.
224 WorkflowCompleted {
225 /// Recording metadata for this event.
226 envelope: EventEnvelope,
227 /// Opaque workflow result payload.
228 result: Payload,
229 },
230 /// A workflow execution failed terminally; this terminal event projects to Failed.
231 WorkflowFailed {
232 /// Recording metadata for this event.
233 envelope: EventEnvelope,
234 /// Terminal workflow failure.
235 error: WorkflowError,
236 },
237 /// A workflow execution was cancelled; this terminal event projects to Cancelled.
238 WorkflowCancelled {
239 /// Recording metadata for this event.
240 envelope: EventEnvelope,
241 /// Human-readable cancellation reason.
242 reason: String,
243 },
244 /// A workflow execution timed out; this terminal event projects to `TimedOut`.
245 WorkflowTimedOut {
246 /// Recording metadata for this event.
247 envelope: EventEnvelope,
248 /// Descriptor identifying the timeout that elapsed.
249 ///
250 /// Intentionally stringly-typed: the closed set of timeout kinds is defined by cluster AT
251 /// (timers and signals), not by the core event model.
252 timeout: String,
253 },
254 /// A workflow execution continued as a new run; this terminal event projects to
255 /// `ContinuedAsNew`.
256 WorkflowContinuedAsNew {
257 /// Recording metadata for this event.
258 envelope: EventEnvelope,
259 /// Opaque workflow input payload carried into the new run.
260 input: Payload,
261 /// Workflow type override for the new run, when migration changes the workflow type.
262 ///
263 /// When absent, the new run uses the current workflow type.
264 workflow_type: Option<String>,
265 /// Run identifier for the current run that is being continued.
266 parent_run_id: RunId,
267 },
268 /// A failed run was reopened.
269 ///
270 /// Engine-internal — never authored by workflow or SDK code. This is the
271 /// compensating event that reconciles reopen with the status-is-a-projection
272 /// invariant: under the last-lifecycle-event-wins scan it supersedes the
273 /// run's prior terminal event and returns the run to Running, exactly as a
274 /// replacement [`Event::WorkflowStarted`] does for continue-as-new. Terminal
275 /// detection is scoped to "since the last reopen point", so a run holds
276 /// exactly one terminal event per lease.
277 WorkflowReopened {
278 /// Recording metadata for this event.
279 envelope: EventEnvelope,
280 /// Run being reopened — the run that recorded the superseded terminal
281 /// event and that the reopened execution continues.
282 run_id: RunId,
283 /// Activities to re-dispatch on replay: those that ended in a terminal
284 /// failure in this run with no later successful attempt. The history
285 /// cursor treats each as a reset point so the recorded failure is
286 /// superseded and the activity resolves to live re-dispatch.
287 reopened: Vec<ActivityId>,
288 },
289 /// A running workflow was paused by an operator.
290 ///
291 /// Engine-internal — never authored by workflow or SDK code. A NON-terminal
292 /// lifecycle marker: under the last-lifecycle-event-wins scan it projects the
293 /// run to [`crate::WorkflowStatus::Paused`], holding new activity dispatch at
294 /// the outbox while every durable record path (timer fires, signal receipts,
295 /// drained completions) keeps recording. It is invisible to the replay cursor
296 /// (it is neither a terminal nor a run-start reset), so a paused-then-resumed
297 /// history replays byte-identically to one that was never paused.
298 WorkflowPaused {
299 /// Recording metadata for this event.
300 envelope: EventEnvelope,
301 /// Run being paused — the live, non-terminal run the operator held.
302 run_id: RunId,
303 /// Optional operator-supplied pause reason.
304 reason: Option<String>,
305 /// Optional identity of the operator who issued the pause.
306 operator: Option<String>,
307 },
308 /// A paused workflow was resumed by an operator.
309 ///
310 /// Engine-internal — never authored by workflow or SDK code. Supersedes the
311 /// run's prior [`Event::WorkflowPaused`] under the last-lifecycle-event-wins
312 /// scan, returning the run to [`crate::WorkflowStatus::Running`], and — like
313 /// [`Event::WorkflowPaused`] — is invisible to the replay cursor.
314 WorkflowResumed {
315 /// Recording metadata for this event.
316 envelope: EventEnvelope,
317 /// Run being resumed.
318 run_id: RunId,
319 /// Optional identity of the operator who issued the resume.
320 operator: Option<String>,
321 },
322 /// Workflow search attributes were updated for visibility and query projection.
323 SearchAttributesUpdated {
324 /// Recording metadata for this event.
325 envelope: EventEnvelope,
326 /// Workflow whose search attributes changed.
327 workflow_id: WorkflowId,
328 /// Updated search attributes keyed by attribute name.
329 attributes: HashMap<String, SearchAttributeValue>,
330 },
331 /// An activity was scheduled by workflow code.
332 ActivityScheduled {
333 /// Recording metadata for this event.
334 envelope: EventEnvelope,
335 /// Deterministic activity identifier derived from the scheduling sequence position.
336 activity_id: ActivityId,
337 /// Activity type selected by workflow code.
338 activity_type: String,
339 /// Opaque activity input payload.
340 input: Payload,
341 /// Pool/flavour selector this activity dispatches to within the workflow's namespace
342 /// (NSTQ-3). This is the durable source-of-truth for re-targeting the **same** task queue
343 /// on reopen/recovery, mirroring how the namespace is recovered from history but recorded
344 /// **per-activity** rather than as a workflow-level search attribute.
345 ///
346 /// Replay-safety: histories recorded before this field existed have no `task_queue` on
347 /// their `ActivityScheduled` events. Decode defaults the missing value to
348 /// [`DEFAULT_TASK_QUEUE`] (`"default"`) via `#[serde(default = ...)]`, so an old history
349 /// deterministically re-derives `task_queue = "default"` — never panics, never differs
350 /// run-to-run. The encoding of the existing fields is untouched.
351 #[serde(default = "default_task_queue")]
352 task_queue: String,
353 /// OPTIONAL node affinity this activity dispatches to (NODE-3). `None` = no affinity (the
354 /// genuine current value; SDK-level node selection is NODE-4). This is the durable
355 /// source-of-truth for re-targeting the **same** node on reopen/recovery, recorded
356 /// **per-activity** alongside `task_queue`.
357 ///
358 /// Replay-safety: histories recorded before this field existed have no `node` key on their
359 /// `ActivityScheduled` events. serde's `Option` default is `None`, so `#[serde(default)]`
360 /// decodes a missing `node` deterministically to `None` — never a sentinel, never panics,
361 /// never differs run-to-run. The encoding of the existing fields is untouched.
362 #[serde(default)]
363 node: Option<String>,
364 },
365 /// The engine DISPATCHED an activity attempt to its task queue.
366 ///
367 /// **This does NOT mean a worker has taken the work.** The event is written
368 /// by the engine at dispatch time, in the SAME atomic append as its
369 /// [`Event::ActivityScheduled`] and under the same `recorded_at`, before any
370 /// worker has been selected — let alone leased the attempt. Worker selection
371 /// happens afterwards, in the server, and may wait indefinitely: a dispatch
372 /// to a task queue nobody serves records this event and then nothing, so a
373 /// run parked forever and a run a worker is actively executing have
374 /// identical history shapes and both project
375 /// [`WorkflowStatus::Running`](crate::WorkflowStatus::Running). That is not
376 /// a projection bug — history genuinely holds no terminal event — but it
377 /// means this event alone can never answer "is anyone working on it".
378 ///
379 /// Every producing seam behaves this way: the single-dispatch and in-VM
380 /// seams (`aion::runtime::nif_activity_dispatch`,
381 /// `aion::runtime::nif_activity_in_vm`), the retry delivery
382 /// (`aion::runtime::nif_activity_retry_dispatch`, which records the next
383 /// attempt's start "before it goes on the wire"), and the fan-out batch
384 /// (`aion::durability::recorder::fan_out`).
385 ///
386 /// The name is therefore wrong and the behaviour is not. A rename to
387 /// `ActivityDispatched` is PROPOSED and PENDING an owner ruling, together
388 /// with the question of whether a separate lease-time event should exist;
389 /// see `docs/design/aion-authoring/ACTIVITY-STARTED-SEMANTICS-DECISION.md`.
390 /// Deferring THIS event to lease time is recommended against there, because
391 /// it would silently change what every already-recorded `ActivityStarted`
392 /// meant.
393 ///
394 /// To ask whether an in-flight activity can still reach a worker, join its
395 /// recorded address to the live fleet — `aion_server`'s
396 /// `worker::ActivityReachability`, surfaced on `POST /workflows/describe`.
397 ActivityStarted {
398 /// Recording metadata for this event.
399 envelope: EventEnvelope,
400 /// Activity that was dispatched.
401 activity_id: ActivityId,
402 /// One-based activity attempt number this start belongs to (NOI-0).
403 ///
404 /// Matches the `attempt` on the [`Event::ActivityFailed`] / [`Event::ActivityCompleted`] /
405 /// [`Event::ActivityCancelled`] that terminates the SAME attempt, so
406 /// `(workflow, activity, attempt)` is a stable identity across the whole lifecycle — the key
407 /// the NOI dedupe/guard/session-id design is built on.
408 ///
409 /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
410 /// `ActivityStarted` events. Decode defaults the missing value to
411 /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
412 /// differs run-to-run. Because real attempts are one-based, `0` is a distinguishable
413 /// legacy/unknown sentinel, never a genuine attempt. The encoding of the existing fields is
414 /// untouched.
415 #[serde(default = "legacy_activity_attempt")]
416 attempt: u32,
417 },
418 /// Recovery offered a previously dispatched, still-dangling activity
419 /// attempt back to the worker adoption path.
420 ///
421 /// This is deliberately an OFFER, not a claim that the worker found a live
422 /// holder: the engine cannot observe a harness process directly. It records
423 /// the truthful recovery decision — retain and redeliver the same execution
424 /// identity instead of falsely terminating it as server death. The worker's
425 /// single-flight/spawn gate then either joins the holder or starts it when no
426 /// holder survived.
427 ActivityAdoptionOffered {
428 /// Recording metadata for this event.
429 envelope: EventEnvelope,
430 /// Activity whose dangling attempt is being offered for adoption.
431 activity_id: ActivityId,
432 /// One-based attempt identity retained across the recovery redelivery.
433 attempt: u32,
434 },
435 /// An activity completed successfully.
436 ActivityCompleted {
437 /// Recording metadata for this event.
438 envelope: EventEnvelope,
439 /// Activity that produced the result.
440 activity_id: ActivityId,
441 /// Opaque activity result payload.
442 result: Payload,
443 /// One-based activity attempt number that produced this completion (NOI-0).
444 ///
445 /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so a
446 /// completed activity carries one consistent `attempt` readable off both its start and its
447 /// terminal — the negative-control invariant NOI-0 gates on.
448 ///
449 /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
450 /// `ActivityCompleted` events. Decode defaults the missing value to
451 /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
452 /// differs run-to-run. The encoding of the existing fields is untouched.
453 #[serde(default = "legacy_activity_attempt")]
454 attempt: u32,
455 },
456 /// An activity attempt failed.
457 ///
458 /// The `attempt` field together with [`ActivityError`]'s retryable or terminal classification
459 /// lets replay distinguish a retryable interim failure from a terminal one for the same
460 /// [`ActivityId`].
461 ActivityFailed {
462 /// Recording metadata for this event.
463 envelope: EventEnvelope,
464 /// Activity whose attempt failed.
465 activity_id: ActivityId,
466 /// Classified activity failure.
467 error: ActivityError,
468 /// One-based activity attempt number that produced this failure.
469 attempt: u32,
470 },
471 /// An ADVISORY activity spent its whole attempt budget and failed for good
472 /// (RUNTIME-OPERATIONS.md R5).
473 ///
474 /// The warning the class promises: an advisory action is a side channel
475 /// (a heartbeat, a notification), so its exhaustion never faults the
476 /// calling step — but it must never be silent either. This event is that
477 /// visibility. It ACCOMPANIES the activity's honest terminal
478 /// [`Event::ActivityFailed`]; it never replaces it, because the activity
479 /// really did fail and history says so.
480 ///
481 /// Non-terminal for the workflow: it says nothing about the run's
482 /// outcome, so status projection deliberately ignores it.
483 ActivityAdvisoryExhausted {
484 /// Recording metadata for this event.
485 envelope: EventEnvelope,
486 /// Advisory activity whose attempt budget was spent.
487 activity_id: ActivityId,
488 /// Activity type of the exhausted advisory activity.
489 activity_type: String,
490 /// The last attempt's failure reason, verbatim — the same string the
491 /// accompanying terminal [`Event::ActivityFailed`] carries.
492 reason: String,
493 /// One-based number of the attempt that spent the budget.
494 attempt: u32,
495 },
496 /// An activity was cancelled as an explicit cancellation outcome.
497 ActivityCancelled {
498 /// Recording metadata for this event.
499 envelope: EventEnvelope,
500 /// Activity that was cancelled.
501 activity_id: ActivityId,
502 /// One-based activity attempt number that was cancelled (NOI-0).
503 ///
504 /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so the
505 /// cancellation terminal is attributable to a specific attempt exactly like
506 /// [`Event::ActivityFailed`] is.
507 ///
508 /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
509 /// `ActivityCancelled` events. Decode defaults the missing value to
510 /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
511 /// differs run-to-run. The encoding of the existing fields is untouched.
512 #[serde(default = "legacy_activity_attempt")]
513 attempt: u32,
514 },
515 /// A timer was scheduled to fire at a deterministic timestamp.
516 TimerStarted {
517 /// Recording metadata for this event.
518 envelope: EventEnvelope,
519 /// Timer selected by workflow code or assigned by the engine.
520 timer_id: TimerId,
521 /// UTC timestamp at which the timer becomes eligible to fire.
522 fire_at: DateTime<Utc>,
523 },
524 /// A timer fired.
525 TimerFired {
526 /// Recording metadata for this event.
527 envelope: EventEnvelope,
528 /// Timer that fired.
529 timer_id: TimerId,
530 },
531 /// A timer was cancelled as an explicit cancellation outcome.
532 TimerCancelled {
533 /// Recording metadata for this event.
534 envelope: EventEnvelope,
535 /// Timer that was cancelled.
536 timer_id: TimerId,
537 /// Who retired the timer. Decides reopen behavior: a
538 /// [`TimerCancelCause::CancelTeardown`] cancellation is re-armed when the
539 /// run is reopened; a [`TimerCancelCause::WorkflowIntent`] cancellation is
540 /// permanent.
541 ///
542 /// Replay-safety: histories recorded before this field existed have no
543 /// `cause` key. Decode defaults the missing value to
544 /// [`TimerCancelCause::WorkflowIntent`] via `#[serde(default)]` — the
545 /// pre-field behavior (never resurrected), never panics, never differs
546 /// run-to-run. The encoding of the existing fields is untouched.
547 #[serde(default)]
548 cause: TimerCancelCause,
549 },
550 /// A `with_timeout` operation reached a durable terminal outcome.
551 WithTimeoutCompleted {
552 /// Recording metadata for this event.
553 envelope: EventEnvelope,
554 /// Timer that bounded the operation.
555 timer_id: TimerId,
556 /// Recorded timeout outcome.
557 outcome: WithTimeoutOutcome,
558 /// JSON-encoded BEAM term payload for completed operation results.
559 result: Option<Payload>,
560 },
561 /// A signal was delivered to the workflow.
562 SignalReceived {
563 /// Recording metadata for this event.
564 envelope: EventEnvelope,
565 /// Signal name selected by the sender.
566 name: String,
567 /// Opaque signal payload.
568 payload: Payload,
569 },
570 /// A signal was sent by this workflow to another workflow.
571 SignalSent {
572 /// Recording metadata for this event.
573 envelope: EventEnvelope,
574 /// Target workflow identifier selected by workflow code.
575 target_workflow_id: WorkflowId,
576 /// Signal name selected by workflow code.
577 name: String,
578 /// Opaque signal payload.
579 payload: Payload,
580 },
581 /// A child workflow was started.
582 ChildWorkflowStarted {
583 /// Recording metadata for this event.
584 envelope: EventEnvelope,
585 /// Child workflow identifier.
586 child_workflow_id: WorkflowId,
587 /// Child workflow type selected by the parent.
588 workflow_type: String,
589 /// Opaque child workflow input payload.
590 input: Payload,
591 /// Package version resolved for the child at record time.
592 ///
593 /// The crash-repair sweep and the child's own start use exactly this
594 /// recorded version, so the crash path resolves identically to the
595 /// crash-free path.
596 package_version: PackageVersion,
597 },
598 /// A child workflow completed successfully.
599 ChildWorkflowCompleted {
600 /// Recording metadata for this event.
601 envelope: EventEnvelope,
602 /// Child workflow that produced the result.
603 child_workflow_id: WorkflowId,
604 /// Opaque child workflow result payload.
605 result: Payload,
606 },
607 /// A child workflow failed terminally.
608 ChildWorkflowFailed {
609 /// Recording metadata for this event.
610 envelope: EventEnvelope,
611 /// Child workflow that failed.
612 child_workflow_id: WorkflowId,
613 /// Terminal child workflow failure.
614 error: WorkflowError,
615 },
616 /// A child workflow was cancelled as an explicit cancellation outcome.
617 ChildWorkflowCancelled {
618 /// Recording metadata for this event.
619 envelope: EventEnvelope,
620 /// Child workflow that was cancelled.
621 child_workflow_id: WorkflowId,
622 },
623 /// A schedule resource was created.
624 ScheduleCreated {
625 /// Recording metadata for this event.
626 envelope: EventEnvelope,
627 /// Schedule resource that was created.
628 schedule_id: ScheduleId,
629 /// Persisted schedule configuration.
630 config: ScheduleConfig,
631 },
632 /// A schedule resource was updated.
633 ScheduleUpdated {
634 /// Recording metadata for this event.
635 envelope: EventEnvelope,
636 /// Schedule resource that was updated.
637 schedule_id: ScheduleId,
638 /// Updated schedule configuration.
639 config: ScheduleConfig,
640 },
641 /// A schedule resource was paused.
642 SchedulePaused {
643 /// Recording metadata for this event.
644 envelope: EventEnvelope,
645 /// Schedule resource that was paused.
646 schedule_id: ScheduleId,
647 },
648 /// A paused schedule resource was resumed.
649 ScheduleResumed {
650 /// Recording metadata for this event.
651 envelope: EventEnvelope,
652 /// Schedule resource that was resumed.
653 schedule_id: ScheduleId,
654 },
655 /// A schedule resource was deleted.
656 ScheduleDeleted {
657 /// Recording metadata for this event.
658 envelope: EventEnvelope,
659 /// Schedule resource that was deleted.
660 schedule_id: ScheduleId,
661 },
662 /// A schedule tick started a workflow execution.
663 ScheduleTriggered {
664 /// Recording metadata for this event.
665 envelope: EventEnvelope,
666 /// Schedule resource that fired.
667 schedule_id: ScheduleId,
668 /// Workflow execution started by the schedule tick.
669 workflow_id: WorkflowId,
670 /// Run started by the schedule tick.
671 run_id: RunId,
672 },
673}
674
675/// Durable terminal outcome for a `with_timeout` operation.
676#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
677pub enum WithTimeoutOutcome {
678 /// The operation closure returned before the deadline.
679 OperationCompleted,
680 /// The deadline fired before the operation completed.
681 TimedOut,
682}
683
684/// Who retired a durable timer, recorded on [`Event::TimerCancelled`].
685///
686/// The distinction decides reopen semantics. A timer the WORKFLOW retired —
687/// an SDK `cancel_timer` call or a `with_timeout` scope settling because the
688/// racing operation won — is a business fact: reopen must never resurrect it.
689/// A timer the ENGINE retired while tearing down a cancelled run
690/// (`Engine::cancel`'s in-flight timer cleanup) is bookkeeping: the deadline
691/// itself was never reached or waived, so reopening the run re-arms it at its
692/// original `fire_at`.
693#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, Default, PartialEq, Eq)]
694pub enum TimerCancelCause {
695 /// Workflow code retired the timer (SDK cancel or a settled timeout scope).
696 ///
697 /// The serde default: histories recorded before this field existed decode
698 /// as workflow intent, preserving their pre-field never-resurrected
699 /// behavior.
700 #[default]
701 WorkflowIntent,
702 /// The engine retired the timer while cancelling its workflow run.
703 CancelTeardown,
704}
705
706impl Event {
707 /// Returns the envelope recorded with this event.
708 #[must_use]
709 pub const fn envelope(&self) -> &EventEnvelope {
710 match self {
711 Self::WorkflowStarted { envelope, .. }
712 | Self::WorkflowCompleted { envelope, .. }
713 | Self::WorkflowFailed { envelope, .. }
714 | Self::WorkflowCancelled { envelope, .. }
715 | Self::WorkflowTimedOut { envelope, .. }
716 | Self::WorkflowContinuedAsNew { envelope, .. }
717 | Self::WorkflowReopened { envelope, .. }
718 | Self::WorkflowPaused { envelope, .. }
719 | Self::WorkflowResumed { envelope, .. }
720 | Self::SearchAttributesUpdated { envelope, .. }
721 | Self::ActivityScheduled { envelope, .. }
722 | Self::ActivityStarted { envelope, .. }
723 | Self::ActivityAdoptionOffered { envelope, .. }
724 | Self::ActivityCompleted { envelope, .. }
725 | Self::ActivityFailed { envelope, .. }
726 | Self::ActivityAdvisoryExhausted { envelope, .. }
727 | Self::ActivityCancelled { envelope, .. }
728 | Self::TimerStarted { envelope, .. }
729 | Self::TimerFired { envelope, .. }
730 | Self::TimerCancelled { envelope, .. }
731 | Self::WithTimeoutCompleted { envelope, .. }
732 | Self::SignalReceived { envelope, .. }
733 | Self::SignalSent { envelope, .. }
734 | Self::ChildWorkflowStarted { envelope, .. }
735 | Self::ChildWorkflowCompleted { envelope, .. }
736 | Self::ChildWorkflowFailed { envelope, .. }
737 | Self::ChildWorkflowCancelled { envelope, .. }
738 | Self::ScheduleCreated { envelope, .. }
739 | Self::ScheduleUpdated { envelope, .. }
740 | Self::SchedulePaused { envelope, .. }
741 | Self::ScheduleResumed { envelope, .. }
742 | Self::ScheduleDeleted { envelope, .. }
743 | Self::ScheduleTriggered { envelope, .. } => envelope,
744 }
745 }
746
747 /// Returns the monotonic sequence number recorded for this event.
748 #[must_use]
749 pub const fn seq(&self) -> u64 {
750 self.envelope().seq
751 }
752
753 /// Returns the deterministic recorded timestamp for this event.
754 #[must_use]
755 pub const fn recorded_at(&self) -> &DateTime<Utc> {
756 &self.envelope().recorded_at
757 }
758
759 /// Returns the workflow history that owns this event.
760 #[must_use]
761 pub const fn workflow_id(&self) -> &WorkflowId {
762 &self.envelope().workflow_id
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use std::collections::HashMap;
769
770 use chrono::{DateTime, Utc};
771 use serde_json::json;
772
773 use super::{
774 DEFAULT_TASK_QUEUE, Event, EventEnvelope, LEGACY_ACTIVITY_ATTEMPT, TimerCancelCause,
775 };
776 use crate::{
777 ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
778 Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
779 WorkflowError, WorkflowId,
780 };
781
782 fn package_version() -> PackageVersion {
783 PackageVersion::new("a".repeat(64))
784 }
785
786 fn recorded_at() -> DateTime<Utc> {
787 DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
788 }
789
790 fn envelope(seq: u64) -> EventEnvelope {
791 EventEnvelope {
792 seq,
793 recorded_at: recorded_at(),
794 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
795 }
796 }
797
798 fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
799 Payload::from_json(&json!({ "label": label }))
800 }
801
802 fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
803 Ok(ScheduleConfig {
804 trigger: TriggerSpec::Cron {
805 expression: String::from("0 0 * * *"),
806 },
807 overlap_policy: OverlapPolicy::Skip,
808 catch_up_policy: CatchUpPolicy::One,
809 workflow_type: String::from("checkout"),
810 input: payload(label)?,
811 search_attributes: HashMap::from([(
812 String::from("aion.namespace"),
813 crate::SearchAttributeValue::String(String::from("tenant-a")),
814 )]),
815 })
816 }
817
818 fn workflow_error(message: &str) -> WorkflowError {
819 WorkflowError {
820 message: String::from(message),
821 details: None,
822 }
823 }
824
825 fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
826 ActivityError {
827 kind,
828 message: String::from(message),
829 details: None,
830 }
831 }
832
833 fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
834 let json = serde_json::to_string(event)?;
835 let decoded = serde_json::from_str::<Event>(&json)?;
836 assert_eq!(*event, decoded);
837 Ok(())
838 }
839
840 /// NSTQ-3: a recorded `ActivityScheduled` carries its `task_queue` through the durable JSON
841 /// wire so reopen/recovery can re-target the same pool.
842 #[test]
843 fn activity_scheduled_records_and_reads_back_its_task_queue()
844 -> Result<(), Box<dyn std::error::Error>> {
845 let event = Event::ActivityScheduled {
846 envelope: envelope(6),
847 activity_id: ActivityId::from_sequence_position(6),
848 activity_type: String::from("charge-card"),
849 input: payload("activity-input")?,
850 task_queue: String::from("claude"),
851 node: None,
852 };
853
854 let json = serde_json::to_string(&event)?;
855 let decoded = serde_json::from_str::<Event>(&json)?;
856
857 match decoded {
858 Event::ActivityScheduled { task_queue, .. } => {
859 assert_eq!(
860 task_queue, "claude",
861 "the recorded task queue must survive the round-trip"
862 );
863 }
864 other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
865 }
866 Ok(())
867 }
868
869 /// NSTQ-3 replay-safety (the load-bearing test): an OLD recorded history that has no
870 /// `task_queue` key on its `ActivityScheduled` events MUST still decode, defaulting the missing
871 /// value to the named `"default"` task queue, deterministically — never panic, never differ
872 /// run-to-run. The old wire form is the exact pre-field bytes: the current serialization with
873 /// the `task_queue` key removed.
874 #[test]
875 fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
876 -> Result<(), Box<dyn std::error::Error>> {
877 // Build a current event, serialize, then strip the `task_queue` key to reconstruct exactly
878 // what a history recorded before the field existed looks like on the wire.
879 let current = Event::ActivityScheduled {
880 envelope: envelope(6),
881 activity_id: ActivityId::from_sequence_position(6),
882 activity_type: String::from("charge-card"),
883 input: payload("activity-input")?,
884 task_queue: String::from("ignored-when-stripped"),
885 node: Some(String::from("ignored-when-stripped")),
886 };
887 let mut value = serde_json::to_value(¤t)?;
888 let data = value
889 .get_mut("data")
890 .and_then(serde_json::Value::as_object_mut)
891 .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
892 assert!(
893 data.remove("task_queue").is_some(),
894 "the current wire form must contain task_queue before we strip it"
895 );
896
897 // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
898 // back the named default, deterministically.
899 let old_wire = serde_json::to_string(&value)?;
900 for _ in 0..4 {
901 let decoded = serde_json::from_str::<Event>(&old_wire)?;
902 match &decoded {
903 Event::ActivityScheduled { task_queue, .. } => {
904 assert_eq!(
905 task_queue, DEFAULT_TASK_QUEUE,
906 "a missing task_queue must default to the named default queue"
907 );
908 assert_eq!(task_queue, "default");
909 }
910 other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
911 }
912 }
913 Ok(())
914 }
915
916 /// NODE-3: a recorded `ActivityScheduled` carries its OPTIONAL `node` affinity through the
917 /// durable JSON wire so reopen/recovery can re-target the same node.
918 #[test]
919 fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
920 {
921 let event = Event::ActivityScheduled {
922 envelope: envelope(6),
923 activity_id: ActivityId::from_sequence_position(6),
924 activity_type: String::from("charge-card"),
925 input: payload("activity-input")?,
926 task_queue: String::from("claude"),
927 node: Some(String::from("box-7")),
928 };
929
930 let json = serde_json::to_string(&event)?;
931 let decoded = serde_json::from_str::<Event>(&json)?;
932
933 match decoded {
934 Event::ActivityScheduled { node, .. } => {
935 assert_eq!(
936 node.as_deref(),
937 Some("box-7"),
938 "the recorded node affinity must survive the round-trip"
939 );
940 }
941 other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
942 }
943 Ok(())
944 }
945
946 /// NODE-3 replay-safety (the load-bearing test): an OLD recorded history that has no `node` key
947 /// on its `ActivityScheduled` events MUST still decode, defaulting the missing value to `None`
948 /// (no affinity) deterministically — never a sentinel, never panic, never differ run-to-run.
949 /// The old wire form is the exact pre-field bytes: the current serialization with the `node`
950 /// key removed.
951 #[test]
952 fn activity_scheduled_decodes_old_history_without_node_as_none()
953 -> Result<(), Box<dyn std::error::Error>> {
954 // Build a current event with a node set, serialize, then strip the `node` key to
955 // reconstruct exactly what a history recorded before the field existed looks like on the
956 // wire.
957 let current = Event::ActivityScheduled {
958 envelope: envelope(6),
959 activity_id: ActivityId::from_sequence_position(6),
960 activity_type: String::from("charge-card"),
961 input: payload("activity-input")?,
962 task_queue: String::from("default"),
963 node: Some(String::from("ignored-when-stripped")),
964 };
965 let mut value = serde_json::to_value(¤t)?;
966 let data = value
967 .get_mut("data")
968 .and_then(serde_json::Value::as_object_mut)
969 .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
970 assert!(
971 data.remove("node").is_some(),
972 "the current wire form must contain node before we strip it"
973 );
974
975 // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
976 // back `None`, deterministically.
977 let old_wire = serde_json::to_string(&value)?;
978 for _ in 0..4 {
979 let decoded = serde_json::from_str::<Event>(&old_wire)?;
980 match &decoded {
981 Event::ActivityScheduled { node, .. } => {
982 assert_eq!(
983 *node, None,
984 "a missing node must default to None (no affinity)"
985 );
986 }
987 other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
988 }
989 }
990 Ok(())
991 }
992
993 /// NOI-0 positive round-trip: `ActivityStarted`, `ActivityCompleted`, and `ActivityCancelled`
994 /// each carry a genuine one-based `attempt` through the durable JSON wire, so replay reads back
995 /// the same attempt that was recorded — a completed activity has one consistent attempt readable
996 /// off BOTH its start and its terminal (the invariant the NOI design keys on).
997 #[test]
998 fn activity_lifecycle_records_and_reads_back_its_attempt()
999 -> Result<(), Box<dyn std::error::Error>> {
1000 let started = Event::ActivityStarted {
1001 envelope: envelope(7),
1002 activity_id: ActivityId::from_sequence_position(6),
1003 attempt: 3,
1004 };
1005 let completed = Event::ActivityCompleted {
1006 envelope: envelope(8),
1007 activity_id: ActivityId::from_sequence_position(6),
1008 result: payload("activity-result")?,
1009 attempt: 3,
1010 };
1011 let cancelled = Event::ActivityCancelled {
1012 envelope: envelope(9),
1013 activity_id: ActivityId::from_sequence_position(6),
1014 attempt: 3,
1015 };
1016
1017 for event in [&started, &completed, &cancelled] {
1018 round_trip(event)?;
1019 }
1020
1021 // Read the attempt back off each decoded terminal — it must be the recorded value, not the
1022 // legacy sentinel.
1023 match serde_json::from_str::<Event>(&serde_json::to_string(&started)?)? {
1024 Event::ActivityStarted { attempt, .. } => assert_eq!(attempt, 3),
1025 other => return Err(format!("expected ActivityStarted, got {other:?}").into()),
1026 }
1027 match serde_json::from_str::<Event>(&serde_json::to_string(&completed)?)? {
1028 Event::ActivityCompleted { attempt, .. } => assert_eq!(attempt, 3),
1029 other => return Err(format!("expected ActivityCompleted, got {other:?}").into()),
1030 }
1031 match serde_json::from_str::<Event>(&serde_json::to_string(&cancelled)?)? {
1032 Event::ActivityCancelled { attempt, .. } => assert_eq!(attempt, 3),
1033 other => return Err(format!("expected ActivityCancelled, got {other:?}").into()),
1034 }
1035 Ok(())
1036 }
1037
1038 /// NOI-0 replay-safety (the load-bearing negative control): an OLD recorded history that has no
1039 /// `attempt` key on its `ActivityStarted` / `ActivityCompleted` / `ActivityCancelled` events MUST
1040 /// still decode without panic, defaulting the missing value to the legacy sentinel
1041 /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) deterministically — never differ run-to-run. Because real
1042 /// attempts are one-based, `0` can never collide with a genuine attempt. The old wire form is the
1043 /// exact pre-field bytes: the current serialization with the `attempt` key removed.
1044 #[test]
1045 fn activity_lifecycle_decodes_old_history_without_attempt_as_legacy_sentinel()
1046 -> Result<(), Box<dyn std::error::Error>> {
1047 // One current event per variant, each with a NON-sentinel attempt so we can prove the strip
1048 // (not the value) is what drives the default on decode.
1049 let started = Event::ActivityStarted {
1050 envelope: envelope(7),
1051 activity_id: ActivityId::from_sequence_position(6),
1052 attempt: 5,
1053 };
1054 let completed = Event::ActivityCompleted {
1055 envelope: envelope(8),
1056 activity_id: ActivityId::from_sequence_position(6),
1057 result: payload("activity-result")?,
1058 attempt: 5,
1059 };
1060 let cancelled = Event::ActivityCancelled {
1061 envelope: envelope(9),
1062 activity_id: ActivityId::from_sequence_position(6),
1063 attempt: 5,
1064 };
1065
1066 // Strip the `attempt` key from each to reconstruct exactly what a pre-NOI-0 history looks
1067 // like on the wire, then decode the stripped form repeatedly: it must succeed and always read
1068 // back the legacy sentinel, deterministically.
1069 for current in [&started, &completed, &cancelled] {
1070 let mut value = serde_json::to_value(current)?;
1071 let data = value
1072 .get_mut("data")
1073 .and_then(serde_json::Value::as_object_mut)
1074 .ok_or("activity lifecycle event must serialize to a tagged object with `data`")?;
1075 assert!(
1076 data.remove("attempt").is_some(),
1077 "the current wire form must contain attempt before we strip it"
1078 );
1079 let old_wire = serde_json::to_string(&value)?;
1080 for _ in 0..4 {
1081 let decoded = serde_json::from_str::<Event>(&old_wire)?;
1082 let attempt = match &decoded {
1083 Event::ActivityStarted { attempt, .. }
1084 | Event::ActivityCompleted { attempt, .. }
1085 | Event::ActivityCancelled { attempt, .. } => *attempt,
1086 other => {
1087 return Err(
1088 format!("expected an activity lifecycle event, got {other:?}").into(),
1089 );
1090 }
1091 };
1092 assert_eq!(
1093 attempt, LEGACY_ACTIVITY_ATTEMPT,
1094 "a missing attempt must default to the legacy sentinel (0)"
1095 );
1096 assert_eq!(attempt, 0);
1097 }
1098 }
1099 Ok(())
1100 }
1101
1102 /// Replay-safety proof for the `cause` field on `TimerCancelled` (#222):
1103 /// a history recorded BEFORE the field existed has no `cause` key and MUST
1104 /// decode without panic, defaulting to `WorkflowIntent` — the pre-field
1105 /// behavior (a reopen never resurrects it) — deterministically. The old
1106 /// wire form is the exact pre-field bytes: the current serialization with
1107 /// the `cause` key removed.
1108 #[test]
1109 fn timer_cancelled_decodes_old_history_without_cause_as_workflow_intent()
1110 -> Result<(), Box<dyn std::error::Error>> {
1111 // A NON-default cause proves the strip (not the value) drives the default.
1112 let cancelled = Event::TimerCancelled {
1113 envelope: envelope(7),
1114 timer_id: TimerId::named("deadline")?,
1115 cause: TimerCancelCause::CancelTeardown,
1116 };
1117
1118 let mut value = serde_json::to_value(&cancelled)?;
1119 let data = value
1120 .get_mut("data")
1121 .and_then(serde_json::Value::as_object_mut)
1122 .ok_or("TimerCancelled must serialize to a tagged object with `data`")?;
1123 assert!(
1124 data.remove("cause").is_some(),
1125 "the current wire form must contain cause before we strip it"
1126 );
1127 let old_wire = serde_json::to_string(&value)?;
1128 for _ in 0..4 {
1129 let decoded = serde_json::from_str::<Event>(&old_wire)?;
1130 match &decoded {
1131 Event::TimerCancelled { cause, .. } => assert_eq!(
1132 *cause,
1133 TimerCancelCause::WorkflowIntent,
1134 "a missing cause must default to WorkflowIntent (never resurrected)"
1135 ),
1136 other => {
1137 return Err(format!("expected TimerCancelled, got {other:?}").into());
1138 }
1139 }
1140 }
1141 Ok(())
1142 }
1143
1144 /// Pause/resume (#204) round-trip: the two new NON-terminal lifecycle markers
1145 /// carry plain fields and survive the durable JSON wire unchanged.
1146 #[test]
1147 fn pause_resume_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1148 let events = vec![
1149 Event::WorkflowPaused {
1150 envelope: envelope(2),
1151 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1152 reason: Some(String::from("operator hold")),
1153 operator: Some(String::from("tom")),
1154 },
1155 Event::WorkflowPaused {
1156 envelope: envelope(3),
1157 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1158 reason: None,
1159 operator: None,
1160 },
1161 Event::WorkflowResumed {
1162 envelope: envelope(4),
1163 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1164 operator: Some(String::from("tom")),
1165 },
1166 ];
1167 for event in &events {
1168 round_trip(event)?;
1169 }
1170 Ok(())
1171 }
1172
1173 /// GATE-6 back-compat: an OLD history serialized before pause/resume existed
1174 /// decodes byte-identically — it simply never contains the new variants. We
1175 /// prove the whole event enum still decodes an old-shape history with no new
1176 /// variants present (the decode round-trip test the brief requires), and that
1177 /// adding the variants did not change the encoding of any existing variant.
1178 #[test]
1179 fn old_history_without_pause_resume_decodes_unchanged() -> Result<(), Box<dyn std::error::Error>>
1180 {
1181 let started = Event::WorkflowStarted {
1182 envelope: envelope(1),
1183 workflow_type: String::from("checkout"),
1184 input: payload("input")?,
1185 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1186 parent_run_id: None,
1187 package_version: package_version(),
1188 };
1189 let completed = Event::WorkflowCompleted {
1190 envelope: envelope(2),
1191 result: payload("result")?,
1192 };
1193 // Serialize an old-shape history and decode it back: no new variant is
1194 // present, and every existing variant round-trips exactly.
1195 let history = vec![started, completed];
1196 let json = serde_json::to_string(&history)?;
1197 let decoded = serde_json::from_str::<Vec<Event>>(&json)?;
1198 assert_eq!(history, decoded);
1199 Ok(())
1200 }
1201
1202 /// #144: the start-time task queue projects from the `aion.task_queue`
1203 /// search attribute recorded by `SearchAttributesUpdated`, mirroring the
1204 /// `aion.namespace` projection. A later update overrides an earlier value.
1205 #[test]
1206 fn start_time_task_queue_projects_from_recorded_attribute()
1207 -> Result<(), Box<dyn std::error::Error>> {
1208 use super::{START_TIME_TASK_QUEUE_ATTRIBUTE, start_time_task_queue};
1209 use crate::SearchAttributeValue;
1210
1211 let events = vec![
1212 Event::WorkflowStarted {
1213 envelope: envelope(1),
1214 workflow_type: String::from("checkout"),
1215 input: payload("input")?,
1216 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1217 parent_run_id: None,
1218 package_version: package_version(),
1219 },
1220 Event::SearchAttributesUpdated {
1221 envelope: envelope(2),
1222 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1223 attributes: HashMap::from([(
1224 START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
1225 SearchAttributeValue::String(String::from("gpu")),
1226 )]),
1227 },
1228 ];
1229
1230 assert_eq!(start_time_task_queue(&events).as_deref(), Some("gpu"));
1231 Ok(())
1232 }
1233
1234 /// #144 back-compat: a history with no recorded `aion.task_queue` attribute
1235 /// projects `None`, so callers fall back to the named default.
1236 #[test]
1237 fn start_time_task_queue_is_none_without_the_attribute()
1238 -> Result<(), Box<dyn std::error::Error>> {
1239 use super::start_time_task_queue;
1240
1241 let events = vec![Event::WorkflowStarted {
1242 envelope: envelope(1),
1243 workflow_type: String::from("checkout"),
1244 input: payload("input")?,
1245 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1246 parent_run_id: None,
1247 package_version: package_version(),
1248 }];
1249
1250 assert_eq!(start_time_task_queue(&events), None);
1251 Ok(())
1252 }
1253
1254 /// #211: the display name projects from the `aion.display_name` search
1255 /// attribute recorded by `SearchAttributesUpdated`, mirroring the
1256 /// `aion.task_queue` projection.
1257 #[test]
1258 fn display_name_projects_from_recorded_attribute() -> Result<(), Box<dyn std::error::Error>> {
1259 use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
1260 use crate::SearchAttributeValue;
1261
1262 let events = vec![
1263 Event::WorkflowStarted {
1264 envelope: envelope(1),
1265 workflow_type: String::from("checkout"),
1266 input: payload("input")?,
1267 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1268 parent_run_id: None,
1269 package_version: package_version(),
1270 },
1271 Event::SearchAttributesUpdated {
1272 envelope: envelope(2),
1273 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1274 attributes: HashMap::from([(
1275 DISPLAY_NAME_ATTRIBUTE.to_owned(),
1276 SearchAttributeValue::String(String::from("Nightly settlement")),
1277 )]),
1278 },
1279 ];
1280
1281 assert_eq!(display_name(&events).as_deref(), Some("Nightly settlement"));
1282 Ok(())
1283 }
1284
1285 /// #211 back-compat: a history with no recorded `aion.display_name`
1286 /// attribute projects `None`, so the unnamed run renders as its bare UUID.
1287 #[test]
1288 fn display_name_is_none_without_the_attribute() -> Result<(), Box<dyn std::error::Error>> {
1289 use super::display_name;
1290
1291 let events = vec![Event::WorkflowStarted {
1292 envelope: envelope(1),
1293 workflow_type: String::from("checkout"),
1294 input: payload("input")?,
1295 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1296 parent_run_id: None,
1297 package_version: package_version(),
1298 }];
1299
1300 assert_eq!(display_name(&events), None);
1301 Ok(())
1302 }
1303
1304 /// #211 rename invariant: a later `SearchAttributesUpdated` overrides the
1305 /// earlier name (last write wins) while BOTH remain in history — a rename
1306 /// is a recorded event, never an overwrite of the past.
1307 #[test]
1308 fn display_name_later_update_overrides_earlier() -> Result<(), Box<dyn std::error::Error>> {
1309 use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
1310 use crate::SearchAttributeValue;
1311
1312 let name_event = |seq: u64, name: &str| Event::SearchAttributesUpdated {
1313 envelope: envelope(seq),
1314 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1315 attributes: HashMap::from([(
1316 DISPLAY_NAME_ATTRIBUTE.to_owned(),
1317 SearchAttributeValue::String(String::from(name)),
1318 )]),
1319 };
1320 let events = vec![
1321 Event::WorkflowStarted {
1322 envelope: envelope(1),
1323 workflow_type: String::from("checkout"),
1324 input: payload("input")?,
1325 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1326 parent_run_id: None,
1327 package_version: package_version(),
1328 },
1329 name_event(2, "first name"),
1330 name_event(3, "second name"),
1331 ];
1332
1333 assert_eq!(display_name(&events).as_deref(), Some("second name"));
1334 // NOTE: the "history keeps both names" half of this invariant is NOT
1335 // asserted here. `events` is this test's own literal, so counting it
1336 // would measure the `vec!` rather than any behaviour. It is proven
1337 // where it can be — against events a real recorder appended — by
1338 // `aion/tests/rename_e2e.rs`
1339 // `renaming_twice_supersedes_while_history_keeps_both_names`.
1340 Ok(())
1341 }
1342
1343 #[test]
1344 fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
1345 let workflow_id = WorkflowId::new_v4();
1346 let recorded_at = recorded_at();
1347 let envelope = EventEnvelope {
1348 seq: 17,
1349 recorded_at,
1350 workflow_id: workflow_id.clone(),
1351 };
1352 let event = Event::WorkflowStarted {
1353 envelope,
1354 workflow_type: String::from("checkout"),
1355 input: payload("input")?,
1356 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1357 parent_run_id: None,
1358 package_version: package_version(),
1359 };
1360
1361 assert_eq!(event.seq(), 17);
1362 assert_eq!(event.recorded_at(), &recorded_at);
1363 assert_eq!(event.workflow_id(), &workflow_id);
1364 Ok(())
1365 }
1366
1367 #[test]
1368 fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1369 let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
1370 let events = vec![
1371 Event::WorkflowStarted {
1372 envelope: envelope(1),
1373 workflow_type: String::from("checkout"),
1374 input: payload("workflow-input")?,
1375 run_id: RunId::new(uuid::Uuid::from_u128(1)),
1376 parent_run_id: None,
1377 package_version: package_version(),
1378 },
1379 Event::WorkflowCompleted {
1380 envelope: envelope(2),
1381 result: payload("workflow-result")?,
1382 },
1383 Event::WorkflowFailed {
1384 envelope: envelope(3),
1385 error: workflow_error("workflow failed"),
1386 },
1387 Event::WorkflowCancelled {
1388 envelope: envelope(4),
1389 reason: String::from("caller requested cancellation"),
1390 },
1391 Event::WorkflowTimedOut {
1392 envelope: envelope(5),
1393 timeout: String::from("execution"),
1394 },
1395 Event::ActivityScheduled {
1396 envelope: envelope(6),
1397 activity_id: ActivityId::from_sequence_position(6),
1398 activity_type: String::from("charge-card"),
1399 input: payload("activity-input")?,
1400 task_queue: String::from("claude"),
1401 node: Some(String::from("box-7")),
1402 },
1403 Event::ActivityStarted {
1404 envelope: envelope(7),
1405 activity_id: ActivityId::from_sequence_position(6),
1406 attempt: 1,
1407 },
1408 Event::ActivityCompleted {
1409 envelope: envelope(8),
1410 activity_id: ActivityId::from_sequence_position(6),
1411 result: payload("activity-result")?,
1412 attempt: 1,
1413 },
1414 Event::ActivityFailed {
1415 envelope: envelope(9),
1416 activity_id: ActivityId::from_sequence_position(6),
1417 error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
1418 attempt: 1,
1419 },
1420 Event::ActivityCancelled {
1421 envelope: envelope(10),
1422 activity_id: ActivityId::from_sequence_position(6),
1423 attempt: 1,
1424 },
1425 Event::TimerStarted {
1426 envelope: envelope(11),
1427 timer_id: TimerId::anonymous(11),
1428 fire_at,
1429 },
1430 Event::TimerFired {
1431 envelope: envelope(12),
1432 timer_id: TimerId::anonymous(11),
1433 },
1434 Event::TimerCancelled {
1435 envelope: envelope(13),
1436 timer_id: TimerId::named("reminder")?,
1437 cause: TimerCancelCause::WorkflowIntent,
1438 },
1439 Event::SignalReceived {
1440 envelope: envelope(14),
1441 name: String::from("approve"),
1442 payload: payload("signal")?,
1443 },
1444 Event::SignalSent {
1445 envelope: envelope(15),
1446 target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
1447 name: String::from("approve"),
1448 payload: payload("signal-sent")?,
1449 },
1450 ];
1451
1452 for event in events {
1453 round_trip(&event)?;
1454 }
1455 Ok(())
1456 }
1457
1458 #[test]
1459 fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1460 let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
1461 let events = vec![
1462 Event::ChildWorkflowStarted {
1463 envelope: envelope(16),
1464 child_workflow_id: child_workflow_id.clone(),
1465 workflow_type: String::from("fulfillment"),
1466 input: payload("child-input")?,
1467 package_version: package_version(),
1468 },
1469 Event::ChildWorkflowCompleted {
1470 envelope: envelope(16),
1471 child_workflow_id: child_workflow_id.clone(),
1472 result: payload("child-result")?,
1473 },
1474 Event::ChildWorkflowFailed {
1475 envelope: envelope(17),
1476 child_workflow_id: child_workflow_id.clone(),
1477 error: workflow_error("child failed"),
1478 },
1479 Event::ChildWorkflowCancelled {
1480 envelope: envelope(18),
1481 child_workflow_id,
1482 },
1483 ];
1484
1485 for event in events {
1486 round_trip(&event)?;
1487 }
1488 Ok(())
1489 }
1490
1491 #[test]
1492 fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1493 let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
1494 let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
1495 let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
1496 let events = vec![
1497 Event::WorkflowContinuedAsNew {
1498 envelope: envelope(19),
1499 input: payload("continued-input")?,
1500 workflow_type: Some(String::from("checkout-v2")),
1501 parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
1502 },
1503 Event::SearchAttributesUpdated {
1504 envelope: envelope(20),
1505 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1506 attributes: HashMap::from([(
1507 String::from("customer_id"),
1508 SearchAttributeValue::String(String::from("cust-123")),
1509 )]),
1510 },
1511 Event::ScheduleCreated {
1512 envelope: envelope(20),
1513 schedule_id: schedule_id.clone(),
1514 config: schedule_config("schedule-created")?,
1515 },
1516 Event::ScheduleUpdated {
1517 envelope: envelope(21),
1518 schedule_id: schedule_id.clone(),
1519 config: schedule_config("schedule-updated")?,
1520 },
1521 Event::SchedulePaused {
1522 envelope: envelope(22),
1523 schedule_id: schedule_id.clone(),
1524 },
1525 Event::ScheduleResumed {
1526 envelope: envelope(23),
1527 schedule_id: schedule_id.clone(),
1528 },
1529 Event::ScheduleDeleted {
1530 envelope: envelope(24),
1531 schedule_id: schedule_id.clone(),
1532 },
1533 Event::ScheduleTriggered {
1534 envelope: envelope(25),
1535 schedule_id,
1536 workflow_id: triggered_workflow_id,
1537 run_id: triggered_run_id,
1538 },
1539 ];
1540
1541 for event in events {
1542 round_trip(&event)?;
1543 }
1544 Ok(())
1545 }
1546}