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