aion/durability/replay_inspect.rs
1//! Time-travel inspection lens over a recorded run (WA-004).
2//!
3//! This is a read-only projection over data the engine already records. It adds
4//! no second store, no new persistence, and no `Event`-schema change (CN5,
5//! ADR-007, P6). Per event it surfaces the workflow-visible state projection and
6//! the recorded `now()` — the event's recorded timestamp, the authoritative
7//! determinism clock (never wall-clock time, exactly the value the production
8//! `now()` NIF serves). On a [`NonDeterminismError`] it surfaces the exact
9//! divergent command (expected vs found at the sequence) the resolver already
10//! computes — never recomputed here (C18).
11//!
12//! The state projection reuses the production replay path: it reconstructs the
13//! command stream the engine fed the resolver from the recorded events and
14//! drives the real [`Replay`] over it, so the resolutions come from replay and
15//! not a parallel engine. The what-if re-run forks the same path from a chosen
16//! event with a mocked outcome, entirely in memory, and reports the resulting
17//! path.
18//!
19//! ## Random is a draw-ordinal projection, not a per-event field
20//!
21//! Workflow-visible `random()` is **not** recorded and **not** attached per
22//! event. The production random path is the determinism NIF
23//! ([`crate::runtime::nif_determinism`]): `workflow.random()` /
24//! `workflow.random_int()` draw `deterministic_float` / `deterministic_i64`
25//! keyed by a per-call *draw ordinal* the workflow handle hands out, advanced
26//! once per `random()` call the workflow code actually makes. The number and
27//! event-positions of those draws exist only while the workflow code runs; they
28//! are not derivable from the event log alone (no `Random` event variant
29//! exists, by design — random is deterministic, not recorded).
30//!
31//! So the lens does **not** invent a per-event random stream. Instead it exposes
32//! a [`RandomDrawProjection`] bound to the run's `(WorkflowId, RunId)` that
33//! computes, for any draw ordinal `n`, the *exact* value the production
34//! `random()` / `random_int()` path serves at that ordinal — by calling the
35//! same `deterministic_float` / `deterministic_i64` the NIF calls. The true
36//! per-step draw *count* is recoverable only by driving the workflow code in an
37//! instrumented in-VM replay; that faithful lens is deferred (see the brief's
38//! open issue) and this projection never fabricates a count it cannot know.
39
40use aion_core::{ActivityError, Event, Payload, RunId, WorkflowError, WorkflowId};
41use chrono::{DateTime, Utc};
42
43use crate::durability::{
44 Command, CorrelationKey, DurabilityError, NON_DETERMINISM_WORKFLOW_ERROR_PREFIX,
45 NonDeterminismError, Replay, ReplayStep, ReplayTerminal, Resolution,
46 correlation::correlation_keys_for_history, current_run_segment,
47};
48use crate::runtime::nif_determinism::{deterministic_float, deterministic_i64};
49
50/// Complete inspection of one recorded run, projected from history and replay.
51#[derive(Clone, Debug, PartialEq)]
52pub struct RunInspection {
53 /// Workflow whose history was inspected.
54 pub workflow_id: WorkflowId,
55 /// Run whose segment was projected.
56 pub run_id: RunId,
57 /// One projected step per recorded event in the run segment, in order.
58 pub steps: Vec<InspectStep>,
59 /// The run's deterministic `random()` draw-ordinal projection.
60 ///
61 /// This computes the value the production `random()` / `random_int()` path
62 /// serves at a given draw ordinal for this `(WorkflowId, RunId)`. It is a
63 /// projection, not a per-event field: see the module docs for why the lens
64 /// cannot attach a random draw to each event without running the workflow
65 /// code in-VM.
66 pub random: RandomDrawProjection,
67 /// The divergent command at the run's non-determinism fault, when one exists.
68 pub divergence: Option<DivergentCommand>,
69}
70
71/// The deterministic `random()` draw-ordinal projection for one run.
72///
73/// Bound to the run's `(WorkflowId, RunId)`, it reproduces — for any draw
74/// ordinal — the exact value the production determinism NIF serves, by calling
75/// the same `deterministic_float` / `deterministic_i64` the NIF calls. Draw
76/// ordinals start at `0` (the first `workflow.random()` call a run makes draws
77/// ordinal `0`), matching the handle's pre-increment sequence counter.
78///
79/// This carries no draw count: the number of draws a run makes is workflow-code
80/// dependent and unrecoverable from history alone (module docs). It answers
81/// "what would `random()` return at ordinal `n`?", never "how many draws did
82/// step `k` make?".
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct RandomDrawProjection {
85 workflow_id: WorkflowId,
86 run_id: RunId,
87}
88
89impl RandomDrawProjection {
90 /// Binds the projection to a run.
91 #[must_use]
92 const fn new(workflow_id: WorkflowId, run_id: RunId) -> Self {
93 Self {
94 workflow_id,
95 run_id,
96 }
97 }
98
99 /// The `f64` in `[0.0, 1.0)` `workflow.random()` returns at draw `ordinal`.
100 ///
101 /// This is byte-for-byte the value the production `random()` NIF serves at
102 /// that ordinal for this run — it calls the same `deterministic_float`.
103 #[must_use]
104 pub fn random_at(&self, ordinal: u64) -> f64 {
105 deterministic_float(&self.workflow_id, &self.run_id, ordinal)
106 }
107
108 /// The `i64` in `[min, max]` `workflow.random_int(min, max)` returns at draw
109 /// `ordinal`.
110 ///
111 /// This is the value the production `random_int` NIF serves at that ordinal
112 /// for this run — it calls the same `deterministic_i64`.
113 ///
114 /// # Errors
115 ///
116 /// Returns [`DurabilityError::HistoryShape`] when `min > max`, mirroring the
117 /// NIF's loud rejection of an inverted range (no silent clamping).
118 pub fn random_int_at(&self, ordinal: u64, min: i64, max: i64) -> Result<i64, DurabilityError> {
119 if min > max {
120 return Err(DurabilityError::HistoryShape {
121 reason: format!(
122 "random_int_at range is inverted: min {min} is greater than max {max}"
123 ),
124 });
125 }
126 Ok(deterministic_i64(
127 &self.workflow_id,
128 &self.run_id,
129 ordinal,
130 min,
131 max,
132 ))
133 }
134}
135
136/// One recorded event's projection: its determinism context and state delta.
137#[derive(Clone, Debug, PartialEq)]
138pub struct InspectStep {
139 /// Sequence number of the recorded event this step projects.
140 pub seq: u64,
141 /// Stable event-variant name for display.
142 pub event_kind: &'static str,
143 /// Correlation identity of the event, when it starts or carries one.
144 pub correlation_key: Option<CorrelationKey>,
145 /// Workflow-visible `now` at this step: the event's recorded timestamp.
146 ///
147 /// This is the authoritative determinism clock, never wall-clock time, and
148 /// is exactly the value the production `now()` NIF serves for this step.
149 pub now: DateTime<Utc>,
150 /// The workflow-visible state delta this event contributes.
151 pub projection: StepProjection,
152}
153
154/// The workflow-visible state delta one recorded event contributes.
155#[derive(Clone, Debug, PartialEq)]
156pub enum StepProjection {
157 /// The run started with its type and input.
158 Started {
159 /// Workflow type recorded at start.
160 workflow_type: String,
161 /// Opaque input payload recorded at start.
162 input: Payload,
163 },
164 /// A world-touching command resolved from recorded history to this outcome.
165 Resolved(Resolution),
166 /// The run reached a recorded terminal state.
167 Terminal(ReplayTerminal),
168 /// An asynchronous arrival event was recorded (not a command outcome row).
169 AsyncArrival {
170 /// Stable variant name of the asynchronous arrival event.
171 kind: &'static str,
172 },
173 /// A recorded event that contributes no replay-visible state delta.
174 NonReplay,
175}
176
177/// The divergent command at a non-determinism fault, expected vs found.
178///
179/// Built directly from the [`NonDeterminismError`] the resolver computes; the
180/// expected/found shapes are the resolver's own, never recomputed here (C18).
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub struct DivergentCommand {
183 /// Sequence position of the recorded event at the mismatch.
184 pub seq: u64,
185 /// Shape of the command the workflow issued.
186 pub expected: String,
187 /// Shape of the recorded event found at the cursor position.
188 pub found: String,
189}
190
191impl From<&NonDeterminismError> for DivergentCommand {
192 fn from(error: &NonDeterminismError) -> Self {
193 Self {
194 seq: error.seq,
195 expected: error.expected.clone(),
196 found: error.found.clone(),
197 }
198 }
199}
200
201/// A mocked outcome substituted at the what-if fork point.
202///
203/// The caller chooses the outcome explicitly; there is no default (ADR-001,
204/// CN2). Each variant carries the data the resulting [`Resolution`] needs.
205#[derive(Clone, Debug, PartialEq)]
206pub enum MockOutcome {
207 /// Replace the activity at the fork with a successful completion.
208 ActivityCompleted(Payload),
209 /// Replace the activity at the fork with a terminal failure.
210 ActivityFailed(ActivityError),
211 /// Replace the child at the fork with a successful completion.
212 ChildCompleted(Payload),
213 /// Replace the child at the fork with a terminal failure.
214 ChildFailed(WorkflowError),
215 /// Replace the awaited signal at the fork with a delivered payload.
216 SignalDelivered(Payload),
217 /// Replace the timer at the fork with a firing.
218 TimerFired,
219}
220
221/// The path a what-if re-run produces after the mocked fork point.
222#[derive(Clone, Debug, PartialEq)]
223pub enum WhatIfOutcome {
224 /// The mocked resolution replaced the recorded one and the run resumed,
225 /// projecting this resolution at the fork.
226 Resolved {
227 /// Sequence position of the recorded event that was forked.
228 from_seq: u64,
229 /// Resolution the mocked outcome produced at the fork.
230 resolution: Resolution,
231 },
232 /// Driving the reconstructed command stream over the forked history reached
233 /// a recorded terminal state with no live handoff.
234 Terminal(ReplayTerminal),
235 /// The forked command stream diverged from the forked history.
236 Diverged(DivergentCommand),
237}
238
239/// Projects a complete inspection of `run_id` from a workflow's full history.
240///
241/// The history is sliced to the run's segment (reopen / continue-as-new aware),
242/// then each recorded event is projected. World-touching events are resolved by
243/// driving the real [`Replay`] over the command stream reconstructed from
244/// history, so resolutions come from the production replay path. A
245/// non-determinism fault is surfaced as [`RunInspection::divergence`] using the
246/// resolver's own expected-vs-found shapes.
247///
248/// # Errors
249///
250/// Returns [`DurabilityError::HistoryShape`] when the history lacks a
251/// `WorkflowStarted` for `run_id` or is otherwise malformed.
252pub fn inspect_run(history: Vec<Event>, run_id: &RunId) -> Result<RunInspection, DurabilityError> {
253 let segment = current_run_segment(history, run_id)?;
254 if segment.is_empty() {
255 return Err(empty_segment_error(run_id));
256 }
257 let workflow_id = run_workflow_id(&segment)?;
258 let keys = correlation_keys_for_history(&segment);
259
260 let mut replay = Replay::new(&workflow_id, run_id, segment.clone())?;
261 let commands = reconstruct_commands(&segment);
262 let mut command_index = 0;
263
264 let mut steps = Vec::with_capacity(segment.len());
265
266 for (event, correlation_key) in segment.iter().zip(keys) {
267 let projection = match command_for_event(event) {
268 CommandSlot::Issues => {
269 let Some(command) = commands.get(command_index).cloned() else {
270 return Err(DurabilityError::HistoryShape {
271 reason: format!(
272 "reconstructed command stream is shorter than history at seq {}",
273 event.seq()
274 ),
275 });
276 };
277 command_index += 1;
278 match replay.step(&command) {
279 Ok(ReplayStep::Recorded(resolution)) => StepProjection::Resolved(resolution),
280 Ok(ReplayStep::Terminal(terminal)) => StepProjection::Terminal(terminal),
281 // ResumeLive contributes no recorded delta. A live
282 // non-determinism fault is likewise not surfaced from the
283 // reconstructed stream: the resolver already recorded the
284 // authoritative expected-vs-found terminal, read back below
285 // (C18, CN5). Re-deriving it here would be a second,
286 // possibly divergent computation of the same fault.
287 Ok(ReplayStep::ResumeLive) | Err(DurabilityError::NonDeterminism(_)) => {
288 StepProjection::NonReplay
289 }
290 Err(other) => return Err(other),
291 }
292 }
293 CommandSlot::Started {
294 workflow_type,
295 input,
296 } => StepProjection::Started {
297 workflow_type,
298 input,
299 },
300 CommandSlot::Terminal => StepProjection::Terminal(terminal_projection(event)?),
301 CommandSlot::AsyncArrival => StepProjection::AsyncArrival {
302 kind: event_kind(event),
303 },
304 CommandSlot::NonReplay => StepProjection::NonReplay,
305 };
306
307 steps.push(InspectStep {
308 seq: event.seq(),
309 event_kind: event_kind(event),
310 correlation_key,
311 now: *event.recorded_at(),
312 projection,
313 });
314 }
315
316 // The recorded non-determinism terminal (a WorkflowFailed the resolver
317 // produced via fail_on_violation) is the authoritative divergence: the
318 // resolver already computed and recorded the expected-vs-found at the
319 // sequence, so we read it back rather than recompute it (C18, CN5).
320 let divergence = recorded_divergence(&segment);
321
322 Ok(RunInspection {
323 workflow_id: workflow_id.clone(),
324 run_id: run_id.clone(),
325 steps,
326 random: RandomDrawProjection::new(workflow_id, run_id.clone()),
327 divergence,
328 })
329}
330
331/// Forks a what-if re-run from `from_seq` with a mocked outcome.
332///
333/// The recorded history is truncated at `from_seq` and the event at that
334/// sequence is replaced by the event the mocked outcome implies; the
335/// reconstructed command stream is then driven over the forked history through
336/// the real [`Replay`]. The fork is entirely in memory and reads the source
337/// history only — it never appends to the production event store or a live
338/// recorder (invariant #3, CN5, ADR-007).
339///
340/// # Errors
341///
342/// Returns [`DurabilityError::HistoryShape`] when `from_seq` is not a
343/// world-touching command outcome in the run segment, when the mocked outcome
344/// does not match the family of the event at `from_seq`, or when the history is
345/// malformed.
346pub fn what_if_from(
347 history: Vec<Event>,
348 run_id: &RunId,
349 from_seq: u64,
350 mocked: &MockOutcome,
351) -> Result<WhatIfOutcome, DurabilityError> {
352 let segment = current_run_segment(history, run_id)?;
353 let workflow_id = run_workflow_id(&segment)?;
354
355 let fork_index = segment
356 .iter()
357 .position(|event| event.seq() == from_seq)
358 .ok_or_else(|| DurabilityError::HistoryShape {
359 reason: format!("run segment has no event at seq {from_seq} to fork from"),
360 })?;
361
362 let forked = forked_history(&segment, fork_index, &workflow_id, mocked)?;
363 let mut replay = Replay::new(&workflow_id, run_id, forked.clone())?;
364 let commands = reconstruct_commands(&forked);
365
366 let mut last_resolution = None;
367 for command in commands {
368 match replay.step(&command) {
369 Ok(ReplayStep::Recorded(resolution)) => last_resolution = Some(resolution),
370 Ok(ReplayStep::Terminal(terminal)) => {
371 return Ok(WhatIfOutcome::Terminal(terminal));
372 }
373 Ok(ReplayStep::ResumeLive) => break,
374 Err(DurabilityError::NonDeterminism(error)) => {
375 return Ok(WhatIfOutcome::Diverged(DivergentCommand::from(&error)));
376 }
377 Err(other) => return Err(other),
378 }
379 }
380
381 match last_resolution {
382 Some(resolution) => Ok(WhatIfOutcome::Resolved {
383 from_seq,
384 resolution,
385 }),
386 None => Err(DurabilityError::HistoryShape {
387 reason: format!(
388 "what-if from seq {from_seq} produced no resolution before history end"
389 ),
390 }),
391 }
392}
393
394/// Classifies one recorded event for projection.
395enum CommandSlot {
396 /// The event is the outcome of a reconstructed world-touching command.
397 Issues,
398 /// The event started the run.
399 Started {
400 workflow_type: String,
401 input: Payload,
402 },
403 /// The event is a recorded terminal lifecycle event.
404 Terminal,
405 /// The event is an asynchronous arrival (fire, delivery, child terminal).
406 AsyncArrival,
407 /// The event contributes no replay-visible state delta.
408 NonReplay,
409}
410
411fn command_for_event(event: &Event) -> CommandSlot {
412 match event {
413 Event::WorkflowStarted {
414 workflow_type,
415 input,
416 ..
417 } => CommandSlot::Started {
418 workflow_type: workflow_type.clone(),
419 input: input.clone(),
420 },
421 // Command-issuing anchors: the resolver consumes their outcome events,
422 // so the projection drives a reconstructed command for each anchor.
423 Event::ActivityScheduled { .. }
424 | Event::TimerStarted { .. }
425 | Event::SignalReceived { .. }
426 | Event::ChildWorkflowStarted { .. } => CommandSlot::Issues,
427 Event::WorkflowCompleted { .. }
428 | Event::WorkflowFailed { .. }
429 | Event::WorkflowCancelled { .. }
430 | Event::WorkflowTimedOut { .. }
431 | Event::WorkflowContinuedAsNew { .. } => CommandSlot::Terminal,
432 Event::TimerFired { .. }
433 | Event::ActivityCompleted { .. }
434 | Event::ActivityFailed { .. }
435 | Event::ChildWorkflowCompleted { .. }
436 | Event::ChildWorkflowFailed { .. } => CommandSlot::AsyncArrival,
437 _ => CommandSlot::NonReplay,
438 }
439}
440
441/// Reconstructs the world-touching command stream the engine fed the resolver.
442///
443/// The stream is derived from recorded anchor events in history order: each
444/// activity schedule, timer start, signal receipt, and child spawn becomes the
445/// command that produced it, with a child spawn followed by its await. The
446/// terminal completion becomes a `CompleteWorkflow` command. This is exactly the
447/// command stream replay consumes, so resolution comes from the production path.
448fn reconstruct_commands(segment: &[Event]) -> Vec<Command> {
449 let keys = correlation_keys_for_history(segment);
450 let mut commands = Vec::new();
451
452 for (event, key) in segment.iter().zip(keys) {
453 match event {
454 Event::ActivityScheduled {
455 activity_type,
456 input,
457 ..
458 } => {
459 if let Some(key) = key {
460 commands.push(Command::RunActivity {
461 key,
462 activity_type: activity_type.clone(),
463 input: input.clone(),
464 });
465 }
466 }
467 Event::TimerStarted { fire_at, .. } => {
468 if let Some(key) = key {
469 commands.push(Command::StartTimer {
470 key,
471 fire_at: *fire_at,
472 });
473 }
474 }
475 Event::SignalReceived { .. } => {
476 if let Some(key) = key {
477 commands.push(Command::AwaitSignal { key });
478 }
479 }
480 Event::ChildWorkflowStarted {
481 child_workflow_id,
482 workflow_type,
483 input,
484 ..
485 } => {
486 if let Some(key) = key {
487 commands.push(Command::SpawnChild {
488 key,
489 workflow_type: workflow_type.clone(),
490 input: input.clone(),
491 });
492 commands.push(Command::AwaitChild {
493 child_workflow_id: child_workflow_id.clone(),
494 });
495 }
496 }
497 Event::WorkflowCompleted { result, .. } => {
498 commands.push(Command::CompleteWorkflow {
499 result: result.clone(),
500 });
501 }
502 _ => {}
503 }
504 }
505
506 commands
507}
508
509/// Builds the forked history for a what-if: the segment up to and including the
510/// fork point, with the outcome at the fork replaced by the mocked outcome.
511fn forked_history(
512 segment: &[Event],
513 fork_index: usize,
514 workflow_id: &WorkflowId,
515 mocked: &MockOutcome,
516) -> Result<Vec<Event>, DurabilityError> {
517 let anchor = &segment[fork_index];
518 let mocked_outcome = mocked_outcome_event(anchor, workflow_id, mocked)?;
519
520 let mut forked: Vec<Event> = segment[..=fork_index].to_vec();
521 forked.push(mocked_outcome);
522 Ok(forked)
523}
524
525/// Produces the recorded outcome event a mocked outcome implies for one anchor.
526fn mocked_outcome_event(
527 anchor: &Event,
528 workflow_id: &WorkflowId,
529 mocked: &MockOutcome,
530) -> Result<Event, DurabilityError> {
531 let envelope = aion_core::EventEnvelope {
532 seq: anchor.seq().saturating_add(1),
533 recorded_at: *anchor.recorded_at(),
534 workflow_id: workflow_id.clone(),
535 };
536
537 match (anchor, mocked) {
538 (Event::ActivityScheduled { activity_id, .. }, MockOutcome::ActivityCompleted(result)) => {
539 Ok(Event::ActivityCompleted {
540 envelope,
541 activity_id: activity_id.clone(),
542 result: result.clone(),
543 // NOI-0: the mocked completion resolves the scheduled activity's first (and only)
544 // delivery — attempt 1 (one-based), matching the sibling `ActivityFailed` mock below.
545 attempt: 1,
546 })
547 }
548 (Event::ActivityScheduled { activity_id, .. }, MockOutcome::ActivityFailed(error)) => {
549 ensure_terminal_activity_error(error)?;
550 Ok(Event::ActivityFailed {
551 envelope,
552 activity_id: activity_id.clone(),
553 error: error.clone(),
554 attempt: 1,
555 })
556 }
557 (Event::TimerStarted { timer_id, .. }, MockOutcome::TimerFired) => Ok(Event::TimerFired {
558 envelope,
559 timer_id: timer_id.clone(),
560 }),
561 (Event::SignalReceived { name, .. }, MockOutcome::SignalDelivered(payload)) => {
562 Ok(Event::SignalReceived {
563 envelope,
564 name: name.clone(),
565 payload: payload.clone(),
566 })
567 }
568 (
569 Event::ChildWorkflowStarted {
570 child_workflow_id, ..
571 },
572 MockOutcome::ChildCompleted(result),
573 ) => Ok(Event::ChildWorkflowCompleted {
574 envelope,
575 child_workflow_id: child_workflow_id.clone(),
576 result: result.clone(),
577 }),
578 (
579 Event::ChildWorkflowStarted {
580 child_workflow_id, ..
581 },
582 MockOutcome::ChildFailed(error),
583 ) => Ok(Event::ChildWorkflowFailed {
584 envelope,
585 child_workflow_id: child_workflow_id.clone(),
586 error: error.clone(),
587 }),
588 (anchor, mocked) => Err(DurabilityError::HistoryShape {
589 reason: format!(
590 "mocked outcome {mocked:?} does not match the {} anchor at the fork",
591 event_kind(anchor)
592 ),
593 }),
594 }
595}
596
597fn ensure_terminal_activity_error(error: &ActivityError) -> Result<(), DurabilityError> {
598 if error.is_retryable() {
599 return Err(DurabilityError::HistoryShape {
600 reason: "mocked activity failure must be terminal to resolve at the fork".to_owned(),
601 });
602 }
603 Ok(())
604}
605
606fn terminal_projection(event: &Event) -> Result<ReplayTerminal, DurabilityError> {
607 match event {
608 Event::WorkflowCompleted { result, .. } => Ok(ReplayTerminal::Completed(result.clone())),
609 Event::WorkflowFailed { error, .. } => Ok(ReplayTerminal::Failed(error.clone())),
610 Event::WorkflowCancelled { reason, .. } => Ok(ReplayTerminal::Cancelled(reason.clone())),
611 Event::WorkflowTimedOut { timeout, .. } => Ok(ReplayTerminal::TimedOut(timeout.clone())),
612 Event::WorkflowContinuedAsNew { input, .. } => {
613 Ok(ReplayTerminal::ContinuedAsNew(input.clone()))
614 }
615 other => Err(DurabilityError::HistoryShape {
616 reason: format!(
617 "terminal projection requested for non-terminal event {}",
618 event_kind(other)
619 ),
620 }),
621 }
622}
623
624/// Reads the divergent command back from a recorded non-determinism terminal.
625///
626/// When replay faults, the engine records a terminal `WorkflowFailed` whose
627/// message is `fail_on_violation`'s formatting of the resolver's
628/// [`NonDeterminismError`]. This reads that message back into a
629/// [`DivergentCommand`] — the resolver's own expected-vs-found at the sequence,
630/// never recomputed here (C18). Returns `None` when the segment holds no such
631/// recorded fault, or when the message is not the recorder's own format.
632fn recorded_divergence(segment: &[Event]) -> Option<DivergentCommand> {
633 let message = segment.iter().find_map(|event| match event {
634 Event::WorkflowFailed { error, .. }
635 if error
636 .message
637 .starts_with(NON_DETERMINISM_WORKFLOW_ERROR_PREFIX) =>
638 {
639 Some((event.seq(), error.message.as_str()))
640 }
641 _ => None,
642 })?;
643
644 parse_recorded_divergence(message.0, message.1)
645}
646
647/// Parses the `expected`/`found`/`seq` fields out of a recorded fault message.
648///
649/// The message shape, fixed by `fail_on_violation` and [`NonDeterminismError`]'s
650/// `Display`, is `"... at sequence {seq}: expected {expected}, found {found}"`.
651/// Parsing is strict: an unexpected shape yields `None` rather than a guessed
652/// value (no silent fabrication). `terminal_seq` is the failure event's own
653/// sequence, used only as the fallback when the embedded sequence is unparsable.
654fn parse_recorded_divergence(terminal_seq: u64, message: &str) -> Option<DivergentCommand> {
655 let after_sequence = message.split_once(" at sequence ")?.1;
656 let (seq_text, remainder) = after_sequence.split_once(": expected ")?;
657 let (expected, found) = remainder.split_once(", found ")?;
658
659 let seq = seq_text.trim().parse::<u64>().unwrap_or(terminal_seq);
660
661 Some(DivergentCommand {
662 seq,
663 expected: expected.to_owned(),
664 found: found.to_owned(),
665 })
666}
667
668fn run_workflow_id(segment: &[Event]) -> Result<WorkflowId, DurabilityError> {
669 segment
670 .first()
671 .map(|event| event.workflow_id().clone())
672 .ok_or_else(|| DurabilityError::HistoryShape {
673 reason: "run segment is empty".to_owned(),
674 })
675}
676
677fn empty_segment_error(run_id: &RunId) -> DurabilityError {
678 DurabilityError::HistoryShape {
679 reason: format!("run segment for {run_id} is empty"),
680 }
681}
682
683/// Stable event-variant name for display, mirroring the resolver's diagnostics.
684fn event_kind(event: &Event) -> &'static str {
685 match event {
686 Event::WorkflowStarted { .. } => "WorkflowStarted",
687 Event::WorkflowCompleted { .. } => "WorkflowCompleted",
688 Event::WorkflowFailed { .. } => "WorkflowFailed",
689 Event::WorkflowCancelled { .. } => "WorkflowCancelled",
690 Event::WorkflowTimedOut { .. } => "WorkflowTimedOut",
691 Event::WorkflowContinuedAsNew { .. } => "WorkflowContinuedAsNew",
692 Event::WorkflowReopened { .. } => "WorkflowReopened",
693 Event::WorkflowPaused { .. } => "WorkflowPaused",
694 Event::WorkflowResumed { .. } => "WorkflowResumed",
695 Event::SearchAttributesUpdated { .. } => "SearchAttributesUpdated",
696 Event::ActivityScheduled { .. } => "ActivityScheduled",
697 Event::ActivityStarted { .. } => "ActivityStarted",
698 Event::ActivityCompleted { .. } => "ActivityCompleted",
699 Event::ActivityFailed { .. } => "ActivityFailed",
700 Event::ActivityCancelled { .. } => "ActivityCancelled",
701 Event::TimerStarted { .. } => "TimerStarted",
702 Event::TimerFired { .. } => "TimerFired",
703 Event::TimerCancelled { .. } => "TimerCancelled",
704 Event::WithTimeoutCompleted { .. } => "WithTimeoutCompleted",
705 Event::SignalReceived { .. } => "SignalReceived",
706 Event::SignalSent { .. } => "SignalSent",
707 Event::ChildWorkflowStarted { .. } => "ChildWorkflowStarted",
708 Event::ChildWorkflowCompleted { .. } => "ChildWorkflowCompleted",
709 Event::ChildWorkflowFailed { .. } => "ChildWorkflowFailed",
710 Event::ChildWorkflowCancelled { .. } => "ChildWorkflowCancelled",
711 Event::ScheduleCreated { .. } => "ScheduleCreated",
712 Event::ScheduleUpdated { .. } => "ScheduleUpdated",
713 Event::SchedulePaused { .. } => "SchedulePaused",
714 Event::ScheduleResumed { .. } => "ScheduleResumed",
715 Event::ScheduleDeleted { .. } => "ScheduleDeleted",
716 Event::ScheduleTriggered { .. } => "ScheduleTriggered",
717 }
718}
719
720#[cfg(test)]
721#[path = "replay_inspect_tests.rs"]
722mod replay_inspect_tests;