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;
18
19use super::{RunAttestationOptions, RunProfileOptions};
20
21#[derive(Clone, Default)]
23pub struct RunJsonOptions {
24 pub quiet: bool,
27}
28
29#[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 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: Option<f64>,
93 known_cost_usd: f64,
95 unpriced_calls: i64,
98}
99
100pub 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
467pub(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
486fn 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 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 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 #[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 #[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 #[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}