1use std::fs;
16use std::path::PathBuf;
17use std::process;
18use std::time::{Duration, Instant};
19
20use serde::Serialize;
21
22use crate::cli::TimeRunArgs;
23use crate::commands::run::{
24 execute_run_with_timing, prepare_eval_temp_file, StdoutPassthroughGuard,
25};
26use crate::env_guard::ScopedEnvVar;
27use crate::json_envelope::{to_string_pretty, JsonEnvelope};
28
29pub const TIME_RUN_SCHEMA_VERSION: u32 = 2;
32
33#[derive(Debug, Default, Clone)]
37pub struct RunTiming {
38 pub parse: Duration,
39 pub typecheck: Duration,
40 pub bytecode_compile: Duration,
41 pub run_setup: Duration,
42 pub run_main: Duration,
43 pub input_bytes: u64,
46 pub cache_hit: bool,
48 pub(crate) module_phases: Option<harn_vm::ModulePhaseRecorder>,
51}
52
53pub(crate) fn record_run_setup_elapsed(timing: Option<&mut RunTiming>, started: Instant) {
54 if let Some(timing) = timing {
55 timing.run_setup = started.elapsed();
56 }
57}
58
59#[derive(Debug, Serialize)]
60pub struct TimingReport {
61 pub command: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub target: Option<String>,
67 pub phases: Vec<PhaseRecord>,
68 pub llm_calls: Vec<LlmCallTiming>,
69 pub tool_calls: Vec<ToolCallTiming>,
70 pub totals: TimingTotals,
71 pub exit_code: i32,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum PhaseRecordKind {
80 TopLevel,
82 Attribution,
84}
85
86#[derive(Debug, Serialize)]
87pub struct PhaseRecord {
88 pub name: String,
89 pub kind: PhaseRecordKind,
91 pub duration_ms: u64,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub input_bytes: Option<u64>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub cache: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub events: Option<u64>,
100}
101
102#[derive(Debug, Serialize)]
103pub struct LlmCallTiming {
104 pub model: String,
105 pub latency_ms: u64,
106 pub tokens: i64,
110}
111
112#[derive(Debug, Serialize)]
113pub struct ToolCallTiming {
114 pub name: String,
115 pub latency_ms: u64,
116}
117
118#[derive(Debug, Serialize)]
119pub struct TimingTotals {
120 pub wall_ms: u64,
121 pub cpu_ms: u64,
122 pub cache_hits: u64,
123 pub cache_misses: u64,
124}
125
126pub(crate) async fn run(args: TimeRunArgs) {
127 let _cache_guard = args
132 .no_cache
133 .then(|| ScopedEnvVar::set(harn_vm::bytecode_cache::CACHE_ENABLED_ENV, "0"));
134
135 harn_vm::llm::enable_tracing();
138 harn_vm::tracing::set_tracing_enabled(true);
139 let _ = harn_vm::tracing::take_spans();
140 let _ = harn_vm::llm::take_trace();
141
142 let mut timing = RunTiming::default();
143 let cpu_start = cpu_ms();
144 let wall_start = std::time::Instant::now();
145 let sandbox = if args.no_sandbox {
146 crate::commands::run::RunSandboxOptions::disabled()
147 } else {
148 crate::commands::run::RunSandboxOptions::default()
149 .with_process_network(args.allow_process_network)
150 .with_write_roots(args.write_root.iter().cloned())
151 .with_read_only_roots(args.read_only_root.iter().cloned())
152 .with_process_read_roots(args.sandbox_read_root.iter().cloned())
153 .with_process_write_roots(args.sandbox_write_root.iter().cloned())
154 };
155
156 let _stdout_guard = (!args.json).then(StdoutPassthroughGuard::enable);
161
162 let (target, outcome) = match (args.eval.as_deref(), args.file.as_deref()) {
163 (Some(code), None) => {
164 let (wrapped, tmp) = prepare_eval_temp_file(code).unwrap_or_else(|e| {
165 eprintln!("error: {e}");
166 process::exit(1);
167 });
168 let tmp_path: PathBuf = tmp.path().to_path_buf();
169 if let Err(e) = fs::write(&tmp_path, &wrapped) {
170 eprintln!("error: failed to write temp file for -e: {e}");
171 process::exit(1);
172 }
173 let path_str = tmp_path.to_string_lossy().into_owned();
174 let outcome = execute_run_with_timing(
175 &path_str,
176 args.argv.clone(),
177 Some(&mut timing),
178 sandbox.clone(),
179 )
180 .await;
181 drop(tmp);
182 (None, outcome)
183 }
184 (None, Some(file)) => {
185 let outcome =
186 execute_run_with_timing(file, args.argv.clone(), Some(&mut timing), sandbox).await;
187 (Some(file.to_string()), outcome)
188 }
189 (Some(_), Some(_)) => {
190 eprintln!(
191 "error: `harn time run` accepts either `-e <code>` or `<file.harn>`, not both"
192 );
193 process::exit(2);
194 }
195 (None, None) => {
196 eprintln!("error: `harn time run` requires either `-e <code>` or `<file.harn>`");
197 process::exit(2);
198 }
199 };
200
201 let wall_ms = wall_start.elapsed().as_millis() as u64;
202 let cpu_ms_total = cpu_ms().saturating_sub(cpu_start);
203
204 if !outcome.stderr.is_empty() {
205 eprint!("{}", outcome.stderr);
206 }
207 if !outcome.stdout.is_empty() {
208 if args.json {
209 eprint!("{}", outcome.stdout);
213 } else {
214 print!("{}", outcome.stdout);
220 }
221 }
222
223 let llm_trace = harn_vm::llm::take_trace();
224 let spans = harn_vm::tracing::take_spans();
225
226 let llm_calls: Vec<LlmCallTiming> = llm_trace
227 .iter()
228 .map(|entry| LlmCallTiming {
229 model: entry.model.clone(),
230 latency_ms: entry.duration_ms,
231 tokens: entry.input_tokens + entry.output_tokens,
232 })
233 .collect();
234
235 let tool_calls: Vec<ToolCallTiming> = spans
236 .iter()
237 .filter(|span| span.kind.as_str() == "tool_call")
238 .map(|span| ToolCallTiming {
239 name: span.name.clone(),
240 latency_ms: span.duration_ms,
241 })
242 .collect();
243
244 let cache_hit = timing.cache_hit;
245 let phases = build_phase_records(&timing, spans.len() as u64);
246
247 let report = TimingReport {
248 command: "run".into(),
249 target,
250 phases,
251 llm_calls,
252 tool_calls,
253 totals: TimingTotals {
254 wall_ms,
255 cpu_ms: cpu_ms_total,
256 cache_hits: u64::from(cache_hit),
257 cache_misses: u64::from(!cache_hit),
258 },
259 exit_code: outcome.exit_code,
260 };
261
262 if args.json {
263 println!(
264 "{}",
265 to_string_pretty(&JsonEnvelope::ok(TIME_RUN_SCHEMA_VERSION, &report))
266 );
267 } else {
268 eprint!("{}", render_human(&report));
269 }
270
271 if outcome.exit_code != 0 {
272 process::exit(outcome.exit_code);
273 }
274}
275
276fn render_human(report: &TimingReport) -> String {
277 use std::fmt::Write;
278
279 let mut out = String::new();
280 let _ = writeln!(out, "\n\x1b[2m─── harn time ───\x1b[0m");
281 let _ = writeln!(
282 out,
283 " wall {} · cpu {} · {} cache",
284 format_ms(report.totals.wall_ms),
285 format_ms(report.totals.cpu_ms),
286 if report.totals.cache_hits > 0 {
287 "hit"
288 } else {
289 "miss"
290 },
291 );
292 let _ = writeln!(out, "\n Phases:");
293 for phase in &report.phases {
294 let suffix = if phase.kind == PhaseRecordKind::Attribution {
295 format!(
296 " ({} events; attribution overlaps top-level)",
297 phase.events.unwrap_or_default()
298 )
299 } else {
300 match (phase.input_bytes, phase.cache.as_deref(), phase.events) {
301 (Some(bytes), _, _) => format!(" ({bytes} input bytes)"),
302 (_, Some(cache), _) => format!(" (cache {cache})"),
303 (_, _, Some(events)) => format!(" ({events} events)"),
304 _ => String::new(),
305 }
306 };
307 let _ = writeln!(
308 out,
309 " {:<18} {:>10}{suffix}",
310 phase.name,
311 format_ms(phase.duration_ms),
312 );
313 }
314 if !report.llm_calls.is_empty() {
315 let _ = writeln!(out, "\n LLM calls:");
316 for call in &report.llm_calls {
317 let _ = writeln!(
318 out,
319 " {:<24} {:>10} ({} tokens)",
320 call.model,
321 format_ms(call.latency_ms),
322 call.tokens,
323 );
324 }
325 }
326 if !report.tool_calls.is_empty() {
327 let _ = writeln!(out, "\n Tool calls:");
328 for call in &report.tool_calls {
329 let _ = writeln!(
330 out,
331 " {:<24} {:>10}",
332 call.name,
333 format_ms(call.latency_ms),
334 );
335 }
336 }
337 out
338}
339
340fn format_ms(ms: u64) -> String {
341 if ms < 1000 {
342 format!("{ms} ms")
343 } else {
344 format!("{:.3} s", ms as f64 / 1000.0)
345 }
346}
347
348#[cfg(unix)]
352pub(crate) fn cpu_ms() -> u64 {
353 use std::mem::MaybeUninit;
354 unsafe {
357 let mut ru = MaybeUninit::<libc::rusage>::zeroed();
358 if libc::getrusage(libc::RUSAGE_SELF, ru.as_mut_ptr()) != 0 {
359 return 0;
360 }
361 let ru = ru.assume_init();
362 let user = duration_ms(ru.ru_utime.tv_sec, ru.ru_utime.tv_usec);
363 let system = duration_ms(ru.ru_stime.tv_sec, ru.ru_stime.tv_usec);
364 user.saturating_add(system)
365 }
366}
367
368#[cfg(not(unix))]
369pub(crate) fn cpu_ms() -> u64 {
370 0
371}
372
373#[cfg(unix)]
374fn duration_ms(secs: libc::time_t, micros: libc::suseconds_t) -> u64 {
375 let secs_ms = i128::from(secs).saturating_mul(1000);
380 let micros_ms = i128::from(micros) / 1000;
381 secs_ms.saturating_add(micros_ms).max(0) as u64
382}
383
384pub(crate) fn build_phase_records(timing: &RunTiming, main_events: u64) -> Vec<PhaseRecord> {
385 let cache_hit = timing.cache_hit;
386 let modules = timing
387 .module_phases
388 .as_ref()
389 .map(harn_vm::ModulePhaseRecorder::snapshot)
390 .unwrap_or_default();
391 vec![
392 PhaseRecord {
393 name: "parse".into(),
394 kind: PhaseRecordKind::TopLevel,
395 duration_ms: timing.parse.as_millis() as u64,
396 input_bytes: if cache_hit {
397 None
398 } else {
399 Some(timing.input_bytes)
400 },
401 cache: None,
402 events: None,
403 },
404 PhaseRecord {
405 name: "typecheck".into(),
406 kind: PhaseRecordKind::TopLevel,
407 duration_ms: timing.typecheck.as_millis() as u64,
408 input_bytes: None,
409 cache: None,
410 events: None,
411 },
412 PhaseRecord {
413 name: "bytecode_compile".into(),
414 kind: PhaseRecordKind::TopLevel,
415 duration_ms: timing.bytecode_compile.as_millis() as u64,
416 input_bytes: None,
417 cache: Some(if cache_hit {
418 "hit".into()
419 } else {
420 "miss".into()
421 }),
422 events: None,
423 },
424 PhaseRecord {
425 name: "run_setup".into(),
426 kind: PhaseRecordKind::TopLevel,
427 duration_ms: timing.run_setup.as_millis() as u64,
428 input_bytes: None,
429 cache: None,
430 events: None,
431 },
432 PhaseRecord {
433 name: "run_main".into(),
434 kind: PhaseRecordKind::TopLevel,
435 duration_ms: timing.run_main.as_millis() as u64,
436 input_bytes: None,
437 cache: None,
438 events: Some(main_events),
439 },
440 PhaseRecord {
443 name: "module_compile".into(),
444 kind: PhaseRecordKind::Attribution,
445 duration_ms: modules.module_compile_ms,
446 input_bytes: None,
447 cache: None,
448 events: Some(modules.modules_compiled),
449 },
450 PhaseRecord {
451 name: "module_load".into(),
452 kind: PhaseRecordKind::Attribution,
453 duration_ms: modules.module_load_ms,
454 input_bytes: None,
455 cache: None,
456 events: Some(modules.modules_loaded),
457 },
458 ]
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::tests::common::json_envelope::assert_envelope;
465
466 fn fixture_timing(cache_hit: bool) -> RunTiming {
467 RunTiming {
468 parse: if cache_hit {
469 Duration::default()
470 } else {
471 Duration::from_millis(12)
472 },
473 typecheck: if cache_hit {
474 Duration::default()
475 } else {
476 Duration::from_millis(80)
477 },
478 bytecode_compile: Duration::from_millis(35),
479 run_setup: Duration::from_millis(8),
480 run_main: Duration::from_millis(1200),
481 input_bytes: 4096,
482 cache_hit,
483 module_phases: None,
484 }
485 }
486
487 fn make_report(cache_hit: bool) -> TimingReport {
488 let timing = fixture_timing(cache_hit);
489 TimingReport {
490 command: "run".into(),
491 target: Some("examples/hello.harn".into()),
492 phases: build_phase_records(&timing, 14),
493 llm_calls: vec![LlmCallTiming {
494 model: "claude-sonnet-4-6".into(),
495 latency_ms: 850,
496 tokens: 1500,
497 }],
498 tool_calls: vec![ToolCallTiming {
499 name: "mcp_call".into(),
500 latency_ms: 200,
501 }],
502 totals: TimingTotals {
503 wall_ms: 1335,
504 cpu_ms: 320,
505 cache_hits: u64::from(cache_hit),
506 cache_misses: u64::from(!cache_hit),
507 },
508 exit_code: 0,
509 }
510 }
511
512 #[test]
513 fn miss_envelope_has_top_level_and_module_attribution_phases() {
514 let envelope = JsonEnvelope::ok(TIME_RUN_SCHEMA_VERSION, make_report(false));
515 let value = serde_json::to_value(&envelope).unwrap();
516 let data = assert_envelope(&value, TIME_RUN_SCHEMA_VERSION);
517 let phases = data["phases"].as_array().expect("phases is array");
518 assert_eq!(phases.len(), 7);
519 assert_eq!(phases[0]["name"], "parse");
520 assert_eq!(phases[0]["input_bytes"], 4096);
521 assert_eq!(phases[2]["name"], "bytecode_compile");
522 assert_eq!(phases[2]["cache"], "miss");
523 assert_eq!(phases[5]["name"], "module_compile");
524 assert_eq!(phases[5]["kind"], "attribution");
525 assert_eq!(phases[5]["events"], 0);
526 assert!(phases[5].get("cache").is_none());
527 assert_eq!(phases[6]["name"], "module_load");
528 assert_eq!(data["totals"]["cache_misses"], 1);
529 assert_eq!(data["totals"]["cache_hits"], 0);
530 }
531
532 #[test]
533 fn hit_envelope_zeros_parse_typecheck_and_marks_cache_hit() {
534 let envelope = JsonEnvelope::ok(TIME_RUN_SCHEMA_VERSION, make_report(true));
535 let value = serde_json::to_value(&envelope).unwrap();
536 let data = assert_envelope(&value, TIME_RUN_SCHEMA_VERSION);
537 let phases = data["phases"].as_array().expect("phases is array");
538 assert_eq!(phases[0]["duration_ms"], 0);
539 assert_eq!(phases[1]["duration_ms"], 0);
540 assert!(phases[0].get("input_bytes").is_none());
542 assert_eq!(phases[2]["cache"], "hit");
543 assert_eq!(data["totals"]["cache_hits"], 1);
544 }
545
546 #[test]
547 fn render_human_lists_phases_and_calls() {
548 let rendered = render_human(&make_report(false));
549 assert!(rendered.contains("harn time"));
550 assert!(rendered.contains("parse"));
551 assert!(rendered.contains("bytecode_compile"));
552 assert!(rendered.contains("cache miss"));
553 assert!(rendered.contains("attribution overlaps top-level"));
554 assert!(rendered.contains("claude-sonnet-4-6"));
555 assert!(rendered.contains("mcp_call"));
556 }
557
558 #[test]
559 fn render_human_for_hit_includes_cache_hit_marker() {
560 let rendered = render_human(&make_report(true));
561 assert!(rendered.contains("cache hit"));
562 }
563}