Skip to main content

salvor_replay/
render.rs

1//! Rendering one event as the two short strings an operator reads: the stable
2//! kind label ([`event_kind`]) and a one-line detail ([`event_detail`]).
3//!
4//! Both are pure functions from an [`Event`] to text: no clock, no IO, no
5//! dependency on the layer that produced the event. That is what lets a single
6//! implementation serve every surface. The runtime emits these two strings live
7//! as each event becomes durable, the CLI prints them again when inspecting a
8//! log afterward, and a browser inspector renders them from the same compiled
9//! fold. A step therefore reads identically wherever you meet it.
10//!
11//! # What the detail withholds
12//!
13//! The detail deliberately does **not** carry full payloads. Inputs, outputs,
14//! resume values, and error messages are truncated, and hashes are shortened,
15//! so a model's raw output or a tool's raw arguments never reach a progress
16//! stream in full. The untruncated form lives only in the event log.
17
18use serde_json::Value;
19use time::OffsetDateTime;
20
21use crate::event::{BudgetKind, Event, Performer};
22#[cfg(test)]
23use crate::id::{RunId, SequenceNumber};
24
25/// The stable `kind` label for one event, matching the enum variant name so
26/// it reads the same as the wire form's `kind` tag.
27#[must_use]
28pub fn event_kind(event: &Event) -> &'static str {
29    match event {
30        Event::RunStarted { .. } => "RunStarted",
31        Event::ModelCallRequested { .. } => "ModelCallRequested",
32        Event::ModelCallCompleted { .. } => "ModelCallCompleted",
33        Event::ToolCallRequested { .. } => "ToolCallRequested",
34        Event::ToolCallCompleted { .. } => "ToolCallCompleted",
35        Event::NowObserved { .. } => "NowObserved",
36        Event::RandomObserved { .. } => "RandomObserved",
37        Event::Suspended { .. } => "Suspended",
38        Event::Resumed { .. } => "Resumed",
39        Event::BudgetExceeded { .. } => "BudgetExceeded",
40        Event::RunCompleted { .. } => "RunCompleted",
41        Event::RunFailed { .. } => "RunFailed",
42        Event::RunAbandoned { .. } => "RunAbandoned",
43        Event::GraphRunStarted { .. } => "GraphRunStarted",
44        Event::NodeEntered { .. } => "NodeEntered",
45        Event::NodeExited { .. } => "NodeExited",
46        Event::NodeSkipped { .. } => "NodeSkipped",
47        Event::BranchTaken { .. } => "BranchTaken",
48        Event::MapFannedOut { .. } => "MapFannedOut",
49        Event::MapIterationStarted { .. } => "MapIterationStarted",
50        Event::MapIterationJoined { .. } => "MapIterationJoined",
51        Event::FoldIterationStarted { .. } => "FoldIterationStarted",
52        Event::FoldIterationJoined { .. } => "FoldIterationJoined",
53        Event::FoldConverged { .. } => "FoldConverged",
54    }
55}
56
57/// The informative payload of one event, rendered as a single line. Picks the
58/// fields that matter per kind: a tool call shows its name, effect, and (when
59/// a client performed it) that fact, a model completion its token usage, a
60/// suspension its reason. Hashes are shortened and every payload is
61/// truncated, so no full input, output, or error text reaches the progress
62/// stream; `salvor history --json` is the escape hatch for the untruncated
63/// envelope.
64#[must_use]
65pub fn event_detail(event: &Event) -> String {
66    match event {
67        Event::RunStarted {
68            agent_def_hash,
69            input,
70            ..
71        } => format!(
72            "agent {} input {}",
73            short_hash(agent_def_hash),
74            truncate_json(input)
75        ),
76        Event::ModelCallRequested { request_hash, .. } => {
77            format!("request {}", short_hash(request_hash))
78        }
79        Event::ModelCallCompleted { usage, .. } => format!(
80            "usage in {} out {}",
81            usage.input_tokens, usage.output_tokens
82        ),
83        Event::ToolCallRequested {
84            tool,
85            input,
86            effect,
87            idempotency_key,
88            performed_by,
89            ..
90        } => {
91            let key = idempotency_key
92                .as_deref()
93                .map_or_else(String::new, |k| format!(" key {k}"));
94            // Absent (the field's default, and every entry recorded before it
95            // existed) means salvor performed the call itself: the
96            // overwhelmingly common case, so it renders nothing. Only a
97            // recorded `Performer::Client` gets a marker, in the same
98            // bracketed register as the effect class beside it.
99            let performer = match performed_by {
100                Some(Performer::Client) => " [Client]",
101                None | Some(Performer::Server) => "",
102            };
103            format!(
104                "{tool} [{effect:?}]{performer}{key} input {}",
105                truncate_json(input)
106            )
107        }
108        Event::ToolCallCompleted {
109            output,
110            deduplicated_from,
111            ..
112        } => {
113            // Absent (the field's default, and every completion recorded before
114            // it existed) means this call ran and this output is what it
115            // produced. A recorded origin means the opposite, which a reader
116            // must not have to infer from silence, so it is said out loud.
117            let copied = deduplicated_from.map_or_else(String::new, |origin| {
118                format!(
119                    " (deduplicated: copied from run {} seq {})",
120                    origin.run_id.as_uuid(),
121                    origin.seq
122                )
123            });
124            if let Some(reason) = suspension_reason(output) {
125                format!("suspends: {reason}{copied}")
126            } else if let Some(failure) = recorded_failure(output) {
127                format!(
128                    "error ({}, {} attempt(s)): {}{copied}",
129                    failure.kind,
130                    failure.attempts,
131                    truncate_str(failure.message)
132                )
133            } else {
134                format!("output {}{copied}", truncate_json(output))
135            }
136        }
137        Event::NowObserved { now } => format_ts(*now),
138        Event::RandomObserved { value } => format!("value {value}"),
139        Event::Suspended { reason, .. } => format!("reason: {reason}"),
140        Event::Resumed { input } => format!("input {}", truncate_json(input)),
141        Event::BudgetExceeded { budget, observed } => {
142            format!(
143                "{} limit {}, observed {}",
144                budget_label(budget.kind),
145                fmt_num(budget.limit),
146                fmt_num(*observed)
147            )
148        }
149        Event::RunCompleted { output } => format!("output {}", truncate_json(output)),
150        Event::RunFailed { error } => format!("error: {}", truncate_str(error)),
151        Event::RunAbandoned {
152            reason,
153            unresolved_write,
154        } => {
155            let why = reason
156                .as_deref()
157                .map_or_else(|| "no reason given".to_owned(), truncate_str);
158            match unresolved_write {
159                Some(write) => format!(
160                    "abandoned: {why} (unresolved write at seq {}, tool {})",
161                    write.seq.get(),
162                    write.tool
163                ),
164                None => format!("abandoned: {why}"),
165            }
166        }
167        Event::GraphRunStarted {
168            graph_hash, input, ..
169        } => format!(
170            "graph {} input {}",
171            short_hash(graph_hash),
172            truncate_json(input)
173        ),
174        Event::NodeEntered { node } => format!("enter {node}"),
175        Event::NodeExited { node } => format!("exit {node}"),
176        Event::NodeSkipped { node, reason } => format!("skip {node}: {}", truncate_str(reason)),
177        Event::BranchTaken { node, case } => format!("branch {node} -> {case}"),
178        Event::MapFannedOut { node, items } => {
179            format!("map {node} fan-out {}", truncate_json(items))
180        }
181        Event::MapIterationStarted {
182            node,
183            index,
184            child_run,
185        } => format!("map {node}[{index}] child {}", short_hash(child_run)),
186        Event::MapIterationJoined { node, index } => format!("map {node}[{index}] joined"),
187        Event::FoldIterationStarted { node, index } => format!("fold {node}[{index}] started"),
188        Event::FoldIterationJoined { node, index } => format!("fold {node}[{index}] joined"),
189        Event::FoldConverged {
190            node,
191            winner_index,
192            reason,
193        } => format!(
194            "fold {node} converged on [{winner_index}]: {}",
195            truncate_str(reason)
196        ),
197    }
198}
199
200/// The reserved key marking a completion output as a recorded suspension.
201///
202/// The two sentinel shapes below are a recorded wire contract. `salvor-runtime`
203/// writes them into a tool call's completion output and decodes them back into
204/// its own `Suspension` and `ToolFailure` types; this module reads the same
205/// recorded fields to render the line. The key names, the field names, and the
206/// three legal `kind` strings must therefore agree between the two.
207///
208/// The reading is spelled out here instead of calling the runtime's decoders
209/// because those decoders return types owned by the tools layer, which is
210/// executor-bound. A renderer needs the recorded fields, not those types, and
211/// must stay free of that dependency to remain buildable for wasm32.
212const SUSPEND_SENTINEL_KEY: &str = "__salvor_suspend";
213
214/// The reserved key marking a completion output as a recorded tool failure.
215/// See [`SUSPEND_SENTINEL_KEY`] for the shared-contract note.
216const ERROR_SENTINEL_KEY: &str = "__salvor_error";
217
218/// The three legal values of a recorded failure's `kind` field, mirroring the
219/// runtime's `ToolFailureKind` wire strings. A completion carrying any other
220/// value is not a failure sentinel and renders as an ordinary output.
221const FAILURE_KINDS: [&str; 3] = ["invalid_input", "handler", "output_serialization"];
222
223/// The recorded failure fields a detail line shows, borrowed out of the
224/// sentinel body.
225struct RecordedFailure<'v> {
226    /// Which dispatch layer failed, as its recorded wire string.
227    kind: &'v str,
228    /// The full recorded error chain, truncated only at render time.
229    message: &'v str,
230    /// How many times the call executed, counting retries.
231    attempts: u32,
232}
233
234/// The reason of a completion output that is the suspension sentinel; `None`
235/// for every other value.
236fn suspension_reason(output: &Value) -> Option<&str> {
237    let body = sentinel_body(output, SUSPEND_SENTINEL_KEY)?;
238    let reason = body.get("reason")?.as_str()?;
239    // A recorded suspension always carries the schema its resume input must
240    // satisfy; a body missing it is not one.
241    body.get("input_schema")?;
242    Some(reason)
243}
244
245/// The recorded fields of a completion output that is the failure sentinel;
246/// `None` for every other value.
247fn recorded_failure(output: &Value) -> Option<RecordedFailure<'_>> {
248    let body = sentinel_body(output, ERROR_SENTINEL_KEY)?;
249    let kind = body.get("kind")?.as_str()?;
250    if !FAILURE_KINDS.contains(&kind) {
251        return None;
252    }
253    Some(RecordedFailure {
254        kind,
255        message: body.get("message")?.as_str()?,
256        attempts: u32::try_from(body.get("attempts")?.as_u64()?).ok()?,
257    })
258}
259
260/// The sentinel's body when `output` is an object with exactly one key equal
261/// to `key`; `None` for every other value.
262fn sentinel_body<'v>(output: &'v Value, key: &str) -> Option<&'v Value> {
263    let map = output.as_object()?;
264    if map.len() != 1 {
265        return None;
266    }
267    map.get(key)
268}
269
270/// Shortens a `sha256:...` hash to its prefix and the first seven hex digits,
271/// so a line names a request without a 64-character wall of hex.
272fn short_hash(hash: &str) -> String {
273    match hash.split_once(':') {
274        Some((scheme, hex)) => {
275            let head: String = hex.chars().take(7).collect();
276            if hex.len() > 7 {
277                format!("{scheme}:{head}\u{2026}")
278            } else {
279                format!("{scheme}:{hex}")
280            }
281        }
282        None => hash.chars().take(12).collect(),
283    }
284}
285
286/// A human word for a budget dimension.
287fn budget_label(kind: BudgetKind) -> &'static str {
288    match kind {
289        BudgetKind::Steps => "steps",
290        BudgetKind::Tokens => "tokens",
291        BudgetKind::CostUsd => "cost_usd",
292        BudgetKind::WallTime => "wall_time",
293    }
294}
295
296/// Formats an `f64` budget figure without a needless `.0` when it is integral.
297/// Steps and tokens are whole numbers even though every budget dimension rides
298/// the wire as a float; the integral cutoff stays inside the range where an
299/// `f64` holds integers exactly (see the `Budget` docs).
300fn fmt_num(value: f64) -> String {
301    if value.fract() == 0.0 && value.abs() < 1e15 {
302        format!("{}", value as i64)
303    } else {
304        format!("{value}")
305    }
306}
307
308/// Formats a timestamp as `YYYY-MM-DD HH:MM:SSZ` from its components, avoiding
309/// a dependency on the `time` crate's optional `formatting` feature.
310fn format_ts(ts: OffsetDateTime) -> String {
311    let utc = ts.to_offset(time::UtcOffset::UTC);
312    format!(
313        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z",
314        utc.year(),
315        u8::from(utc.month()),
316        utc.day(),
317        utc.hour(),
318        utc.minute(),
319        utc.second(),
320    )
321}
322
323/// Compact one-line JSON, truncated so a payload never blows out a line and
324/// never streams in full.
325fn truncate_json(value: &serde_json::Value) -> String {
326    truncate_str(&value.to_string())
327}
328
329/// Truncates a string to a scannable length with an ellipsis, so no full
330/// payload or error message reaches the progress stream.
331fn truncate_str(text: &str) -> String {
332    const CAP: usize = 80;
333    if text.chars().count() > CAP {
334        let head: String = text.chars().take(CAP).collect();
335        format!("{head}\u{2026}")
336    } else {
337        text.to_owned()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use serde_json::json;
345    use uuid::Uuid;
346
347    /// A completion that copied its output says so, and names what it copied.
348    /// A completion that executed says nothing extra, so every line ever
349    /// rendered before this field existed reads exactly as it did.
350    #[test]
351    fn a_deduplicated_completion_says_what_it_copied() {
352        let origin = crate::event::DedupOrigin {
353            run_id: RunId::from_uuid(
354                Uuid::parse_str("00000000-0000-4000-8000-0000000000aa").expect("uuid"),
355            ),
356            seq: SequenceNumber::new(4),
357        };
358        let executed = event_detail(&Event::ToolCallCompleted {
359            seq: SequenceNumber::new(1),
360            output: json!({"charge_id": "po_1"}),
361            deduplicated_from: None,
362        });
363        assert_eq!(executed, r#"output {"charge_id":"po_1"}"#);
364
365        let copied = event_detail(&Event::ToolCallCompleted {
366            seq: SequenceNumber::new(1),
367            output: json!({"charge_id": "po_1"}),
368            deduplicated_from: Some(origin),
369        });
370        assert_eq!(
371            copied,
372            r#"output {"charge_id":"po_1"} (deduplicated: copied from run 00000000-0000-4000-8000-0000000000aa seq 4)"#
373        );
374    }
375
376    /// A long input is truncated, so a raw payload never reaches the stream in
377    /// full: the detail line stays capped even for a large value.
378    #[test]
379    fn detail_truncates_long_payloads() {
380        let big = "x".repeat(500);
381        let detail = event_detail(&Event::RunStarted {
382            agent_def_hash: "sha256:abcdef0123456789".into(),
383            input: json!({ "prompt": big }),
384            labels: None,
385        });
386        assert!(detail.contains('\u{2026}'), "detail should be truncated");
387        assert!(
388            detail.chars().count() < 200,
389            "truncated detail stays short: {} chars",
390            detail.chars().count()
391        );
392        // The short hash appears; the full 64-hex form does not.
393        assert!(detail.contains("sha256:abcdef0"));
394    }
395
396    /// Every kind maps to its variant name, matching the wire tag.
397    #[test]
398    fn kind_matches_variant_name() {
399        assert_eq!(
400            event_kind(&Event::RunCompleted { output: json!(1) }),
401            "RunCompleted"
402        );
403        assert_eq!(
404            event_kind(&Event::RandomObserved { value: 7 }),
405            "RandomObserved"
406        );
407    }
408
409    /// The compatibility test: a `ToolCallRequested` with `performed_by: None`
410    /// (the default, and every entry recorded before the field existed)
411    /// renders EXACTLY as it did before this field's marker was added. The
412    /// string below is the pinned pre-change output; the field
413    /// deliberately reads no `performed_by` at all so a change to this test
414    /// would only ever mean the compatibility case regressed.
415    #[test]
416    fn detail_omits_performer_marker_when_absent() {
417        let event = Event::ToolCallRequested {
418            seq: crate::id::SequenceNumber::new(3),
419            tool: "refund_card".into(),
420            input: json!({"amount_cents": 15900}),
421            effect: crate::effect::Effect::Write,
422            idempotency_key: Some("sha256:d2bb005d".into()),
423            performed_by: None,
424        };
425        assert_eq!(
426            event_detail(&event),
427            r#"refund_card [Write] key sha256:d2bb005d input {"amount_cents":15900}"#
428        );
429    }
430
431    /// A `ToolCallRequested` performed by the server, explicitly recorded as
432    /// such rather than left absent, still renders no marker: the field's
433    /// meaning is "who performed this", and a server-performed call is not
434    /// noteworthy however it got recorded.
435    #[test]
436    fn detail_omits_performer_marker_for_explicit_server() {
437        let event = Event::ToolCallRequested {
438            seq: crate::id::SequenceNumber::new(3),
439            tool: "refund_card".into(),
440            input: json!({"amount_cents": 15900}),
441            effect: crate::effect::Effect::Write,
442            idempotency_key: Some("sha256:d2bb005d".into()),
443            performed_by: Some(Performer::Server),
444        };
445        assert_eq!(
446            event_detail(&event),
447            r#"refund_card [Write] key sha256:d2bb005d input {"amount_cents":15900}"#
448        );
449    }
450
451    /// A client-performed call gets the `[Client]` marker, placed right after
452    /// the effect class it sits beside.
453    #[test]
454    fn detail_marks_a_client_performed_call() {
455        let event = Event::ToolCallRequested {
456            seq: crate::id::SequenceNumber::new(3),
457            tool: "refund_card".into(),
458            input: json!({"amount_cents": 15900}),
459            effect: crate::effect::Effect::Write,
460            idempotency_key: Some("sha256:d2bb005d".into()),
461            performed_by: Some(Performer::Client),
462        };
463        assert_eq!(
464            event_detail(&event),
465            r#"refund_card [Write] [Client] key sha256:d2bb005d input {"amount_cents":15900}"#
466        );
467    }
468}