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` — even when a cache hit
10//! lets us skip parse/typecheck. That keeps consumers' shape stable so
11//! `phases.length >= 5` is a safe assertion and `cache: "hit"` always
12//! lives on the `bytecode_compile` row.
13
14use 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
28/// Schema version for the `harn time run --json` envelope. Bump when
29/// the [`TimingReport`] shape changes in a way agents must detect.
30pub const TIME_RUN_SCHEMA_VERSION: u32 = 1;
31
32/// Per-phase wall-clock samples recorded by the run path. Filled in by
33/// [`crate::commands::run`] when timing is requested; absent fields
34/// (e.g. parse on a cache hit) stay zero.
35#[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    /// Source size in bytes, captured before parse to populate the
43    /// `input_bytes` field on the parse phase row.
44    pub input_bytes: u64,
45    /// True when the bytecode cache short-circuited parse/typecheck.
46    pub cache_hit: bool,
47}
48
49#[derive(Debug, Serialize)]
50pub struct TimingReport {
51    /// The wrapped subcommand. Always `"run"` today; future expansions
52    /// (e.g. `"check"`) reuse the same envelope.
53    pub command: String,
54    /// Resolved script path. `None` for `-e <code>` invocations.
55    #[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    /// Forwarded exit code from the wrapped subcommand. Non-zero exit
62    /// still emits a successful envelope — the wrapper's job is to
63    /// describe what happened, not to mask failures.
64    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    /// `"hit"` or `"miss"` on `bytecode_compile`; absent on other phases.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub cache: Option<String>,
76    /// Count of completed VM spans observed inside this phase. Only
77    /// populated on `run_main` today.
78    #[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    /// Total tokens for the call (input + output). Per-call input/output
87    /// split is available via `harn run --trace`; this surface keeps the
88    /// shape compact for agent consumption.
89    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    // Lift the bytecode cache via env var so the existing `harn run`
108    // code path observes the override. The guard restores the previous
109    // value on drop; the CLI is single-shot, but tests reuse the
110    // process and rely on a clean revert.
111    let _cache_guard = args
112        .no_cache
113        .then(|| ScopedEnvVar::set(harn_vm::bytecode_cache::CACHE_ENABLED_ENV, "0"));
114
115    // Make sure LLM + VM tracing capture per-call durations. Both are
116    // thread-local and reset by `set_tracing_enabled(true)`.
117    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    // In `--json` mode stdout is owned by the envelope, so we keep
132    // script output buffered (passthrough disabled) and write it to
133    // stderr afterwards. In human mode we mirror `harn run` and stream
134    // script output directly to the terminal stdout.
135    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            // JSON mode owns stdout for the envelope. Script output
185            // would corrupt downstream `jq` pipelines, so mirror it to
186            // stderr where humans can still see it.
187            eprint!("{}", outcome.stdout);
188        } else {
189            // Passthrough already delivered output to the terminal in
190            // human mode, but on cache-hit paths some bytes can land
191            // in the captured buffer (stdout passthrough only catches
192            // bytes flushed after it was installed). Re-emit so they
193            // aren't lost.
194            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/// Total user + system CPU time consumed by the current process, in
317/// milliseconds. Falls back to `0` on platforms where `getrusage` is
318/// unavailable so the field is always present in the envelope.
319#[cfg(unix)]
320pub(crate) fn cpu_ms() -> u64 {
321    use std::mem::MaybeUninit;
322    // SAFETY: `getrusage` writes a fully-initialized `rusage` on success;
323    // we treat the value as live only after the syscall reports OK.
324    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    // `libc::time_t` and `libc::suseconds_t` are platform-defined
344    // (i64 + i32 on macOS, i64 + i64 on glibc Linux). Going through
345    // i128 once dodges the per-platform clippy lint on a no-op cast
346    // and gives plenty of headroom for the *1000.
347    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        // input_bytes is omitted on a hit since the parse path didn't run.
519        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}