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}
93
94pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 1;
95pub const RUN_PHASE_SCHEMA_VERSION: u32 = 2;
96pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
97
98#[derive(Serialize)]
99struct RunPhaseEvent {
100 schema_version: u32,
101 event: &'static str,
102 phases: Vec<PhaseRecord>,
103}
104
105#[derive(Serialize)]
106struct RunRusageEvent {
107 schema_version: u32,
108 event: &'static str,
109 cpu_ms: u64,
110}
111
112fn run_summary_options_from_args(args: &crate::cli::RunArgs) -> Option<RunSummaryOptions> {
113 args.emit_summary_json.then(|| RunSummaryOptions {
114 sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
115 })
116}
117
118pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
119 RunAuxOptions {
120 summary: run_summary_options_from_args(args),
121 phase: run_phase_options_from_args(args),
122 rusage: run_rusage_options_from_args(args),
123 }
124}
125
126pub(crate) fn run_control_options_from_args(args: &crate::cli::RunArgs) -> RunControlOptions {
127 RunControlOptions {
128 timeout: args.timeout,
129 defer_project_handlers: args.defer_project_handlers,
130 }
131}
132
133fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
134 args.emit_phase_json.then(|| RunPhaseOptions {
135 sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
136 })
137}
138
139fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
140 args.emit_rusage_json.then(|| RunRusageOptions {
141 sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
142 })
143}
144
145fn build_run_json_sink(
146 file: Option<PathBuf>,
147 fd: Option<i32>,
148 fd_flag: &'static str,
149) -> RunJsonSink {
150 RunJsonSink {
151 target: if let Some(path) = file {
152 RunJsonSinkTarget::File(path)
153 } else if let Some(fd) = fd {
154 RunJsonSinkTarget::Fd(fd)
155 } else {
156 RunJsonSinkTarget::Stderr
157 },
158 fd_flag,
159 }
160}
161
162pub(super) fn render_and_persist_profile_rollup(
163 options: &RunProfileOptions,
164 profile: &harn_vm::profile::RunProfile,
165 stderr: &mut String,
166) -> Result<(), String> {
167 if options.text {
168 stderr.push_str(&harn_vm::profile::render(profile));
169 }
170 if let Some(path) = options.json_path.as_ref() {
171 if let Some(parent) = path.parent() {
172 if !parent.as_os_str().is_empty() {
173 fs::create_dir_all(parent)
174 .map_err(|error| format!("create {}: {error}", parent.display()))?;
175 }
176 }
177 let json = serde_json::to_string_pretty(profile)
178 .map_err(|error| format!("serialize profile: {error}"))?;
179 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
180 }
181 Ok(())
182}
183
184fn build_run_summary<'a>(
185 started: Instant,
186 exit_code: i32,
187 profile: Option<&'a harn_vm::profile::RunProfile>,
188 llm: RunSummaryLlm,
189) -> RunSummary<'a> {
190 RunSummary {
191 schema_version: RUN_SUMMARY_SCHEMA_VERSION,
192 event: "run_summary",
193 wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
194 exit_code,
195 llm,
196 profile,
197 }
198}
199
200pub(super) fn run_summary_llm_snapshot() -> RunSummaryLlm {
201 let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
202 let cost_usd = harn_vm::llm::peek_total_cost();
203 RunSummaryLlm {
204 call_count,
205 input_tokens,
206 output_tokens,
207 time_ms,
208 cost_usd: if cost_usd.is_finite() { cost_usd } else { 0.0 },
209 }
210}
211
212pub(super) struct RunAuxEmission {
213 pub stderr: String,
214 pub exit_code: i32,
215 pub error: Option<String>,
216}
217
218#[allow(clippy::too_many_arguments)]
219pub(super) fn emit_run_aux_for_exit(
220 summary: Option<&RunSummaryOptions>,
221 phase: Option<&RunPhaseOptions>,
222 rusage: Option<&RunRusageOptions>,
223 started: Instant,
224 exit_code: i32,
225 profile: Option<&harn_vm::profile::RunProfile>,
226 llm: Option<RunSummaryLlm>,
227 timing: Option<&RunTiming>,
228 main_events: u64,
229 cpu_ms_total: Option<u64>,
230 json_mode: bool,
231 stderr: &mut String,
232) -> RunAuxEmission {
233 let mut aux_stderr = String::new();
234 let mut final_exit_code = exit_code;
235 let mut aux_error = None;
236 let aux_target = if json_mode { &mut aux_stderr } else { stderr };
237 let default_timing = RunTiming::default();
238 let timing = timing.unwrap_or(&default_timing);
239
240 if let Some(options) = summary {
241 let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
242 let summary = build_run_summary(started, exit_code, profile, llm);
243 if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
244 record_aux_error(
245 &mut final_exit_code,
246 &mut aux_error,
247 aux_target,
248 "run summary",
249 error,
250 );
251 }
252 }
253 if let Some(options) = phase {
254 let phase_event = RunPhaseEvent {
255 schema_version: RUN_PHASE_SCHEMA_VERSION,
256 event: "run_phase",
257 phases: time::build_phase_records(timing, main_events),
258 };
259 if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
260 {
261 record_aux_error(
262 &mut final_exit_code,
263 &mut aux_error,
264 aux_target,
265 "run phase",
266 error,
267 );
268 }
269 }
270 if let Some(options) = rusage {
271 let rusage_event = RunRusageEvent {
272 schema_version: RUN_RUSAGE_SCHEMA_VERSION,
273 event: "run_rusage",
274 cpu_ms: cpu_ms_total.unwrap_or(0),
275 };
276 if let Err(error) =
277 emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
278 {
279 record_aux_error(
280 &mut final_exit_code,
281 &mut aux_error,
282 aux_target,
283 "run rusage",
284 error,
285 );
286 }
287 }
288
289 RunAuxEmission {
290 stderr: aux_stderr,
291 exit_code: final_exit_code,
292 error: aux_error,
293 }
294}
295
296fn record_aux_error(
297 final_exit_code: &mut i32,
298 aux_error: &mut Option<String>,
299 stderr: &mut String,
300 label: &str,
301 error: String,
302) {
303 stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
304 if *final_exit_code == 0 {
305 *final_exit_code = 1;
306 }
307 if aux_error.is_none() {
308 *aux_error = Some(error);
309 }
310}
311
312fn emit_raw_json_line(
313 sink: &RunJsonSink,
314 value: &impl Serialize,
315 label: &str,
316 stderr: &mut String,
317) -> Result<(), String> {
318 let line =
319 serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
320 match &sink.target {
321 RunJsonSinkTarget::Stderr => {
322 stderr.push_str(&line);
323 Ok(())
324 }
325 RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
326 RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
327 }
328}
329
330fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
331 if let Some(parent) = path.parent() {
332 if !parent.as_os_str().is_empty() {
333 fs::create_dir_all(parent)
334 .map_err(|error| format!("create {}: {error}", parent.display()))?;
335 }
336 }
337 fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
338}
339
340#[cfg(unix)]
341fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
342 use std::fs::File;
343 use std::os::unix::io::FromRawFd;
344
345 if fd < 0 {
346 return Err(format!("invalid {flag} {fd}: must be non-negative"));
347 }
348 let duped = unsafe { libc::dup(fd) };
349 if duped < 0 {
350 return Err(format!(
351 "duplicate {flag} {fd}: {}",
352 io::Error::last_os_error()
353 ));
354 }
355 let mut file = unsafe { File::from_raw_fd(duped) };
356 file.write_all(line.as_bytes())
357 .and_then(|_| file.flush())
358 .map_err(|error| format!("write {flag} {fd}: {error}"))
359}
360
361#[cfg(not(unix))]
362fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
363 Err(format!("{flag} is only supported on Unix platforms"))
364}
365
366pub(super) async fn append_run_provenance_event(
367 log: &Arc<harn_vm::event_log::AnyEventLog>,
368 kind: &str,
369 payload: serde_json::Value,
370) {
371 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
372 return;
373 };
374 let _ = log
375 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
376 .await;
377}
378
379pub(super) async fn emit_run_attestation(
380 log: &Arc<harn_vm::event_log::AnyEventLog>,
381 path: &str,
382 store_base: &Path,
383 started_at_ms: i64,
384 exit_code: i32,
385 options: &RunAttestationOptions,
386 stderr: &mut String,
387) -> Result<(), String> {
388 let finished_at_ms = now_ms();
389 let status = if exit_code == 0 { "success" } else { "failure" };
390 append_run_provenance_event(
391 log,
392 "finished",
393 serde_json::json!({
394 "pipeline": path,
395 "status": status,
396 "exit_code": exit_code,
397 }),
398 )
399 .await;
400 log.flush()
401 .await
402 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
403 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
404 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
405 let (signing_key, key_id) =
406 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
407 .await
408 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
409 let receipt = harn_vm::build_signed_receipt(
410 log,
411 harn_vm::ReceiptBuildOptions {
412 pipeline: path.to_string(),
413 status: status.to_string(),
414 started_at_ms,
415 finished_at_ms,
416 exit_code,
417 producer_name: "harn-cli".to_string(),
418 producer_version: env!("CARGO_PKG_VERSION").to_string(),
419 },
420 &signing_key,
421 key_id,
422 )
423 .await
424 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
425 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
426 if let Some(parent) = receipt_path.parent() {
427 fs::create_dir_all(parent)
428 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
429 }
430 let encoded = serde_json::to_vec_pretty(&receipt)
431 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
432 fs::write(&receipt_path, encoded)
433 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
434 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
435 Ok(())
436}
437
438fn receipt_output_path(
439 store_base: &Path,
440 options: &RunAttestationOptions,
441 receipt_id: &str,
442) -> PathBuf {
443 if let Some(path) = options.receipt_out.as_ref() {
444 return path.clone();
445 }
446 harn_vm::runtime_paths::state_root(store_base)
447 .join("receipts")
448 .join(format!("{receipt_id}.json"))
449}
450
451pub(super) fn now_ms() -> i64 {
452 now_wall_ms(&RealClock::new())
453}
454
455pub(super) fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
462 use harn_vm::VmValue;
463 match value {
464 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
465 VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
466 _ => 0,
467 }
468}
469
470pub(crate) fn render_trace_summary() -> String {
471 use std::fmt::Write;
472
473 let entries = harn_vm::llm::take_trace();
474 if entries.is_empty() {
475 return String::new();
476 }
477 let mut out = String::new();
478 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
479 let mut total_input = 0i64;
480 let mut total_output = 0i64;
481 let mut total_ms = 0u64;
482 for (index, entry) in entries.iter().enumerate() {
483 let _ = writeln!(
484 out,
485 " #{}: {} | {} in + {} out tokens | {} ms",
486 index + 1,
487 entry.model,
488 entry.input_tokens,
489 entry.output_tokens,
490 entry.duration_ms,
491 );
492 total_input += entry.input_tokens;
493 total_output += entry.output_tokens;
494 total_ms += entry.duration_ms;
495 }
496 let total_tokens = total_input + total_output;
497 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
499 let _ = writeln!(
500 out,
501 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
502 entries.len(),
503 if entries.len() == 1 { "" } else { "s" },
504 total_tokens,
505 total_input,
506 total_output,
507 total_ms,
508 cost,
509 );
510 out
511}