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