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 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 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 unpriced_calls: i64,
97}
98
99pub 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 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
469pub(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
488fn 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 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 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 #[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 #[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 #[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}