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