Skip to main content

harn_cli/commands/run/
reporting.rs

1//! Auxiliary run output, profiling, and provenance receipts.
2//!
3//! The execution path owns when these artifacts are emitted; this module owns
4//! their wire shapes and sinks so the runner itself stays focused on lifecycle.
5
6use std::fs;
7#[cfg(unix)]
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use serde::Serialize;
14
15use crate::commands::time::{self, PhaseRecord, RunTiming};
16use harn_vm::clock::{now_wall_ms, RealClock};
17use harn_vm::event_log::EventLog;
18
19use super::{RunAttestationOptions, RunProfileOptions};
20
21/// JSON event-stream configuration for `--json` runs.
22#[derive(Clone, Default)]
23pub struct RunJsonOptions {
24    /// Suppress `stdout` / `stderr` events. Transcript, tool, hook,
25    /// persona, and the terminal result/error events still flow.
26    pub quiet: bool,
27}
28
29/// Post-run summary configuration for `harn run --emit-summary-json`.
30#[derive(Clone, Debug)]
31pub struct RunSummaryOptions {
32    pub sink: RunJsonSink,
33}
34
35#[derive(Clone, Debug)]
36pub struct RunPhaseOptions {
37    pub sink: RunJsonSink,
38}
39
40#[derive(Clone, Debug)]
41pub struct RunRusageOptions {
42    pub sink: RunJsonSink,
43}
44
45#[derive(Clone, Debug, Default)]
46pub struct RunAuxOptions {
47    pub summary: Option<RunSummaryOptions>,
48    pub phase: Option<RunPhaseOptions>,
49    pub rusage: Option<RunRusageOptions>,
50}
51
52#[derive(Clone, Debug, Default)]
53pub struct RunControlOptions {
54    pub timeout: Option<Duration>,
55    pub defer_project_handlers: bool,
56}
57
58#[derive(Clone, Debug)]
59pub struct RunJsonSink {
60    pub target: RunJsonSinkTarget,
61    pub fd_flag: &'static str,
62}
63
64#[derive(Clone, Debug)]
65pub enum RunJsonSinkTarget {
66    /// Append the summary to the captured stderr buffer so it remains
67    /// terminal after all diagnostics that `run_file_with_skill_dirs`
68    /// flushes on return.
69    Stderr,
70    File(PathBuf),
71    Fd(i32),
72}
73
74#[derive(Serialize)]
75struct RunSummary<'a> {
76    schema_version: u32,
77    event: &'static str,
78    wall_time_ms: u64,
79    exit_code: i32,
80    llm: RunSummaryLlm,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    profile: Option<&'a harn_vm::profile::RunProfile>,
83}
84
85#[derive(Serialize)]
86pub(super) struct RunSummaryLlm {
87    call_count: i64,
88    input_tokens: i64,
89    output_tokens: i64,
90    time_ms: i64,
91    cost_usd: f64,
92    /// Calls the catalog prices no rate for. They contribute nothing to
93    /// `cost_usd`, so a non-zero count here means `cost_usd` is a floor on the
94    /// real spend rather than the spend. Without it, a run served entirely by
95    /// an unpriced model reports `cost_usd: 0.0` and reads as free.
96    unpriced_calls: i64,
97}
98
99/// v2 added `llm.unpriced_calls`. Additive: a reader that ignores it sees
100/// exactly the v1 shape.
101pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 2;
102pub const RUN_PHASE_SCHEMA_VERSION: u32 = 2;
103pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
104
105#[derive(Serialize)]
106struct RunPhaseEvent {
107    schema_version: u32,
108    event: &'static str,
109    phases: Vec<PhaseRecord>,
110}
111
112#[derive(Serialize)]
113struct RunRusageEvent {
114    schema_version: u32,
115    event: &'static str,
116    cpu_ms: u64,
117}
118
119fn run_summary_options_from_args(args: &crate::cli::RunArgs) -> Option<RunSummaryOptions> {
120    args.emit_summary_json.then(|| RunSummaryOptions {
121        sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
122    })
123}
124
125pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
126    RunAuxOptions {
127        summary: run_summary_options_from_args(args),
128        phase: run_phase_options_from_args(args),
129        rusage: run_rusage_options_from_args(args),
130    }
131}
132
133pub(crate) fn run_control_options_from_args(args: &crate::cli::RunArgs) -> RunControlOptions {
134    RunControlOptions {
135        timeout: args.timeout,
136        defer_project_handlers: args.defer_project_handlers,
137    }
138}
139
140fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
141    args.emit_phase_json.then(|| RunPhaseOptions {
142        sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
143    })
144}
145
146fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
147    args.emit_rusage_json.then(|| RunRusageOptions {
148        sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
149    })
150}
151
152fn build_run_json_sink(
153    file: Option<PathBuf>,
154    fd: Option<i32>,
155    fd_flag: &'static str,
156) -> RunJsonSink {
157    RunJsonSink {
158        target: if let Some(path) = file {
159            RunJsonSinkTarget::File(path)
160        } else if let Some(fd) = fd {
161            RunJsonSinkTarget::Fd(fd)
162        } else {
163            RunJsonSinkTarget::Stderr
164        },
165        fd_flag,
166    }
167}
168
169pub(super) fn render_and_persist_profile_rollup(
170    options: &RunProfileOptions,
171    profile: &harn_vm::profile::RunProfile,
172    stderr: &mut String,
173) -> Result<(), String> {
174    if options.text {
175        stderr.push_str(&harn_vm::profile::render(profile));
176    }
177    if let Some(path) = options.json_path.as_ref() {
178        if let Some(parent) = path.parent() {
179            if !parent.as_os_str().is_empty() {
180                fs::create_dir_all(parent)
181                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
182            }
183        }
184        let json = serde_json::to_string_pretty(profile)
185            .map_err(|error| format!("serialize profile: {error}"))?;
186        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
187    }
188    Ok(())
189}
190
191fn build_run_summary<'a>(
192    started: Instant,
193    exit_code: i32,
194    profile: Option<&'a harn_vm::profile::RunProfile>,
195    llm: RunSummaryLlm,
196) -> RunSummary<'a> {
197    RunSummary {
198        schema_version: RUN_SUMMARY_SCHEMA_VERSION,
199        event: "run_summary",
200        wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
201        exit_code,
202        llm,
203        profile,
204    }
205}
206
207pub(super) fn run_summary_llm_snapshot() -> RunSummaryLlm {
208    let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
209    let cost_usd = harn_vm::llm::peek_total_cost();
210    // Counted off the trace rather than the accumulator: the accumulator folds
211    // an unpriced call in as 0.0 and cannot tell it apart afterwards.
212    let unpriced_calls = harn_vm::llm::peek_trace()
213        .iter()
214        .filter(|entry| entry.cost_usd.is_none())
215        .count() as i64;
216    RunSummaryLlm {
217        call_count,
218        input_tokens,
219        output_tokens,
220        time_ms,
221        cost_usd: if cost_usd.is_finite() { cost_usd } else { 0.0 },
222        unpriced_calls,
223    }
224}
225
226pub(super) struct RunAuxEmission {
227    pub stderr: String,
228    pub exit_code: i32,
229    pub error: Option<String>,
230}
231
232#[allow(clippy::too_many_arguments)]
233pub(super) fn emit_run_aux_for_exit(
234    summary: Option<&RunSummaryOptions>,
235    phase: Option<&RunPhaseOptions>,
236    rusage: Option<&RunRusageOptions>,
237    started: Instant,
238    exit_code: i32,
239    profile: Option<&harn_vm::profile::RunProfile>,
240    llm: Option<RunSummaryLlm>,
241    timing: Option<&RunTiming>,
242    main_events: u64,
243    cpu_ms_total: Option<u64>,
244    json_mode: bool,
245    stderr: &mut String,
246) -> RunAuxEmission {
247    let mut aux_stderr = String::new();
248    let mut final_exit_code = exit_code;
249    let mut aux_error = None;
250    let aux_target = if json_mode { &mut aux_stderr } else { stderr };
251    let default_timing = RunTiming::default();
252    let timing = timing.unwrap_or(&default_timing);
253
254    if let Some(options) = summary {
255        let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
256        let summary = build_run_summary(started, exit_code, profile, llm);
257        if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
258            record_aux_error(
259                &mut final_exit_code,
260                &mut aux_error,
261                aux_target,
262                "run summary",
263                error,
264            );
265        }
266    }
267    if let Some(options) = phase {
268        let phase_event = RunPhaseEvent {
269            schema_version: RUN_PHASE_SCHEMA_VERSION,
270            event: "run_phase",
271            phases: time::build_phase_records(timing, main_events),
272        };
273        if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
274        {
275            record_aux_error(
276                &mut final_exit_code,
277                &mut aux_error,
278                aux_target,
279                "run phase",
280                error,
281            );
282        }
283    }
284    if let Some(options) = rusage {
285        let rusage_event = RunRusageEvent {
286            schema_version: RUN_RUSAGE_SCHEMA_VERSION,
287            event: "run_rusage",
288            cpu_ms: cpu_ms_total.unwrap_or(0),
289        };
290        if let Err(error) =
291            emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
292        {
293            record_aux_error(
294                &mut final_exit_code,
295                &mut aux_error,
296                aux_target,
297                "run rusage",
298                error,
299            );
300        }
301    }
302
303    RunAuxEmission {
304        stderr: aux_stderr,
305        exit_code: final_exit_code,
306        error: aux_error,
307    }
308}
309
310fn record_aux_error(
311    final_exit_code: &mut i32,
312    aux_error: &mut Option<String>,
313    stderr: &mut String,
314    label: &str,
315    error: String,
316) {
317    stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
318    if *final_exit_code == 0 {
319        *final_exit_code = 1;
320    }
321    if aux_error.is_none() {
322        *aux_error = Some(error);
323    }
324}
325
326fn emit_raw_json_line(
327    sink: &RunJsonSink,
328    value: &impl Serialize,
329    label: &str,
330    stderr: &mut String,
331) -> Result<(), String> {
332    let line =
333        serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
334    match &sink.target {
335        RunJsonSinkTarget::Stderr => {
336            stderr.push_str(&line);
337            Ok(())
338        }
339        RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
340        RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
341    }
342}
343
344fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
345    if let Some(parent) = path.parent() {
346        if !parent.as_os_str().is_empty() {
347            fs::create_dir_all(parent)
348                .map_err(|error| format!("create {}: {error}", parent.display()))?;
349        }
350    }
351    fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
352}
353
354#[cfg(unix)]
355fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
356    use std::fs::File;
357    use std::os::unix::io::FromRawFd;
358
359    if fd < 0 {
360        return Err(format!("invalid {flag} {fd}: must be non-negative"));
361    }
362    let duped = unsafe { libc::dup(fd) };
363    if duped < 0 {
364        return Err(format!(
365            "duplicate {flag} {fd}: {}",
366            io::Error::last_os_error()
367        ));
368    }
369    let mut file = unsafe { File::from_raw_fd(duped) };
370    file.write_all(line.as_bytes())
371        .and_then(|_| file.flush())
372        .map_err(|error| format!("write {flag} {fd}: {error}"))
373}
374
375#[cfg(not(unix))]
376fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
377    Err(format!("{flag} is only supported on Unix platforms"))
378}
379
380pub(super) async fn append_run_provenance_event(
381    log: &Arc<harn_vm::event_log::AnyEventLog>,
382    kind: &str,
383    payload: serde_json::Value,
384) {
385    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
386        return;
387    };
388    let _ = log
389        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
390        .await;
391}
392
393pub(super) async fn emit_run_attestation(
394    log: &Arc<harn_vm::event_log::AnyEventLog>,
395    path: &str,
396    store_base: &Path,
397    started_at_ms: i64,
398    exit_code: i32,
399    options: &RunAttestationOptions,
400    stderr: &mut String,
401) -> Result<(), String> {
402    let finished_at_ms = now_ms();
403    let status = if exit_code == 0 { "success" } else { "failure" };
404    append_run_provenance_event(
405        log,
406        "finished",
407        serde_json::json!({
408            "pipeline": path,
409            "status": status,
410            "exit_code": exit_code,
411        }),
412    )
413    .await;
414    log.flush()
415        .await
416        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
417    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
418        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
419    let (signing_key, key_id) =
420        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
421            .await
422            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
423    let receipt = harn_vm::build_signed_receipt(
424        log,
425        harn_vm::ReceiptBuildOptions {
426            pipeline: path.to_string(),
427            status: status.to_string(),
428            started_at_ms,
429            finished_at_ms,
430            exit_code,
431            producer_name: "harn-cli".to_string(),
432            producer_version: env!("CARGO_PKG_VERSION").to_string(),
433        },
434        &signing_key,
435        key_id,
436    )
437    .await
438    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
439    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
440    if let Some(parent) = receipt_path.parent() {
441        fs::create_dir_all(parent)
442            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
443    }
444    let encoded = serde_json::to_vec_pretty(&receipt)
445        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
446    fs::write(&receipt_path, encoded)
447        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
448    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
449    Ok(())
450}
451
452fn receipt_output_path(
453    store_base: &Path,
454    options: &RunAttestationOptions,
455    receipt_id: &str,
456) -> PathBuf {
457    if let Some(path) = options.receipt_out.as_ref() {
458        return path.clone();
459    }
460    harn_vm::runtime_paths::state_root(store_base)
461        .join("receipts")
462        .join(format!("{receipt_id}.json"))
463}
464
465pub(super) fn now_ms() -> i64 {
466    now_wall_ms(&RealClock::new())
467}
468
469/// Map a script's top-level return value to a process exit code.
470///
471/// - `int n`             → exit n (clamped to 0..=255)
472/// - `Result::Ok(_)`     → exit 0
473/// - `Result::Err(_)`    → exit 1
474/// - anything else       → exit 0
475pub(super) fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
476    use harn_vm::VmValue;
477    match value {
478        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
479        VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
480        _ => 0,
481    }
482}
483
484pub(crate) fn render_trace_summary() -> String {
485    render_trace_entries(&harn_vm::llm::take_trace())
486}
487
488/// Rendering is split from the thread-local read so the money arithmetic can
489/// be tested against constructed entries rather than a live provider call.
490fn render_trace_entries(entries: &[harn_vm::llm::LlmTraceEntry]) -> String {
491    use std::fmt::Write;
492
493    if entries.is_empty() {
494        return String::new();
495    }
496    let mut out = String::new();
497    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
498    let mut total_input = 0i64;
499    let mut total_output = 0i64;
500    let mut total_ms = 0u64;
501    // Priced and unpriced calls are summed separately. This used to apply one
502    // hardcoded Sonnet-4 rate ($3/$15 per MTok) to every call regardless of
503    // which model served it, so a trace of any other model reported a
504    // confidently wrong dollar figure.
505    //
506    // The price is now read off the entry rather than recomputed here. The
507    // runtime already priced each call through the one owner of per-call cost,
508    // which sees prompt-cache accounting and the accelerated-serving tier;
509    // re-pricing from tokens alone cannot, and would make this summary
510    // disagree with the run's own reported total.
511    let mut priced_cost = 0.0f64;
512    let mut unpriced_calls = 0usize;
513    for (index, entry) in entries.iter().enumerate() {
514        let cost = entry.cost_usd;
515        match cost {
516            Some(cost) => priced_cost += cost,
517            None => unpriced_calls += 1,
518        }
519        let _ = writeln!(
520            out,
521            "  #{}: {} | {} in + {} out tokens | {} ms | {}",
522            index + 1,
523            entry.model,
524            entry.input_tokens,
525            entry.output_tokens,
526            entry.duration_ms,
527            cost.map_or_else(|| "unpriced".to_string(), |cost| format!("${cost:.4}")),
528        );
529        total_input += entry.input_tokens;
530        total_output += entry.output_tokens;
531        total_ms += entry.duration_ms;
532    }
533    let total_tokens = total_input + total_output;
534    // "≥" rather than "~" when some calls could not be priced: the total is a
535    // floor on the real spend, not an estimate of it.
536    let cost_label = if unpriced_calls == 0 {
537        format!("${priced_cost:.4}")
538    } else {
539        format!("≥${priced_cost:.4} ({unpriced_calls} unpriced)")
540    };
541    let _ = writeln!(
542        out,
543        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, {}\x1b[0m",
544        entries.len(),
545        if entries.len() == 1 { "" } else { "s" },
546        total_tokens,
547        total_input,
548        total_output,
549        total_ms,
550        cost_label,
551    );
552    out
553}
554
555#[cfg(test)]
556mod trace_summary_pricing_tests {
557    use super::render_trace_entries;
558    use harn_vm::llm::LlmTraceEntry;
559
560    fn entry(model: &str, cost_usd: Option<f64>) -> LlmTraceEntry {
561        LlmTraceEntry {
562            model: model.to_string(),
563            provider: "anthropic".to_string(),
564            input_tokens: 1_000,
565            output_tokens: 100,
566            cost_usd,
567            duration_ms: 5,
568        }
569    }
570
571    /// The summary used to price every call at one hardcoded Sonnet-4 rate
572    /// ($3/$15 per MTok) regardless of which model served it. It now sums the
573    /// price the runtime already computed, so the total is whatever the run
574    /// actually booked.
575    #[test]
576    fn the_total_is_the_sum_of_the_prices_the_runtime_recorded() {
577        let rendered = render_trace_entries(&[
578            entry("claude-sonnet-4-20250514", Some(0.25)),
579            entry("claude-haiku-4-5-20251001", Some(0.0125)),
580        ]);
581        assert!(
582            rendered.contains("$0.2625"),
583            "the total must be the exact sum of the recorded prices: {rendered}"
584        );
585        assert!(
586            !rendered.contains("unpriced"),
587            "no call was unpriced, so nothing should be hedged: {rendered}"
588        );
589    }
590
591    /// An unpriced call must not be silently treated as free. The total
592    /// becomes a floor, and says how many calls it could not account for.
593    #[test]
594    fn an_unpriced_call_makes_the_total_a_floor_rather_than_a_figure() {
595        let rendered = render_trace_entries(&[
596            entry("claude-sonnet-4-20250514", Some(0.25)),
597            entry("some-model-the-catalog-does-not-price", None),
598        ]);
599        assert!(
600            rendered.contains("\u{2265}$0.2500"),
601            "a partially priced total must be marked as a floor: {rendered}"
602        );
603        assert!(
604            rendered.contains("(1 unpriced)"),
605            "the count of unaccounted calls must be stated: {rendered}"
606        );
607        assert!(
608            rendered.contains("unpriced"),
609            "the unpriced call's own row must say so: {rendered}"
610        );
611    }
612
613    /// Two models that priced differently must not collapse to one number.
614    /// That equality was the shape of the original bug.
615    #[test]
616    fn two_models_priced_differently_do_not_collapse_to_one_number() {
617        let rendered = render_trace_entries(&[
618            entry("claude-sonnet-4-20250514", Some(0.2500)),
619            entry("claude-haiku-4-5-20251001", Some(0.0125)),
620        ]);
621        assert!(
622            rendered.contains("$0.2500") && rendered.contains("$0.0125"),
623            "each call must show its own price: {rendered}"
624        );
625    }
626}