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