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 };
153
154 let _stdout_guard = (!args.json).then(StdoutPassthroughGuard::enable);
159
160 let (target, outcome) = match (args.eval.as_deref(), args.file.as_deref()) {
161 (Some(code), None) => {
162 let (wrapped, tmp) = prepare_eval_temp_file(code).unwrap_or_else(|e| {
163 eprintln!("error: {e}");
164 process::exit(1);
165 });
166 let tmp_path: PathBuf = tmp.path().to_path_buf();
167 if let Err(e) = fs::write(&tmp_path, &wrapped) {
168 eprintln!("error: failed to write temp file for -e: {e}");
169 process::exit(1);
170 }
171 let path_str = tmp_path.to_string_lossy().into_owned();
172 let outcome = execute_run_with_timing(
173 &path_str,
174 args.argv.clone(),
175 Some(&mut timing),
176 sandbox.clone(),
177 )
178 .await;
179 drop(tmp);
180 (None, outcome)
181 }
182 (None, Some(file)) => {
183 let outcome =
184 execute_run_with_timing(file, args.argv.clone(), Some(&mut timing), sandbox).await;
185 (Some(file.to_string()), outcome)
186 }
187 (Some(_), Some(_)) => {
188 eprintln!(
189 "error: `harn time run` accepts either `-e <code>` or `<file.harn>`, not both"
190 );
191 process::exit(2);
192 }
193 (None, None) => {
194 eprintln!("error: `harn time run` requires either `-e <code>` or `<file.harn>`");
195 process::exit(2);
196 }
197 };
198
199 let wall_ms = wall_start.elapsed().as_millis() as u64;
200 let cpu_ms_total = cpu_ms().saturating_sub(cpu_start);
201
202 if !outcome.stderr.is_empty() {
203 eprint!("{}", outcome.stderr);
204 }
205 if !outcome.stdout.is_empty() {
206 if args.json {
207 eprint!("{}", outcome.stdout);
211 } else {
212 print!("{}", outcome.stdout);
218 }
219 }
220
221 let llm_trace = harn_vm::llm::take_trace();
222 let spans = harn_vm::tracing::take_spans();
223
224 let llm_calls: Vec<LlmCallTiming> = llm_trace
225 .iter()
226 .map(|entry| LlmCallTiming {
227 model: entry.model.clone(),
228 latency_ms: entry.duration_ms,
229 tokens: entry.input_tokens + entry.output_tokens,
230 })
231 .collect();
232
233 let tool_calls: Vec<ToolCallTiming> = spans
234 .iter()
235 .filter(|span| span.kind.as_str() == "tool_call")
236 .map(|span| ToolCallTiming {
237 name: span.name.clone(),
238 latency_ms: span.duration_ms,
239 })
240 .collect();
241
242 let cache_hit = timing.cache_hit;
243 let phases = build_phase_records(&timing, spans.len() as u64);
244
245 let report = TimingReport {
246 command: "run".into(),
247 target,
248 phases,
249 llm_calls,
250 tool_calls,
251 totals: TimingTotals {
252 wall_ms,
253 cpu_ms: cpu_ms_total,
254 cache_hits: u64::from(cache_hit),
255 cache_misses: u64::from(!cache_hit),
256 },
257 exit_code: outcome.exit_code,
258 };
259
260 if args.json {
261 println!(
262 "{}",
263 to_string_pretty(&JsonEnvelope::ok(TIME_RUN_SCHEMA_VERSION, &report))
264 );
265 } else {
266 eprint!("{}", render_human(&report));
267 }
268
269 if outcome.exit_code != 0 {
270 process::exit(outcome.exit_code);
271 }
272}
273
274fn render_human(report: &TimingReport) -> String {
275 use std::fmt::Write;
276
277 let mut out = String::new();
278 let _ = writeln!(out, "\n\x1b[2m─── harn time ───\x1b[0m");
279 let _ = writeln!(
280 out,
281 " wall {} · cpu {} · {} cache",
282 format_ms(report.totals.wall_ms),
283 format_ms(report.totals.cpu_ms),
284 if report.totals.cache_hits > 0 {
285 "hit"
286 } else {
287 "miss"
288 },
289 );
290 let _ = writeln!(out, "\n Phases:");
291 for phase in &report.phases {
292 let suffix = if phase.kind == PhaseRecordKind::Attribution {
293 format!(
294 " ({} events; attribution overlaps top-level)",
295 phase.events.unwrap_or_default()
296 )
297 } else {
298 match (phase.input_bytes, phase.cache.as_deref(), phase.events) {
299 (Some(bytes), _, _) => format!(" ({bytes} input bytes)"),
300 (_, Some(cache), _) => format!(" (cache {cache})"),
301 (_, _, Some(events)) => format!(" ({events} events)"),
302 _ => String::new(),
303 }
304 };
305 let _ = writeln!(
306 out,
307 " {:<18} {:>10}{suffix}",
308 phase.name,
309 format_ms(phase.duration_ms),
310 );
311 }
312 if !report.llm_calls.is_empty() {
313 let _ = writeln!(out, "\n LLM calls:");
314 for call in &report.llm_calls {
315 let _ = writeln!(
316 out,
317 " {:<24} {:>10} ({} tokens)",
318 call.model,
319 format_ms(call.latency_ms),
320 call.tokens,
321 );
322 }
323 }
324 if !report.tool_calls.is_empty() {
325 let _ = writeln!(out, "\n Tool calls:");
326 for call in &report.tool_calls {
327 let _ = writeln!(
328 out,
329 " {:<24} {:>10}",
330 call.name,
331 format_ms(call.latency_ms),
332 );
333 }
334 }
335 out
336}
337
338fn format_ms(ms: u64) -> String {
339 if ms < 1000 {
340 format!("{ms} ms")
341 } else {
342 format!("{:.3} s", ms as f64 / 1000.0)
343 }
344}
345
346#[cfg(unix)]
350pub(crate) fn cpu_ms() -> u64 {
351 use std::mem::MaybeUninit;
352 unsafe {
355 let mut ru = MaybeUninit::<libc::rusage>::zeroed();
356 if libc::getrusage(libc::RUSAGE_SELF, ru.as_mut_ptr()) != 0 {
357 return 0;
358 }
359 let ru = ru.assume_init();
360 let user = duration_ms(ru.ru_utime.tv_sec, ru.ru_utime.tv_usec);
361 let system = duration_ms(ru.ru_stime.tv_sec, ru.ru_stime.tv_usec);
362 user.saturating_add(system)
363 }
364}
365
366#[cfg(not(unix))]
367pub(crate) fn cpu_ms() -> u64 {
368 0
369}
370
371#[cfg(unix)]
372fn duration_ms(secs: libc::time_t, micros: libc::suseconds_t) -> u64 {
373 let secs_ms = i128::from(secs).saturating_mul(1000);
378 let micros_ms = i128::from(micros) / 1000;
379 secs_ms.saturating_add(micros_ms).max(0) as u64
380}
381
382pub(crate) fn build_phase_records(timing: &RunTiming, main_events: u64) -> Vec<PhaseRecord> {
383 let cache_hit = timing.cache_hit;
384 let modules = timing
385 .module_phases
386 .as_ref()
387 .map(harn_vm::ModulePhaseRecorder::snapshot)
388 .unwrap_or_default();
389 vec![
390 PhaseRecord {
391 name: "parse".into(),
392 kind: PhaseRecordKind::TopLevel,
393 duration_ms: timing.parse.as_millis() as u64,
394 input_bytes: if cache_hit {
395 None
396 } else {
397 Some(timing.input_bytes)
398 },
399 cache: None,
400 events: None,
401 },
402 PhaseRecord {
403 name: "typecheck".into(),
404 kind: PhaseRecordKind::TopLevel,
405 duration_ms: timing.typecheck.as_millis() as u64,
406 input_bytes: None,
407 cache: None,
408 events: None,
409 },
410 PhaseRecord {
411 name: "bytecode_compile".into(),
412 kind: PhaseRecordKind::TopLevel,
413 duration_ms: timing.bytecode_compile.as_millis() as u64,
414 input_bytes: None,
415 cache: Some(if cache_hit {
416 "hit".into()
417 } else {
418 "miss".into()
419 }),
420 events: None,
421 },
422 PhaseRecord {
423 name: "run_setup".into(),
424 kind: PhaseRecordKind::TopLevel,
425 duration_ms: timing.run_setup.as_millis() as u64,
426 input_bytes: None,
427 cache: None,
428 events: None,
429 },
430 PhaseRecord {
431 name: "run_main".into(),
432 kind: PhaseRecordKind::TopLevel,
433 duration_ms: timing.run_main.as_millis() as u64,
434 input_bytes: None,
435 cache: None,
436 events: Some(main_events),
437 },
438 PhaseRecord {
441 name: "module_compile".into(),
442 kind: PhaseRecordKind::Attribution,
443 duration_ms: modules.module_compile_ms,
444 input_bytes: None,
445 cache: None,
446 events: Some(modules.modules_compiled),
447 },
448 PhaseRecord {
449 name: "module_load".into(),
450 kind: PhaseRecordKind::Attribution,
451 duration_ms: modules.module_load_ms,
452 input_bytes: None,
453 cache: None,
454 events: Some(modules.modules_loaded),
455 },
456 ]
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::tests::common::json_envelope::assert_envelope;
463
464 fn fixture_timing(cache_hit: bool) -> RunTiming {
465 RunTiming {
466 parse: if cache_hit {
467 Duration::default()
468 } else {
469 Duration::from_millis(12)
470 },
471 typecheck: if cache_hit {
472 Duration::default()
473 } else {
474 Duration::from_millis(80)
475 },
476 bytecode_compile: Duration::from_millis(35),
477 run_setup: Duration::from_millis(8),
478 run_main: Duration::from_millis(1200),
479 input_bytes: 4096,
480 cache_hit,
481 module_phases: None,
482 }
483 }
484
485 fn make_report(cache_hit: bool) -> TimingReport {
486 let timing = fixture_timing(cache_hit);
487 TimingReport {
488 command: "run".into(),
489 target: Some("examples/hello.harn".into()),
490 phases: build_phase_records(&timing, 14),
491 llm_calls: vec![LlmCallTiming {
492 model: "claude-sonnet-4-6".into(),
493 latency_ms: 850,
494 tokens: 1500,
495 }],
496 tool_calls: vec![ToolCallTiming {
497 name: "mcp_call".into(),
498 latency_ms: 200,
499 }],
500 totals: TimingTotals {
501 wall_ms: 1335,
502 cpu_ms: 320,
503 cache_hits: u64::from(cache_hit),
504 cache_misses: u64::from(!cache_hit),
505 },
506 exit_code: 0,
507 }
508 }
509
510 #[test]
511 fn miss_envelope_has_top_level_and_module_attribution_phases() {
512 let envelope = JsonEnvelope::ok(TIME_RUN_SCHEMA_VERSION, make_report(false));
513 let value = serde_json::to_value(&envelope).unwrap();
514 let data = assert_envelope(&value, TIME_RUN_SCHEMA_VERSION);
515 let phases = data["phases"].as_array().expect("phases is array");
516 assert_eq!(phases.len(), 7);
517 assert_eq!(phases[0]["name"], "parse");
518 assert_eq!(phases[0]["input_bytes"], 4096);
519 assert_eq!(phases[2]["name"], "bytecode_compile");
520 assert_eq!(phases[2]["cache"], "miss");
521 assert_eq!(phases[5]["name"], "module_compile");
522 assert_eq!(phases[5]["kind"], "attribution");
523 assert_eq!(phases[5]["events"], 0);
524 assert!(phases[5].get("cache").is_none());
525 assert_eq!(phases[6]["name"], "module_load");
526 assert_eq!(data["totals"]["cache_misses"], 1);
527 assert_eq!(data["totals"]["cache_hits"], 0);
528 }
529
530 #[test]
531 fn hit_envelope_zeros_parse_typecheck_and_marks_cache_hit() {
532 let envelope = JsonEnvelope::ok(TIME_RUN_SCHEMA_VERSION, make_report(true));
533 let value = serde_json::to_value(&envelope).unwrap();
534 let data = assert_envelope(&value, TIME_RUN_SCHEMA_VERSION);
535 let phases = data["phases"].as_array().expect("phases is array");
536 assert_eq!(phases[0]["duration_ms"], 0);
537 assert_eq!(phases[1]["duration_ms"], 0);
538 assert!(phases[0].get("input_bytes").is_none());
540 assert_eq!(phases[2]["cache"], "hit");
541 assert_eq!(data["totals"]["cache_hits"], 1);
542 }
543
544 #[test]
545 fn render_human_lists_phases_and_calls() {
546 let rendered = render_human(&make_report(false));
547 assert!(rendered.contains("harn time"));
548 assert!(rendered.contains("parse"));
549 assert!(rendered.contains("bytecode_compile"));
550 assert!(rendered.contains("cache miss"));
551 assert!(rendered.contains("attribution overlaps top-level"));
552 assert!(rendered.contains("claude-sonnet-4-6"));
553 assert!(rendered.contains("mcp_call"));
554 }
555
556 #[test]
557 fn render_human_for_hit_includes_cache_hit_marker() {
558 let rendered = render_human(&make_report(true));
559 assert!(rendered.contains("cache hit"));
560 }
561}