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