Skip to main content

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}