1use 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;
18use harn_vm::llm::usage::{summarize_usage_cost_certainty, UsageCostCertainty};
19
20use super::{RunAttestationOptions, RunProfileOptions};
21
22#[derive(Clone, Default)]
24pub struct RunJsonOptions {
25 pub quiet: bool,
28}
29
30#[derive(Clone, Debug)]
32pub struct RunSummaryOptions {
33 pub sink: RunJsonSink,
34}
35
36#[derive(Clone, Debug)]
37pub struct RunPhaseOptions {
38 pub sink: RunJsonSink,
39}
40
41#[derive(Clone, Debug)]
42pub struct RunRusageOptions {
43 pub sink: RunJsonSink,
44}
45
46#[derive(Clone, Debug, Default)]
47pub struct RunAuxOptions {
48 pub summary: Option<RunSummaryOptions>,
49 pub phase: Option<RunPhaseOptions>,
50 pub rusage: Option<RunRusageOptions>,
51}
52
53#[derive(Clone, Debug, Default)]
54pub struct RunControlOptions {
55 pub timeout: Option<Duration>,
56 pub eager_project_handlers: bool,
57}
58
59#[derive(Clone, Debug)]
60pub struct RunJsonSink {
61 pub target: RunJsonSinkTarget,
62 pub fd_flag: &'static str,
63}
64
65#[derive(Clone, Debug)]
66pub enum RunJsonSinkTarget {
67 Stderr,
71 File(PathBuf),
72 Fd(i32),
73}
74
75#[derive(Serialize)]
76struct RunSummary<'a> {
77 schema_version: u32,
78 event: &'static str,
79 wall_time_ms: u64,
80 exit_code: i32,
81 llm: RunSummaryLlm,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 profile: Option<&'a harn_vm::profile::RunProfile>,
84}
85
86#[derive(Serialize)]
87pub(super) struct RunSummaryLlm {
88 call_count: i64,
90 provider_call_count: i64,
92 input_tokens: i64,
93 output_tokens: i64,
94 time_ms: i64,
95 cost_usd: Option<f64>,
97 known_cost_usd: f64,
99 unpriced_calls: i64,
102 usage_unknown_calls: i64,
104}
105
106pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 4;
109pub const RUN_PHASE_SCHEMA_VERSION: u32 = 2;
110pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
111
112#[derive(Serialize)]
113struct RunPhaseEvent {
114 schema_version: u32,
115 event: &'static str,
116 phases: Vec<PhaseRecord>,
117}
118
119#[derive(Serialize)]
120struct RunRusageEvent {
121 schema_version: u32,
122 event: &'static str,
123 cpu_ms: u64,
124}
125
126fn run_summary_options_from_args(args: &crate::cli::RunArgs) -> Option<RunSummaryOptions> {
127 args.emit_summary_json.then(|| RunSummaryOptions {
128 sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
129 })
130}
131
132pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
133 RunAuxOptions {
134 summary: run_summary_options_from_args(args),
135 phase: run_phase_options_from_args(args),
136 rusage: run_rusage_options_from_args(args),
137 }
138}
139
140pub(crate) fn run_control_options_from_args(args: &crate::cli::RunArgs) -> RunControlOptions {
141 RunControlOptions {
142 timeout: args.timeout,
143 eager_project_handlers: args.eager_project_handlers,
144 }
145}
146
147fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
148 args.emit_phase_json.then(|| RunPhaseOptions {
149 sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
150 })
151}
152
153fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
154 args.emit_rusage_json.then(|| RunRusageOptions {
155 sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
156 })
157}
158
159fn build_run_json_sink(
160 file: Option<PathBuf>,
161 fd: Option<i32>,
162 fd_flag: &'static str,
163) -> RunJsonSink {
164 RunJsonSink {
165 target: if let Some(path) = file {
166 RunJsonSinkTarget::File(path)
167 } else if let Some(fd) = fd {
168 RunJsonSinkTarget::Fd(fd)
169 } else {
170 RunJsonSinkTarget::Stderr
171 },
172 fd_flag,
173 }
174}
175
176pub(super) fn render_and_persist_profile_rollup(
177 options: &RunProfileOptions,
178 profile: &harn_vm::profile::RunProfile,
179 stderr: &mut String,
180) -> Result<(), String> {
181 if options.text {
182 stderr.push_str(&harn_vm::profile::render(profile));
183 }
184 if let Some(path) = options.json_path.as_ref() {
185 if let Some(parent) = path.parent() {
186 if !parent.as_os_str().is_empty() {
187 fs::create_dir_all(parent)
188 .map_err(|error| format!("create {}: {error}", parent.display()))?;
189 }
190 }
191 let json = serde_json::to_string_pretty(profile)
192 .map_err(|error| format!("serialize profile: {error}"))?;
193 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
194 }
195 Ok(())
196}
197
198fn build_run_summary<'a>(
199 started: Instant,
200 exit_code: i32,
201 profile: Option<&'a harn_vm::profile::RunProfile>,
202 llm: RunSummaryLlm,
203) -> RunSummary<'a> {
204 RunSummary {
205 schema_version: RUN_SUMMARY_SCHEMA_VERSION,
206 event: "run_summary",
207 wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
208 exit_code,
209 llm,
210 profile,
211 }
212}
213
214pub(super) fn run_summary_llm_snapshot() -> RunSummaryLlm {
215 let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
216 let trace = harn_vm::llm::peek_trace();
217 let certainty = summarize_usage_cost_certainty(trace.iter().map(|entry| &entry.usage));
218 run_summary_llm_from_parts(input_tokens, output_tokens, time_ms, call_count, certainty)
219}
220
221fn run_summary_llm_from_parts(
222 input_tokens: i64,
223 output_tokens: i64,
224 time_ms: i64,
225 call_count: i64,
226 certainty: UsageCostCertainty,
227) -> RunSummaryLlm {
228 RunSummaryLlm {
229 call_count,
230 provider_call_count: certainty.provider_call_count,
231 input_tokens,
232 output_tokens,
233 time_ms,
234 cost_usd: (certainty.unpriced_calls == 0).then_some(certainty.known_cost_usd),
235 known_cost_usd: certainty.known_cost_usd,
236 unpriced_calls: certainty.unpriced_calls,
237 usage_unknown_calls: certainty.usage_unknown_calls,
238 }
239}
240
241pub(super) struct RunAuxEmission {
242 pub stderr: String,
243 pub exit_code: i32,
244 pub error: Option<String>,
245}
246
247#[allow(clippy::too_many_arguments)]
248pub(super) fn emit_run_aux_for_exit(
249 summary: Option<&RunSummaryOptions>,
250 phase: Option<&RunPhaseOptions>,
251 rusage: Option<&RunRusageOptions>,
252 started: Instant,
253 exit_code: i32,
254 profile: Option<&harn_vm::profile::RunProfile>,
255 llm: Option<RunSummaryLlm>,
256 timing: Option<&RunTiming>,
257 main_events: u64,
258 cpu_ms_total: Option<u64>,
259 json_mode: bool,
260 stderr: &mut String,
261) -> RunAuxEmission {
262 let mut aux_stderr = String::new();
263 let mut final_exit_code = exit_code;
264 let mut aux_error = None;
265 let aux_target = if json_mode { &mut aux_stderr } else { stderr };
266 let default_timing = RunTiming::default();
267 let timing = timing.unwrap_or(&default_timing);
268
269 if let Some(options) = summary {
270 let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
271 let summary = build_run_summary(started, exit_code, profile, llm);
272 if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
273 record_aux_error(
274 &mut final_exit_code,
275 &mut aux_error,
276 aux_target,
277 "run summary",
278 error,
279 );
280 }
281 }
282 if let Some(options) = phase {
283 let phase_event = RunPhaseEvent {
284 schema_version: RUN_PHASE_SCHEMA_VERSION,
285 event: "run_phase",
286 phases: time::build_phase_records(timing, main_events),
287 };
288 if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
289 {
290 record_aux_error(
291 &mut final_exit_code,
292 &mut aux_error,
293 aux_target,
294 "run phase",
295 error,
296 );
297 }
298 }
299 if let Some(options) = rusage {
300 let rusage_event = RunRusageEvent {
301 schema_version: RUN_RUSAGE_SCHEMA_VERSION,
302 event: "run_rusage",
303 cpu_ms: cpu_ms_total.unwrap_or(0),
304 };
305 if let Err(error) =
306 emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
307 {
308 record_aux_error(
309 &mut final_exit_code,
310 &mut aux_error,
311 aux_target,
312 "run rusage",
313 error,
314 );
315 }
316 }
317
318 RunAuxEmission {
319 stderr: aux_stderr,
320 exit_code: final_exit_code,
321 error: aux_error,
322 }
323}
324
325fn record_aux_error(
326 final_exit_code: &mut i32,
327 aux_error: &mut Option<String>,
328 stderr: &mut String,
329 label: &str,
330 error: String,
331) {
332 stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
333 if *final_exit_code == 0 {
334 *final_exit_code = 1;
335 }
336 if aux_error.is_none() {
337 *aux_error = Some(error);
338 }
339}
340
341fn emit_raw_json_line(
342 sink: &RunJsonSink,
343 value: &impl Serialize,
344 label: &str,
345 stderr: &mut String,
346) -> Result<(), String> {
347 let line =
348 serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
349 match &sink.target {
350 RunJsonSinkTarget::Stderr => {
351 stderr.push_str(&line);
352 Ok(())
353 }
354 RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
355 RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
356 }
357}
358
359fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
360 if let Some(parent) = path.parent() {
361 if !parent.as_os_str().is_empty() {
362 fs::create_dir_all(parent)
363 .map_err(|error| format!("create {}: {error}", parent.display()))?;
364 }
365 }
366 fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
367}
368
369#[cfg(unix)]
370fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
371 use std::fs::File;
372 use std::os::unix::io::FromRawFd;
373
374 if fd < 0 {
375 return Err(format!("invalid {flag} {fd}: must be non-negative"));
376 }
377 let duped = unsafe { libc::dup(fd) };
378 if duped < 0 {
379 return Err(format!(
380 "duplicate {flag} {fd}: {}",
381 io::Error::last_os_error()
382 ));
383 }
384 let mut file = unsafe { File::from_raw_fd(duped) };
385 file.write_all(line.as_bytes())
386 .and_then(|_| file.flush())
387 .map_err(|error| format!("write {flag} {fd}: {error}"))
388}
389
390#[cfg(not(unix))]
391fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
392 Err(format!("{flag} is only supported on Unix platforms"))
393}
394
395pub(super) async fn append_run_provenance_event(
396 log: &Arc<harn_vm::event_log::AnyEventLog>,
397 kind: &str,
398 payload: serde_json::Value,
399) {
400 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
401 return;
402 };
403 let _ = log
404 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
405 .await;
406}
407
408pub(super) async fn emit_run_attestation(
409 log: &Arc<harn_vm::event_log::AnyEventLog>,
410 path: &str,
411 store_base: &Path,
412 started_at_ms: i64,
413 exit_code: i32,
414 options: &RunAttestationOptions,
415 stderr: &mut String,
416) -> Result<(), String> {
417 let finished_at_ms = now_ms();
418 let status = if exit_code == 0 { "success" } else { "failure" };
419 append_run_provenance_event(
420 log,
421 "finished",
422 serde_json::json!({
423 "pipeline": path,
424 "status": status,
425 "exit_code": exit_code,
426 }),
427 )
428 .await;
429 log.flush()
430 .await
431 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
432 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
433 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
434 let (signing_key, key_id) =
435 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
436 .await
437 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
438 let receipt = harn_vm::build_signed_receipt(
439 log,
440 harn_vm::ReceiptBuildOptions {
441 pipeline: path.to_string(),
442 status: status.to_string(),
443 started_at_ms,
444 finished_at_ms,
445 exit_code,
446 producer_name: "harn-cli".to_string(),
447 producer_version: env!("CARGO_PKG_VERSION").to_string(),
448 },
449 &signing_key,
450 key_id,
451 )
452 .await
453 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
454 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
455 if let Some(parent) = receipt_path.parent() {
456 fs::create_dir_all(parent)
457 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
458 }
459 let encoded = serde_json::to_vec_pretty(&receipt)
460 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
461 fs::write(&receipt_path, encoded)
462 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
463 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
464 Ok(())
465}
466
467fn receipt_output_path(
468 store_base: &Path,
469 options: &RunAttestationOptions,
470 receipt_id: &str,
471) -> PathBuf {
472 if let Some(path) = options.receipt_out.as_ref() {
473 return path.clone();
474 }
475 harn_vm::runtime_paths::state_root(store_base)
476 .join("receipts")
477 .join(format!("{receipt_id}.json"))
478}
479
480pub(super) fn now_ms() -> i64 {
481 now_wall_ms(&RealClock::new())
482}
483
484pub(super) fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
491 use harn_vm::VmValue;
492 match value {
493 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
494 VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
495 _ => 0,
496 }
497}
498
499pub(crate) fn render_trace_summary() -> String {
500 render_trace_entries(&harn_vm::llm::take_trace())
501}
502
503fn render_trace_entries(entries: &[harn_vm::llm::LlmTraceEntry]) -> String {
506 use std::fmt::Write;
507
508 if entries.is_empty() {
509 return String::new();
510 }
511 let mut out = String::new();
512 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
513 let mut total_input = 0i64;
514 let mut total_output = 0i64;
515 let mut total_ms = 0u64;
516 for (index, entry) in entries.iter().enumerate() {
527 let certainty = summarize_usage_cost_certainty([&entry.usage]);
528 let _ = writeln!(
529 out,
530 " #{}: {} | {} in + {} out tokens | {} ms | {}",
531 index + 1,
532 entry.model,
533 entry.usage.input_tokens,
534 entry.usage.output_tokens,
535 entry.duration_ms,
536 render_cost_certainty(certainty),
537 );
538 total_input += entry.usage.input_tokens;
539 total_output += entry.usage.output_tokens;
540 total_ms += entry.duration_ms;
541 }
542 let total_tokens = total_input + total_output;
543 let certainty = summarize_usage_cost_certainty(entries.iter().map(|entry| &entry.usage));
544 let token_label = render_token_certainty(total_tokens, certainty.usage_unknown_calls);
545 let _ = writeln!(
546 out,
547 " \x1b[1m{} logical call{}, {} provider call{}, {} ({}in + {}out), {} ms, {}\x1b[0m",
548 entries.len(),
549 if entries.len() == 1 { "" } else { "s" },
550 certainty.provider_call_count,
551 if certainty.provider_call_count == 1 {
552 ""
553 } else {
554 "s"
555 },
556 token_label,
557 total_input,
558 total_output,
559 total_ms,
560 render_cost_certainty(certainty),
561 );
562 out
563}
564
565fn render_cost_certainty(certainty: UsageCostCertainty) -> String {
566 if certainty.unpriced_calls == 0 {
567 format!("${:.4}", certainty.known_cost_usd)
568 } else {
569 format!(
573 "≥${:.4} ({} unpriced)",
574 certainty.known_cost_usd, certainty.unpriced_calls
575 )
576 }
577}
578
579fn render_token_certainty(known_tokens: i64, usage_unknown_calls: i64) -> String {
580 if usage_unknown_calls == 0 {
581 format!("{known_tokens} tokens")
582 } else {
583 format!("≥{known_tokens} tokens ({usage_unknown_calls} usage unknown)")
584 }
585}
586
587#[cfg(test)]
588mod trace_summary_pricing_tests {
589 use super::{render_trace_entries, run_summary_llm_from_parts};
590 use harn_vm::llm::{
591 usage::{LlmUsage, UsageCostCertainty},
592 LlmTraceEntry,
593 };
594
595 fn entry(model: &str, cost_usd: Option<f64>) -> LlmTraceEntry {
596 LlmTraceEntry {
597 model: model.to_string(),
598 provider: "anthropic".to_string(),
599 usage: LlmUsage {
600 input_tokens: 1_000,
601 output_tokens: 100,
602 cost_usd,
603 cache_read_tokens: 0,
604 cache_write_tokens: 0,
605 cache_supported: true,
606 cache_hit_ratio: Some(0.0),
607 cache_savings_usd: 0.0,
608 cache_hit: false,
609 served_fast: false,
610 accounting_status: harn_vm::llm::usage::UsageAccountingStatus::Reported,
611 known_cost_usd: cost_usd.unwrap_or(0.0),
612 provider_call_count: 1,
613 unpriced_calls: i64::from(cost_usd.is_none()),
614 usage_unknown_calls: 0,
615 },
616 duration_ms: 5,
617 }
618 }
619
620 #[test]
625 fn the_total_is_the_sum_of_the_prices_the_runtime_recorded() {
626 let rendered = render_trace_entries(&[
627 entry("claude-sonnet-4-20250514", Some(0.25)),
628 entry("claude-haiku-4-5-20251001", Some(0.0125)),
629 ]);
630 assert!(
631 rendered.contains("$0.2625"),
632 "the total must be the exact sum of the recorded prices: {rendered}"
633 );
634 assert!(
635 !rendered.contains("unpriced"),
636 "no call was unpriced, so nothing should be hedged: {rendered}"
637 );
638 }
639
640 #[test]
643 fn an_unpriced_call_makes_the_total_a_floor_rather_than_a_figure() {
644 let rendered = render_trace_entries(&[
645 entry("claude-sonnet-4-20250514", Some(0.25)),
646 entry("some-model-the-catalog-does-not-price", None),
647 ]);
648 assert!(
649 rendered.contains("\u{2265}$0.2500"),
650 "a partially priced total must be marked as a floor: {rendered}"
651 );
652 assert!(
653 rendered.contains("(1 unpriced)"),
654 "the count of unaccounted calls must be stated: {rendered}"
655 );
656 assert!(
657 rendered.contains("unpriced"),
658 "the unpriced call's own row must say so: {rendered}"
659 );
660 }
661
662 #[test]
665 fn two_models_priced_differently_do_not_collapse_to_one_number() {
666 let rendered = render_trace_entries(&[
667 entry("claude-sonnet-4-20250514", Some(0.2500)),
668 entry("claude-haiku-4-5-20251001", Some(0.0125)),
669 ]);
670 assert!(
671 rendered.contains("$0.2500") && rendered.contains("$0.0125"),
672 "each call must show its own price: {rendered}"
673 );
674 }
675
676 #[test]
677 fn mixed_retry_keeps_known_spend_and_physical_uncertainty() {
678 let mut retried = entry("retrying-model", None);
679 retried.usage.input_tokens = 7;
680 retried.usage.output_tokens = 5;
681 retried.usage.known_cost_usd = 0.0123;
682 retried.usage.provider_call_count = 2;
683 retried.usage.unpriced_calls = 1;
684 retried.usage.usage_unknown_calls = 1;
685
686 let rendered = render_trace_entries(&[retried]);
687 assert!(
688 rendered.contains("1 logical call, 2 provider calls"),
689 "physical retries must not collapse to one logical trace row: {rendered}"
690 );
691 assert!(
692 rendered.contains("≥12 tokens (1 usage unknown)"),
693 "known tokens must be labeled as a lower bound: {rendered}"
694 );
695 assert!(
696 rendered.matches("≥$0.0123 (1 unpriced)").count() >= 2,
697 "both the row and total must retain the priced sibling's lower bound: {rendered}"
698 );
699 }
700
701 #[test]
702 fn json_summary_projects_the_canonical_physical_certainty() {
703 let summary = run_summary_llm_from_parts(
704 7,
705 5,
706 9,
707 1,
708 UsageCostCertainty {
709 known_cost_usd: 0.0123,
710 provider_call_count: 2,
711 unpriced_calls: 1,
712 usage_unknown_calls: 1,
713 },
714 );
715 assert_eq!(summary.call_count, 1);
716 assert_eq!(summary.provider_call_count, 2);
717 assert_eq!(summary.cost_usd, None);
718 assert_eq!(summary.known_cost_usd, 0.0123);
719 assert_eq!(summary.unpriced_calls, 1);
720 assert_eq!(summary.usage_unknown_calls, 1);
721 }
722}