wavekat_flow/trace.rs
1//! What a flow run did — the trace (doc 48 "Traces: what the flow did,
2//! visible everywhere"). The engine appends one [`TraceStep`] per node it
3//! executes and ends with a [`FlowOutcome`]. The daemon maps this to call
4//! events (a flow timeline in Call details) and to the push uploader that
5//! syncs it to the platform; the editor later overlays step counts on the
6//! flow diagram.
7//!
8//! Serializable and self-contained (references node ids and small enums, no
9//! daemon types) so it travels unchanged from the engine core to events,
10//! storage, and the wire. This is an output format, not the document shape —
11//! so it is hand-written here, not generated from the schema.
12
13use std::time::Instant;
14
15use serde::Serialize;
16
17use crate::NodeId;
18
19/// The full record of one run of a flow against one caller.
20///
21/// Owned by the *caller* of [`crate::engine::run`], not the engine — so a
22/// daemon that races the run against the dialog's death still holds every
23/// step executed up to the drop (a caller hanging up mid-menu is exactly the
24/// trace worth keeping).
25#[derive(Debug, Clone, Serialize)]
26pub struct Trace {
27 pub flow_id: String,
28 pub flow_version: u64,
29 pub steps: Vec<TraceStep>,
30 pub outcome: FlowOutcome,
31 /// Set when the run ended abnormally (`Aborted`/`Defect`) — the
32 /// underlying effect or engine error, for logs. `None` on a clean run.
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub error: Option<String>,
35 /// When the run started — the zero point every step's [`TraceStep::at_ms`]
36 /// offsets from. Not serialized (wall-clock anchoring is the consumer's
37 /// job; it knows when it answered).
38 #[serde(skip)]
39 started: Instant,
40}
41
42impl Trace {
43 /// Start an empty trace for one run. `outcome` begins as
44 /// [`FlowOutcome::Defect`] and is replaced when the engine finishes; a
45 /// trace whose run was cancelled (dropped mid-select) keeps the
46 /// placeholder — consumers that saw the cancellation should trust their
47 /// own end signal, not this field.
48 pub fn new(flow_id: &str, flow_version: u64) -> Self {
49 Trace {
50 flow_id: flow_id.to_string(),
51 flow_version,
52 steps: Vec::new(),
53 outcome: FlowOutcome::Defect, // replaced before return
54 error: None,
55 started: Instant::now(),
56 }
57 }
58
59 pub(crate) fn push(&mut self, node: &NodeId, kind: &'static str, detail: StepDetail) {
60 let at_ms = self.started.elapsed().as_millis() as u64;
61 self.steps.push(TraceStep {
62 node: node.clone(),
63 kind,
64 at_ms,
65 detail,
66 });
67 }
68
69 /// Whether the run completed without an effect failure or engine defect —
70 /// the signal the daemon uses to decide whether to log a warning
71 /// alongside persisting the trace.
72 pub fn is_clean(&self) -> bool {
73 !matches!(self.outcome, FlowOutcome::Aborted | FlowOutcome::Defect)
74 }
75}
76
77/// One executed node and what happened there.
78#[derive(Debug, Clone, PartialEq, Serialize)]
79pub struct TraceStep {
80 pub node: NodeId,
81 pub kind: &'static str,
82 /// Milliseconds since the run started (≈ since the call was answered) —
83 /// places the step on the call's timeline, and later on the recording's
84 /// waveform.
85 pub at_ms: u64,
86 pub detail: StepDetail,
87}
88
89/// The per-node outcome. Tagged so it serializes to a self-describing object
90/// the renderer can switch on.
91#[derive(Debug, Clone, PartialEq, Serialize)]
92#[serde(tag = "detail", rename_all = "snake_case")]
93pub enum StepDetail {
94 /// A greeting/`say_hours` spoke and continued.
95 Spoke,
96 /// An `hours` branch resolved.
97 Hours { open: bool },
98 /// A menu caller pressed a mapped option.
99 MenuChoice { digit: String },
100 /// A menu exhausted its retries with no input at all.
101 MenuNoInput,
102 /// A menu exhausted its retries hearing only unmapped keys.
103 MenuInvalid,
104 /// A `ring` finished — `answered` means the human took the call.
105 Ring { answered: bool },
106 /// A voicemail was recorded (seconds captured).
107 MessageRecorded { secs: u32 },
108 /// A blind transfer was placed.
109 Transferred { target: String },
110 /// The flow spoke an optional goodbye and hung up.
111 HungUp,
112}
113
114/// How a run ended — the call disposition ("Answered by your receptionist",
115/// "Left a message", …) and the debugging outcome for abnormal ends.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "snake_case")]
118pub enum FlowOutcome {
119 /// A `ring` node was answered by a human; the engine stepped out.
120 Answered,
121 /// A `message` node recorded a voicemail.
122 MessageLeft,
123 /// A `transfer` node handed the call to an external number.
124 Transferred,
125 /// A `hangup` node ended the call.
126 HungUp,
127 /// An effect failed mid-run (the call likely dropped). See
128 /// [`Trace::error`].
129 Aborted,
130 /// The flow reached an impossible state — an unknown node, a missing exit,
131 /// or the step cap. Validation is meant to prevent all of these, so this
132 /// signals a defect worth alerting on.
133 Defect,
134}