Skip to main content

harn_cli/commands/
time.rs

1//! `harn time` — wrap a subcommand with structured phase timing.
2//!
3//! Today only `harn time run` is supported. The wrapper enables both VM
4//! and LLM tracing, drives the run through the same code path as
5//! `harn run`, and emits a versioned [`JsonEnvelope`] with per-phase
6//! wall-clock + cache hit/miss + per-LLM-call + per-tool-call latency.
7//!
8//! Phases are emitted in fixed order — `parse`, `typecheck`,
9//! `bytecode_compile`, `run_setup`, `run_main`, `module_compile`,
10//! `module_load` — even when a cache hit
11//! lets us skip parse/typecheck. That keeps consumers' shape stable so
12//! `phases.length >= 7` is a safe assertion and `cache: "hit"` always
13//! lives on the `bytecode_compile` row.
14
15use 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
29/// Schema version for the `harn time run --json` envelope. Bump when
30/// the [`TimingReport`] shape changes in a way agents must detect.
31pub const TIME_RUN_SCHEMA_VERSION: u32 = 2;
32
33/// Per-phase wall-clock samples recorded by the run path. Filled in by
34/// [`crate::commands::run`] when timing is requested; absent fields
35/// (e.g. parse on a cache hit) stay zero.
36#[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    /// Source size in bytes, captured before parse to populate the
44    /// `input_bytes` field on the parse phase row.
45    pub input_bytes: u64,
46    /// True when the bytecode cache short-circuited parse/typecheck.
47    pub cache_hit: bool,
48    /// VM-scoped module attribution. The live handle lets error paths report
49    /// partial work without copying timing state at every return site.
50    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    /// The wrapped subcommand. Always `"run"` today; future expansions
62    /// (e.g. `"check"`) reuse the same envelope.
63    pub command: String,
64    /// Resolved script path. `None` for `-e <code>` invocations.
65    #[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    /// Forwarded exit code from the wrapped subcommand. Non-zero exit
72    /// still emits a successful envelope — the wrapper's job is to
73    /// describe what happened, not to mask failures.
74    pub exit_code: i32,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum PhaseRecordKind {
80    /// A mutually reconcilable component of run wall time.
81    TopLevel,
82    /// Diagnostic work attribution that overlaps top-level phases.
83    Attribution,
84}
85
86#[derive(Debug, Serialize)]
87pub struct PhaseRecord {
88    pub name: String,
89    /// Whether this row is additive top-level time or overlapping attribution.
90    pub kind: PhaseRecordKind,
91    pub duration_ms: u64,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub input_bytes: Option<u64>,
94    /// `"hit"` or `"miss"` on `bytecode_compile`; absent on other phases.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub cache: Option<String>,
97    /// Count of completed events attributed to this phase.
98    #[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    /// Total tokens for the call (input + output). Per-call input/output
107    /// split is available via `harn run --trace`; this surface keeps the
108    /// shape compact for agent consumption.
109    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    // Lift the bytecode cache via env var so the existing `harn run`
128    // code path observes the override. The guard restores the previous
129    // value on drop; the CLI is single-shot, but tests reuse the
130    // process and rely on a clean revert.
131    let _cache_guard = args
132        .no_cache
133        .then(|| ScopedEnvVar::set(harn_vm::bytecode_cache::CACHE_ENABLED_ENV, "0"));
134
135    // Make sure LLM + VM tracing capture per-call durations. Both are
136    // thread-local and reset by `set_tracing_enabled(true)`.
137    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    // In `--json` mode stdout is owned by the envelope, so we keep
157    // script output buffered (passthrough disabled) and write it to
158    // stderr afterwards. In human mode we mirror `harn run` and stream
159    // script output directly to the terminal stdout.
160    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            // JSON mode owns stdout for the envelope. Script output
210            // would corrupt downstream `jq` pipelines, so mirror it to
211            // stderr where humans can still see it.
212            eprint!("{}", outcome.stdout);
213        } else {
214            // Passthrough already delivered output to the terminal in
215            // human mode, but on cache-hit paths some bytes can land
216            // in the captured buffer (stdout passthrough only catches
217            // bytes flushed after it was installed). Re-emit so they
218            // aren't lost.
219            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/// Total user + system CPU time consumed by the current process, in
349/// milliseconds. Falls back to `0` on platforms where `getrusage` is
350/// unavailable so the field is always present in the envelope.
351#[cfg(unix)]
352pub(crate) fn cpu_ms() -> u64 {
353    use std::mem::MaybeUninit;
354    // SAFETY: `getrusage` writes a fully-initialized `rusage` on success;
355    // we treat the value as live only after the syscall reports OK.
356    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    // `libc::time_t` and `libc::suseconds_t` are platform-defined
376    // (i64 + i32 on macOS, i64 + i64 on glibc Linux). Going through
377    // i128 once dodges the per-platform clippy lint on a no-op cast
378    // and gives plenty of headroom for the *1000.
379    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        // These are attribution rows overlapping run_setup/run_main, not
441        // additive top-level phases.
442        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        // input_bytes is omitted on a hit since the parse path didn't run.
541        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}