返回 CodeWhale
metrics.rs
根目录 / crates / cli / src / metrics.rs
1 //! `codewhale metrics` — reads the audit log and session/task stores and prints
2 //! a human-readable usage rollup.
3 //!
4 //! Data sources, all resolved through the shared Codewhale state resolvers so
5 //! the reader lands on the same files the writers use:
6 //! - `~/.codewhale/audit.log` — one JSON line per event (approvals, credentials)
7 //! - `~/.codewhale/sessions/` — saved session JSON files (tool call history)
8 //! - `~/.codewhale/tasks/runtime/events/` — runtime thread JSONL event streams
9 //! - `~/.codewhale/sessions/<id>/runtime/events/` — session-scoped runtime
10 //! stores (`default_runtime_store_root` in `crates/tui/src/runtime_threads.rs`)
11 //!
12 //! `CODEWHALE_RUNTIME_DIR` / `DEEPSEEK_RUNTIME_DIR` is an *exclusive* store root
13 //! for every Runtime store in the writing process, so when it is set the reader
14 //! uses it alone. Mixing it with the default roots would count one call twice.
15 //!
16 //! Default-root audit history includes retained rotations and legacy receipts,
17 //! excluding records copied across roots. An explicit `CODEWHALE_HOME` never
18 //! reads outside that root.
19 //!
20 //! The three sources overlap and are deliberately not de-duplicated against
21 //! each other: an approval receipt, a saved-session transcript entry, and a
22 //! runtime `item.*` receipt each describe one tool call from a different
23 //! vantage point, and collapsing them would assert an identity the data does
24 //! not carry. Cross-reference with `--json` when an exact count matters.
25 //!
26 //! There is no fourth source. The opt-in tool audit file behind
27 //! `CODEWHALE_TOOL_AUDIT_LOG` / `DEEPSEEK_TOOL_AUDIT_LOG` (`emit_tool_audit`)
28 //! is a *different* file from `~/.codewhale/audit.log` and is not discovered
29 //! here. Because that variable can be pointed at the audit log itself, and
30 //! because `emit_tool_audit` puts `tool_name` and `success` at the JSON top
31 //! level rather than under `details`, the audit reader accepts both shapes.
32
33 use std::collections::{HashMap, HashSet};
34 use std::path::{Path, PathBuf};
35
36 use anyhow::Result;
37 use chrono::{DateTime, Duration, Utc};
38 use serde_json::Value;
39 use sha2::{Digest, Sha256};
40
41 // ──────────────────────────────────────────────────────────────────────────────
42 // Public entry-point
43 // ──────────────────────────────────────────────────────────────────────────────
44
45 /// Arguments accepted by `codewhale metrics`.
46 #[derive(Debug, Default)]
47 pub struct MetricsArgs {
48 /// Emit machine-readable JSON instead of human text.
49 pub json: bool,
50 /// Restrict to events newer than this cutoff (inclusive).
51 pub since: Option<DateTime<Utc>>,
52 }
53
54 pub fn run(args: MetricsArgs) -> Result<()> {
55 // `resolve_state_dir` is the shared read-path resolver already used by
56 // `doctor` and the session store; the runtime thread store hangs its event
57 // streams off `<tasks>/runtime`. Resolving the home is fallible, and a
58 // rollup of zeros is indistinguishable from real emptiness, so a home we
59 // cannot resolve is an error rather than a silent all-zero report.
60 let audit_roots = resolve_audit_roots()?;
61 let sessions = codewhale_config::resolve_state_dir("sessions")?;
62 let tasks = codewhale_config::resolve_state_dir("tasks")?;
63 let runtime_events = runtime_event_dirs(&tasks, &sessions);
64
65 // Collect data from every source; treat missing files as empty.
66 let mut rollup = Rollup::default();
67 read_audit_history(&audit_roots, args.since, &mut rollup);
68 read_session_files(&sessions, args.since, &mut rollup);
69 read_runtime_events(&runtime_events, args.since, &mut rollup);
70
71 if args.json {
72 print_json(&rollup)?;
73 } else {
74 print_human(&rollup, args.since);
75 }
76
77 Ok(())
78 }
79
80 // ──────────────────────────────────────────────────────────────────────────────
81 // Duration-string parser ("7d", "24h", "30m", "2h", "now-2h", "2h30m")
82 // ──────────────────────────────────────────────────────────────────────────────
83
84 /// Parse a loose humantime-ish duration string into an absolute `DateTime<Utc>`
85 /// cutoff (i.e. `Utc::now() - duration`).
86 ///
87 /// Accepted forms:
88 /// - `7d` / `24h` / `30m` / `90s`
89 /// - `2h30m`, `1d12h`
90 /// - `now-2h` (leading `now-` is stripped before parsing)
91 pub fn parse_since(s: &str) -> Result<DateTime<Utc>> {
92 let s = s.trim().to_ascii_lowercase();
93 let s = s.strip_prefix("now-").unwrap_or(&s);
94 let secs = parse_duration_secs(s)?;
95 Ok(Utc::now() - Duration::seconds(secs))
96 }
97
98 fn parse_duration_secs(s: &str) -> Result<i64> {
99 // Walk through the string accumulating numbers and consuming unit suffixes.
100 let mut total: i64 = 0;
101 let mut num_buf = String::new();
102
103 for ch in s.chars() {
104 match ch {
105 '0'..='9' => num_buf.push(ch),
106 'd' | 'h' | 'm' | 's' => {
107 let n: i64 = num_buf
108 .parse()
109 .map_err(|_| anyhow::anyhow!("invalid duration component: {num_buf:?}"))?;
110 num_buf.clear();
111 let factor = match ch {
112 'd' => 86_400,
113 'h' => 3_600,
114 'm' => 60,
115 's' => 1,
116 _ => unreachable!(),
117 };
118 total += n * factor;
119 }
120 _ => anyhow::bail!("unrecognised character {ch:?} in duration {s:?}"),
121 }
122 }
123
124 if !num_buf.is_empty() {
125 // Trailing bare number — treat as seconds.
126 let n: i64 = num_buf.parse()?;
127 total += n;
128 }
129
130 if total == 0 {
131 anyhow::bail!("duration {s:?} resolved to zero seconds");
132 }
133
134 Ok(total)
135 }
136
137 // ──────────────────────────────────────────────────────────────────────────────
138 // Rollup data model
139 // ──────────────────────────────────────────────────────────────────────────────
140
141 /// Per-tool aggregated counters.
142 #[derive(Debug, Default, serde::Serialize)]
143 pub struct ToolStats {
144 pub calls: u64,
145 /// Calls that were auto-approved (no prompt required).
146 pub auto_approved: u64,
147 /// Calls that required a manual prompt.
148 pub prompted: u64,
149 /// Total elapsed ms (from events that carry this field).
150 pub total_elapsed_ms: u64,
151 /// Number of elapsed_ms samples included in `total_elapsed_ms`.
152 pub elapsed_samples: u64,
153 /// Successful calls (where we have result data).
154 pub successes: u64,
155 /// Failed calls.
156 pub failures: u64,
157 /// Calls an approval receipt blocked before they ran. A denial is neither
158 /// a success nor a failure, so it stays out of `success_rate_pct`.
159 pub denied: u64,
160 /// Durable receipts whose outcome could not be read. Never folded into
161 /// `failures`: an unrecorded outcome is unknown, not a failure.
162 pub outcome_unknown: u64,
163 /// Terminal receipts with no usable `started_at`/`ended_at` pair. Counted
164 /// rather than contributing a 0 ms sample, which would understate a call
165 /// that was actually slow.
166 pub elapsed_unavailable: u64,
167 }
168
169 impl ToolStats {
170 fn success_rate_pct(&self) -> Option<f64> {
171 let judged = self.successes + self.failures;
172 if judged == 0 {
173 None
174 } else {
175 Some(self.successes as f64 / judged as f64 * 100.0)
176 }
177 }
178
179 fn avg_elapsed_ms(&self) -> Option<u64> {
180 self.total_elapsed_ms.checked_div(self.elapsed_samples)
181 }
182 }
183
184 /// Compaction event stats.
185 #[derive(Debug, Default, serde::Serialize)]
186 pub struct CompactionStats {
187 pub events: u64,
188 pub refusals: HashMap<String, u64>,
189 pub triggers: HashMap<String, u64>,
190 pub paths: HashMap<String, u64>,
191 pub summarizer_usage_samples: u64,
192 pub summarizer_input_tokens: u64,
193 pub summarizer_output_tokens: u64,
194 #[serde(skip)]
195 receipt_ids: HashSet<String>,
196 /// Sum of `reduction_ratio` from events that carry it (0.0–1.0 each).
197 pub ratio_sum: f64,
198 pub ratio_samples: u64,
199 }
200
201 impl CompactionStats {
202 fn avg_reduction_pct(&self) -> Option<f64> {
203 if self.ratio_samples == 0 {
204 None
205 } else {
206 Some(self.ratio_sum / self.ratio_samples as f64 * 100.0)
207 }
208 }
209 }
210
211 /// Sub-agent lifecycle receipt counts; these are not unique worker totals.
212 #[derive(Debug, Default, serde::Serialize)]
213 pub struct AgentStats {
214 pub spawns: u64,
215 pub successes: u64,
216 pub failures: u64,
217 pub cancelled: u64,
218 pub interrupted: u64,
219 pub budget_exhausted: u64,
220 /// Terminal receipts with missing, malformed, or unrecognized outcomes.
221 pub unknown_outcomes: u64,
222 /// Completions carrying a usage receipt. Token sums cover exactly these;
223 /// a completion without usage is a missing receipt, never zero tokens.
224 pub usage_receipts: u64,
225 pub input_tokens: u64,
226 pub output_tokens: u64,
227 pub total_tokens: u64,
228 /// Summed priced subtotal in microdollars, JSON consumers only.
229 pub cost_microusd: u64,
230 }
231
232 impl AgentStats {
233 fn record_completion(&mut self, event: &Value) {
234 // Runtime's worker_status owns the outcome. A completed status item
235 // means its receipt settled, not that the worker succeeded. Preserve
236 // explicit unknown values instead of falling back to a legacy boolean.
237 let status = event
238 .pointer("/details/worker_status")
239 .or_else(|| event.pointer("/payload/worker_status"))
240 .or_else(|| event.pointer("/details/status"))
241 .or_else(|| event.pointer("/payload/status"));
242 let count = if let Some(status) = status {
243 match status.as_str() {
244 Some("completed") => &mut self.successes,
245 Some("failed") => &mut self.failures,
246 Some("cancelled") => &mut self.cancelled,
247 Some("interrupted") => &mut self.interrupted,
248 Some("budget_exhausted") => &mut self.budget_exhausted,
249 _ => &mut self.unknown_outcomes,
250 }
251 } else {
252 match event
253 .pointer("/details/success")
254 .or_else(|| event.pointer("/payload/success"))
255 .and_then(Value::as_bool)
256 {
257 Some(true) => &mut self.successes,
258 Some(false) => &mut self.failures,
259 None => &mut self.unknown_outcomes,
260 }
261 };
262 *count = count.saturating_add(1);
263 // Child cost visibility (#6315): the runtime persists the completion
264 // receipt's usage on the agent.completed payload.
265 let usage = event
266 .pointer("/payload/usage")
267 .or_else(|| event.pointer("/details/usage"));
268 if let Some(usage) = usage
269 && usage.is_object()
270 {
271 self.usage_receipts = self.usage_receipts.saturating_add(1);
272 for (field, sum) in [
273 ("input_tokens", &mut self.input_tokens),
274 ("output_tokens", &mut self.output_tokens),
275 ("total_tokens", &mut self.total_tokens),
276 ("cost_microusd", &mut self.cost_microusd),
277 ] {
278 if let Some(n) = usage.get(field).and_then(Value::as_u64) {
279 *sum = (*sum).saturating_add(n);
280 }
281 }
282 }
283 }
284
285 fn summary(&self) -> String {
286 let outcomes = [
287 (self.successes, "completed"),
288 (self.failures, "failed"),
289 (self.cancelled, "cancelled"),
290 (self.interrupted, "interrupted"),
291 (self.budget_exhausted, "budget exhausted"),
292 (self.unknown_outcomes, "outcome unconfirmed"),
293 ]
294 .into_iter()
295 .filter(|(count, _)| *count > 0)
296 .map(|(count, label)| format!("{} {label}", fmt_num(count)))
297 .collect::<Vec<_>>();
298 if self.spawns == 0 && outcomes.is_empty() {
299 return "Sub-agents: (no data)".to_string();
300 }
301 let mut summary = format!("Sub-agents: {} spawn receipts", fmt_num(self.spawns));
302 if !outcomes.is_empty() {
303 summary.push_str(&format!("; outcomes: {}", outcomes.join(", ")));
304 }
305 if self.usage_receipts > 0 {
306 summary.push_str(&format!(
307 "; tokens: {} in/{} out/{} total ({} {})",
308 fmt_num(self.input_tokens),
309 fmt_num(self.output_tokens),
310 fmt_num(self.total_tokens),
311 fmt_num(self.usage_receipts),
312 if self.usage_receipts == 1 {
313 "receipt"
314 } else {
315 "receipts"
316 },
317 ));
318 }
319 summary
320 }
321 }
322
323 /// Capacity-controller / rate-limit intervention stats.
324 #[derive(Debug, Default, serde::Serialize)]
325 pub struct CapacityStats {
326 pub total: u64,
327 pub by_category: HashMap<String, u64>,
328 }
329
330 /// Credential / session event stats (from audit log).
331 #[derive(Debug, Default, serde::Serialize)]
332 pub struct CredentialStats {
333 pub saves: u64,
334 pub clears: u64,
335 }
336
337 /// Runtime receipts for model-client dispatch and provider-reported usage.
338 ///
339 /// These are deliberately not billing records: the terminal diagnostics count
340 /// parent model-client calls, while `turn.usage` exists only when a provider
341 /// supplied usage for one call. Client-internal HTTP retries and invoices are
342 /// outside both receipts.
343 #[derive(Debug, Default, serde::Serialize)]
344 pub struct RuntimeRequestStats {
345 /// Distinct durable `turn.completed` receipts with a usable `(thread, turn)` identity.
346 pub terminal_turn_receipts: u64,
347 /// Terminal receipts carrying the optional request diagnostics projection.
348 pub diagnostics_turn_receipts: u64,
349 /// Terminal receipts from older or partial logs with no diagnostics projection.
350 pub diagnostics_unavailable_turn_receipts: u64,
351 /// Present-but-incomplete diagnostics are unknown rather than zero.
352 pub diagnostics_incomplete_turn_receipts: u64,
353 /// Terminal receipts omitted because their identity could not be verified.
354 pub terminal_receipts_without_identity: u64,
355 /// Repeated terminal snapshots for one `(thread, turn)` omitted from the rollup.
356 pub duplicate_terminal_receipts_skipped: u64,
357 /// Parent model-client calls recorded by terminal diagnostics, not HTTP retries or invoices.
358 pub model_requests_started: u64,
359 pub transparent_stream_retries: u64,
360 pub stream_resumes: u64,
361 /// Distinct `turn.usage` receipts with a verified runtime event identity.
362 pub provider_usage_receipts: u64,
363 /// `turn.usage` records that could not be identified, so their values are unknown.
364 pub provider_usage_receipts_without_identity: u64,
365 /// Repeated runtime event identities omitted from provider usage totals.
366 pub duplicate_provider_usage_receipts_skipped: u64,
367 /// `turn.usage` records missing either required token total are not treated as zero.
368 pub provider_usage_receipts_incomplete: u64,
369 /// Provider-reported per-request input tokens only; terminal cumulative snapshots are excluded.
370 pub provider_reported_input_tokens: u64,
371 /// Provider-reported per-request output tokens only; terminal cumulative snapshots are excluded.
372 pub provider_reported_output_tokens: u64,
373 }
374
375 /// Top-level rollup.
376 #[derive(Debug, Default, serde::Serialize)]
377 pub struct Rollup {
378 /// UTC timestamp of the earliest event we've seen.
379 pub earliest_ts: Option<DateTime<Utc>>,
380 /// UTC timestamp of the latest event we've seen.
381 pub latest_ts: Option<DateTime<Utc>>,
382 /// Per-tool stats keyed by tool name.
383 pub tools: HashMap<String, ToolStats>,
384 pub compaction: CompactionStats,
385 pub agents: AgentStats,
386 pub capacity: CapacityStats,
387 pub credentials: CredentialStats,
388 pub runtime_requests: RuntimeRequestStats,
389 /// Total lines read across all sources.
390 pub total_lines: u64,
391 /// Lines successfully parsed.
392 pub parsed_lines: u64,
393 }
394
395 #[derive(Default)]
396 struct RuntimeEventDedup {
397 terminal_turns: HashSet<(String, String)>,
398 event_records: HashSet<(String, u64)>,
399 }
400
401 impl Rollup {
402 fn touch_ts(&mut self, ts: &DateTime<Utc>) {
403 match self.earliest_ts {
404 None => self.earliest_ts = Some(*ts),
405 Some(ref cur) if ts < cur => self.earliest_ts = Some(*ts),
406 _ => {}
407 }
408 match self.latest_ts {
409 None => self.latest_ts = Some(*ts),
410 Some(ref cur) if ts > cur => self.latest_ts = Some(*ts),
411 _ => {}
412 }
413 }
414
415 fn tool_mut(&mut self, name: &str) -> &mut ToolStats {
416 self.tools.entry(name.to_string()).or_default()
417 }
418
419 fn total_tool_calls(&self) -> u64 {
420 self.tools.values().map(|t| t.calls).sum()
421 }
422 }
423
424 // ──────────────────────────────────────────────────────────────────────────────
425 // Source readers
426 // ──────────────────────────────────────────────────────────────────────────────
427
428 /// Read both retained generations from each root. A copied legacy record is
429 /// counted once across roots, while repeated records within one root retain
430 /// their multiplicity. No source log is rewritten or removed.
431 fn read_audit_history(roots: &[PathBuf], since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
432 let mut earlier_roots = HashMap::new();
433 for root in roots {
434 let mut root_counts = HashMap::new();
435 for name in ["audit.log.1", "audit.log"] {
436 read_audit_log(
437 &root.join(name),
438 since,
439 rollup,
440 &earlier_roots,
441 &mut root_counts,
442 );
443 }
444 for (record, count) in root_counts {
445 let prior = earlier_roots.entry(record).or_insert(0);
446 *prior = (*prior).max(count);
447 }
448 }
449 }
450
451 /// Read one JSON event per line, excluding copies already seen in other roots.
452 fn read_audit_log(
453 path: &Path,
454 since: Option<DateTime<Utc>>,
455 rollup: &mut Rollup,
456 earlier_roots: &HashMap<[u8; 32], u64>,
457 root_counts: &mut HashMap<[u8; 32], u64>,
458 ) {
459 let content = match std::fs::read_to_string(path) {
460 Ok(c) => c,
461 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
462 Err(e) => {
463 tracing::trace!(
464 "metrics: could not read audit log {}: {}",
465 path.display(),
466 e
467 );
468 return;
469 }
470 };
471
472 for raw_line in content.lines() {
473 rollup.total_lines += 1;
474 let line = raw_line.trim();
475 if line.is_empty() {
476 continue;
477 }
478
479 let v: Value = match serde_json::from_str(line) {
480 Ok(v) => v,
481 Err(e) => {
482 tracing::trace!("metrics: skipping malformed audit line: {e}");
483 continue;
484 }
485 };
486
487 // Copy migration preserves the complete event, including its timestamp.
488 // Count occurrences so two identical legitimate records in one source
489 // are not collapsed into one merely because another root also exists.
490 let fingerprint: [u8; 32] = Sha256::digest(v.to_string().as_bytes()).into();
491 let count = root_counts.entry(fingerprint).or_insert(0);
492 *count += 1;
493 if *count <= earlier_roots.get(&fingerprint).copied().unwrap_or(0) {
494 continue;
495 }
496
497 // Parse timestamp — field is "ts" in audit log.
498 let ts = parse_ts_field(&v, "ts");
499
500 if let Some(cutoff) = since {
501 match ts {
502 Some(t) if t < cutoff => continue,
503 _ => {}
504 }
505 }
506
507 rollup.parsed_lines += 1;
508 if let Some(t) = &ts {
509 rollup.touch_ts(t);
510 }
511
512 let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");
513
514 match event {
515 // `log_sensitive_event` emits `auto_approve_session`
516 // (`crates/tui/src/tui/ui/event_loop.rs`). The bare `auto_approve`
517 // name only ever appears in older logs; keep it as an alias.
518 "tool.approval.auto_approve" | "tool.approval.auto_approve_session" => {
519 let tool_name = audit_tool_name(&v);
520 let stats = rollup.tool_mut(tool_name);
521 stats.calls += 1;
522 stats.auto_approved += 1;
523 }
524 // Every denial name written by `log_sensitive_event` and
525 // `auto_deny_session_approval`. The call never ran, so it is
526 // counted as its own class rather than as a failed execution.
527 "tool.approval.auto_deny"
528 | "tool.approval.auto_deny_session"
529 | "tool.approval.auto_deny_auto_review"
530 | "tool.approval.auto_deny_full_access_policy" => {
531 let tool_name = audit_tool_name(&v);
532 let stats = rollup.tool_mut(tool_name);
533 stats.calls += 1;
534 stats.denied += 1;
535 }
536 "tool.approval.prompted" => {
537 let tool_name = audit_tool_name(&v);
538 let stats = rollup.tool_mut(tool_name);
539 stats.calls += 1;
540 stats.prompted += 1;
541 }
542 "tool.completed" | "tool.result" => {
543 let tool_name = audit_tool_name(&v);
544 let stats = rollup.tool_mut(tool_name);
545 stats.calls += 1;
546
547 // Optional elapsed_ms
548 if let Some(ms) = v
549 .pointer("/details/elapsed_ms")
550 .or_else(|| v.pointer("/payload/elapsed_ms"))
551 .or_else(|| v.get("elapsed_ms"))
552 .and_then(|v| v.as_u64())
553 {
554 stats.total_elapsed_ms += ms;
555 stats.elapsed_samples += 1;
556 }
557
558 // Success / failure. An absent outcome is unknown, not a
559 // success — the previous default silently graded every
560 // outcome-free receipt as passing.
561 match v
562 .pointer("/details/success")
563 .or_else(|| v.pointer("/payload/success"))
564 .or_else(|| v.get("success"))
565 .and_then(|b| b.as_bool())
566 {
567 Some(true) => stats.successes += 1,
568 Some(false) => stats.failures += 1,
569 None => stats.outcome_unknown += 1,
570 }
571 }
572 "compaction.refused" => {
573 let reason = v
574 .pointer("/details/reason")
575 .and_then(Value::as_str)
576 .unwrap_or("unknown");
577 *rollup
578 .compaction
579 .refusals
580 .entry(reason.to_string())
581 .or_default() += 1;
582 }
583 "compaction.completed" | "context.compaction" => {
584 if let Some(id) = compaction_receipt_identity(&v, false)
585 && !rollup.compaction.receipt_ids.insert(id)
586 {
587 continue;
588 }
589 for (field, counts) in [
590 ("trigger", &mut rollup.compaction.triggers),
591 ("path", &mut rollup.compaction.paths),
592 ] {
593 if let Some(value) = v
594 .pointer(&format!("/details/{field}"))
595 .and_then(Value::as_str)
596 {
597 *counts.entry(value.to_string()).or_default() += 1;
598 }
599 }
600 if let (Some(input), Some(output)) = (
601 v.pointer("/details/summarizer_usage/input_tokens")
602 .and_then(Value::as_u64),
603 v.pointer("/details/summarizer_usage/output_tokens")
604 .and_then(Value::as_u64),
605 ) {
606 rollup.compaction.summarizer_usage_samples += 1;
607 rollup.compaction.summarizer_input_tokens += input;
608 rollup.compaction.summarizer_output_tokens += output;
609 }
610 rollup.compaction.events += 1;
611 if let Some(ratio) = v
612 .pointer("/details/reduction_ratio")
613 .or_else(|| v.pointer("/payload/reduction_ratio"))
614 .and_then(|r| r.as_f64())
615 {
616 rollup.compaction.ratio_sum += ratio;
617 rollup.compaction.ratio_samples += 1;
618 }
619 }
620 "agent.spawn" | "agent.spawned" | "subagent.spawned" => {
621 rollup.agents.spawns += 1;
622 }
623 "agent.completed" | "subagent.completed" => {
624 rollup.agents.record_completion(&v);
625 }
626 e if e.starts_with("capacity.") => {
627 rollup.capacity.total += 1;
628 let category = v
629 .pointer("/details/category")
630 .or_else(|| v.pointer("/payload/category"))
631 .and_then(|c| c.as_str())
632 .unwrap_or(e.trim_start_matches("capacity."));
633 *rollup
634 .capacity
635 .by_category
636 .entry(category.to_string())
637 .or_insert(0) += 1;
638 }
639 "credential.save" => {
640 rollup.credentials.saves += 1;
641 }
642 "credential.clear" => {
643 rollup.credentials.clears += 1;
644 }
645 _ => {
646 // Unknown event — tracked in parsed_lines but otherwise ignored.
647 }
648 }
649 }
650 }
651
652 /// Read session JSON files under `sessions/` (one per session).
653 /// These carry tool call history with optional elapsed_ms and result data.
654 fn read_session_files(sessions_dir: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
655 let rd = match std::fs::read_dir(sessions_dir) {
656 Ok(rd) => rd,
657 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
658 Err(e) => {
659 tracing::trace!(
660 "metrics: could not list sessions dir {}: {}",
661 sessions_dir.display(),
662 e
663 );
664 return;
665 }
666 };
667
668 for entry in rd.flatten() {
669 let path = entry.path();
670 // Only look at .json files directly in sessions/; skip sub-dirs.
671 if path.is_dir() || path.extension().map(|e| e != "json").unwrap_or(true) {
672 continue;
673 }
674 read_session_file(&path, since, rollup);
675 }
676 }
677
678 fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
679 let content = match std::fs::read_to_string(path) {
680 Ok(c) => c,
681 Err(e) => {
682 tracing::trace!(
683 "metrics: could not read session file {}: {}",
684 path.display(),
685 e
686 );
687 return;
688 }
689 };
690
691 rollup.total_lines += 1;
692
693 let v: Value = match serde_json::from_str(&content) {
694 Ok(v) => v,
695 Err(e) => {
696 tracing::trace!(
697 "metrics: skipping malformed session file {}: {}",
698 path.display(),
699 e
700 );
701 return;
702 }
703 };
704
705 rollup.parsed_lines += 1;
706
707 // Session-level timestamp filter (check metadata.created_at or updated_at).
708 let session_ts = v
709 .pointer("/metadata/updated_at")
710 .or_else(|| v.pointer("/metadata/created_at"))
711 .and_then(|t| t.as_str())
712 .and_then(|s| s.parse::<DateTime<Utc>>().ok());
713
714 if let Some(cutoff) = since
715 && let Some(ts) = &session_ts
716 && *ts < cutoff
717 {
718 return;
719 }
720
721 if let Some(ts) = session_ts {
722 rollup.touch_ts(&ts);
723 }
724
725 // Walk messages looking for tool_use calls with associated results.
726 let messages = match v.get("messages").and_then(|m| m.as_array()) {
727 Some(m) => m,
728 None => return,
729 };
730
731 // Build a map from tool_use_id → (tool_name, elapsed_ms_option, started_at_option).
732 let mut pending: HashMap<String, (String, Option<u64>)> = HashMap::new();
733
734 for msg in messages {
735 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
736 let content_arr = match msg.get("content").and_then(|c| c.as_array()) {
737 Some(c) => c,
738 None => continue,
739 };
740
741 for block in content_arr {
742 let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
743 match (role, block_type) {
744 ("assistant", "tool_use") => {
745 let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
746 let name = block
747 .get("name")
748 .and_then(|n| n.as_str())
749 .unwrap_or("unknown");
750 let elapsed_ms = block.get("elapsed_ms").and_then(|e| e.as_u64());
751 if !id.is_empty() {
752 pending.insert(id.to_string(), (name.to_string(), elapsed_ms));
753 }
754 }
755 ("user", "tool_result") => {
756 let id = block
757 .get("tool_use_id")
758 .and_then(|i| i.as_str())
759 .unwrap_or("");
760 if let Some((name, elapsed_ms)) = pending.remove(id) {
761 let stats = rollup.tool_mut(&name);
762 // Only count if not already counted via audit log (we don't de-dup, so
763 // session files may double-count approvals; that's acceptable — users who
764 // want precise counts should use --json and cross-reference).
765 stats.calls += 1;
766 if let Some(ms) = elapsed_ms {
767 stats.total_elapsed_ms += ms;
768 stats.elapsed_samples += 1;
769 }
770 // Tool result success: absence of "is_error": true
771 let is_error = block
772 .get("is_error")
773 .and_then(|e| e.as_bool())
774 .unwrap_or(false);
775 if is_error {
776 stats.failures += 1;
777 } else {
778 stats.successes += 1;
779 }
780 }
781 }
782 _ => {}
783 }
784 }
785 }
786
787 // Walk messages for compaction events embedded as special user messages.
788 for msg in messages {
789 if let Some(compaction) = msg
790 .get("compaction")
791 .or_else(|| msg.pointer("/metadata/compaction"))
792 {
793 rollup.compaction.events += 1;
794 if let Some(ratio) = compaction.get("reduction_ratio").and_then(|r| r.as_f64()) {
795 rollup.compaction.ratio_sum += ratio;
796 rollup.compaction.ratio_samples += 1;
797 }
798 }
799 }
800 }
801
802 /// Every Runtime event directory this install can have written to.
803 ///
804 /// Mirrors `default_runtime_store_root` / `runtime_dir_override` in
805 /// `crates/tui/src/runtime_threads.rs`: the task-scoped store lives at
806 /// `<tasks>/runtime`, a session-scoped store at `<sessions>/<id>/runtime`, and
807 /// an explicit `CODEWHALE_RUNTIME_DIR` replaces both. Missing directories are
808 /// simply empty; the walk stays inside the resolved state roots.
809 fn runtime_event_dirs(tasks: &Path, sessions: &Path) -> Vec<PathBuf> {
810 if let Some(root) = runtime_dir_override() {
811 return vec![root.join("events")];
812 }
813 let mut dirs = vec![tasks.join("runtime").join("events")];
814 let Ok(rd) = std::fs::read_dir(sessions) else {
815 return dirs;
816 };
817 let mut session_dirs: Vec<PathBuf> = rd
818 .flatten()
819 // `DirEntry::file_type` does not follow symlinks, so a link planted in
820 // `sessions/` cannot walk the reader into another root.
821 .filter(|entry| entry.file_type().is_ok_and(|ty| ty.is_dir()))
822 .map(|entry| entry.path().join("runtime").join("events"))
823 .collect();
824 session_dirs.sort();
825 dirs.append(&mut session_dirs);
826 dirs
827 }
828
829 /// The writer's exclusive store-root override (`runtime_dir_override`).
830 fn runtime_dir_override() -> Option<PathBuf> {
831 std::env::var("CODEWHALE_RUNTIME_DIR")
832 .or_else(|_| std::env::var("DEEPSEEK_RUNTIME_DIR"))
833 .ok()
834 .filter(|dir| !dir.trim().is_empty())
835 .map(PathBuf::from)
836 }
837
838 /// Read every runtime event root under one de-duplication scope, so the same
839 /// `(thread_id, seq)` receipt is counted once however many roots list it.
840 fn read_runtime_events(events_dirs: &[PathBuf], since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
841 let mut dedup = RuntimeEventDedup::default();
842 for dir in events_dirs {
843 read_runtime_events_dir(dir, since, rollup, &mut dedup);
844 }
845 }
846
847 /// Read JSONL event streams from one runtime events directory.
848 fn read_runtime_events_dir(
849 events_dir: &Path,
850 since: Option<DateTime<Utc>>,
851 rollup: &mut Rollup,
852 dedup: &mut RuntimeEventDedup,
853 ) {
854 let rd = match std::fs::read_dir(events_dir) {
855 Ok(rd) => rd,
856 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
857 Err(e) => {
858 tracing::trace!(
859 "metrics: could not list events dir {}: {}",
860 events_dir.display(),
861 e
862 );
863 return;
864 }
865 };
866
867 for entry in rd.flatten() {
868 let path = entry.path();
869 if path.extension().map(|e| e != "jsonl").unwrap_or(true) {
870 continue;
871 }
872 read_events_jsonl(&path, since, rollup, dedup);
873 }
874 }
875
876 fn read_events_jsonl(
877 path: &Path,
878 since: Option<DateTime<Utc>>,
879 rollup: &mut Rollup,
880 dedup: &mut RuntimeEventDedup,
881 ) {
882 let content = match std::fs::read_to_string(path) {
883 Ok(c) => c,
884 Err(e) => {
885 tracing::trace!(
886 "metrics: could not read events file {}: {}",
887 path.display(),
888 e
889 );
890 return;
891 }
892 };
893
894 for raw_line in content.lines() {
895 rollup.total_lines += 1;
896 let line = raw_line.trim();
897 if line.is_empty() {
898 continue;
899 }
900
901 let v: Value = match serde_json::from_str(line) {
902 Ok(v) => v,
903 Err(e) => {
904 tracing::trace!("metrics: skipping malformed event line: {e}");
905 continue;
906 }
907 };
908
909 let ts = parse_ts_field(&v, "timestamp");
910
911 if let Some(cutoff) = since {
912 match ts {
913 Some(t) if t < cutoff => continue,
914 _ => {}
915 }
916 }
917
918 rollup.parsed_lines += 1;
919 if let Some(t) = &ts {
920 rollup.touch_ts(t);
921 }
922
923 let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");
924
925 match event {
926 "turn.completed" => record_terminal_request_diagnostics(&v, rollup, dedup),
927 "turn.usage" => record_provider_usage_receipt(&v, rollup, dedup),
928 // Tool and compaction receipts are durable *item* records. The
929 // `tool.started` / `tool.completed` names this reader used to
930 // match are synthesized for HTTP clients by
931 // `map_compat_stream_event` (`crates/tui/src/runtime_api.rs`) and
932 // are never persisted, so those arms counted nothing.
933 "item.started" | "item.completed" | "item.failed" => {
934 record_runtime_item_receipt(event, &v, rollup, dedup);
935 }
936 "agent.spawned" | "subagent.spawned" => {
937 rollup.agents.spawns += 1;
938 }
939 "agent.completed" | "subagent.completed" => {
940 rollup.agents.record_completion(&v);
941 }
942 e if e.starts_with("capacity.") => {
943 rollup.capacity.total += 1;
944 let category = v
945 .pointer("/payload/category")
946 .and_then(|c| c.as_str())
947 .unwrap_or(e.trim_start_matches("capacity."));
948 *rollup
949 .capacity
950 .by_category
951 .entry(category.to_string())
952 .or_insert(0) += 1;
953 }
954 _ => {}
955 }
956 }
957 }
958
959 fn runtime_event_identity(v: &Value) -> Option<(String, u64)> {
960 Some((
961 v.get("thread_id")?.as_str()?.to_string(),
962 v.get("seq")?.as_u64()?,
963 ))
964 }
965
966 fn terminal_turn_identity(v: &Value) -> Option<(String, String)> {
967 Some((
968 v.get("thread_id")?.as_str()?.to_string(),
969 v.get("turn_id")?.as_str()?.to_string(),
970 ))
971 }
972
973 fn record_terminal_request_diagnostics(
974 v: &Value,
975 rollup: &mut Rollup,
976 dedup: &mut RuntimeEventDedup,
977 ) {
978 let Some(identity) = terminal_turn_identity(v) else {
979 rollup.runtime_requests.terminal_receipts_without_identity = rollup
980 .runtime_requests
981 .terminal_receipts_without_identity
982 .saturating_add(1);
983 return;
984 };
985 if !dedup.terminal_turns.insert(identity) {
986 rollup.runtime_requests.duplicate_terminal_receipts_skipped = rollup
987 .runtime_requests
988 .duplicate_terminal_receipts_skipped
989 .saturating_add(1);
990 return;
991 }
992
993 let stats = &mut rollup.runtime_requests;
994 stats.terminal_turn_receipts = stats.terminal_turn_receipts.saturating_add(1);
995 let Some(diagnostics) = v.pointer("/payload/turn/modelRequestDiagnostics") else {
996 stats.diagnostics_unavailable_turn_receipts = stats
997 .diagnostics_unavailable_turn_receipts
998 .saturating_add(1);
999 return;
1000 };
1001 let Some(model_requests_started) = diagnostics
1002 .get("modelRequestsStarted")
1003 .and_then(Value::as_u64)
1004 else {
1005 stats.diagnostics_incomplete_turn_receipts =
1006 stats.diagnostics_incomplete_turn_receipts.saturating_add(1);
1007 return;
1008 };
1009 let Some(transparent_stream_retries) = diagnostics
1010 .get("transparentStreamRetries")
1011 .and_then(Value::as_u64)
1012 else {
1013 stats.diagnostics_incomplete_turn_receipts =
1014 stats.diagnostics_incomplete_turn_receipts.saturating_add(1);
1015 return;
1016 };
1017 let Some(stream_resumes) = diagnostics.get("streamResumes").and_then(Value::as_u64) else {
1018 stats.diagnostics_incomplete_turn_receipts =
1019 stats.diagnostics_incomplete_turn_receipts.saturating_add(1);
1020 return;
1021 };
1022
1023 stats.diagnostics_turn_receipts = stats.diagnostics_turn_receipts.saturating_add(1);
1024 stats.model_requests_started = stats
1025 .model_requests_started
1026 .saturating_add(model_requests_started);
1027 stats.transparent_stream_retries = stats
1028 .transparent_stream_retries
1029 .saturating_add(transparent_stream_retries);
1030 stats.stream_resumes = stats.stream_resumes.saturating_add(stream_resumes);
1031 }
1032
1033 /// Fold one durable `item.*` receipt into the tool or compaction rollup.
1034 ///
1035 /// Reads only the fields a rollup needs — `kind`, `metadata.tool_name`,
1036 /// `metadata.is_error`, the two timestamps, and the compaction message counts.
1037 /// `item.detail` and `metadata.tool_input` carry tool output and arguments and
1038 /// are deliberately never read here.
1039 fn record_runtime_item_receipt(
1040 event: &str,
1041 v: &Value,
1042 rollup: &mut Rollup,
1043 dedup: &mut RuntimeEventDedup,
1044 ) {
1045 let Some(item) = v.pointer("/payload/item") else {
1046 return;
1047 };
1048 let kind = item.get("kind").and_then(Value::as_str).unwrap_or_default();
1049 let is_tool = matches!(kind, "tool_call" | "file_change" | "command_execution");
1050 if !is_tool && kind != "context_compaction" {
1051 return;
1052 }
1053 // One receipt, counted once. A record with no verifiable runtime identity
1054 // cannot be de-duplicated, so it is counted without one rather than
1055 // dropped — `thread_id` and `seq` are required fields of every record the
1056 // store writes, so this only affects hand-edited logs.
1057 if let Some(identity) = runtime_event_identity(v)
1058 && !dedup.event_records.insert(identity)
1059 {
1060 return;
1061 }
1062
1063 if !is_tool {
1064 record_compaction_item_receipt(event, v, rollup);
1065 return;
1066 }
1067
1068 // `tool_name` is copied forward into the completion metadata, but the
1069 // redaction and error branches rewrite or leave that object alone, so fall
1070 // back to the started record's `tool` projection before giving up. An
1071 // unresolvable name is bucketed, never dropped.
1072 let tool_name = item
1073 .pointer("/metadata/tool_name")
1074 .or_else(|| v.pointer("/payload/tool/name"))
1075 .and_then(Value::as_str)
1076 .unwrap_or("unknown");
1077 let elapsed_ms = item_elapsed_ms(item);
1078 let stats = rollup.tool_mut(tool_name);
1079 match event {
1080 "item.started" => stats.calls += 1,
1081 "item.completed" => match item.pointer("/metadata/is_error").and_then(Value::as_bool) {
1082 Some(false) => stats.successes += 1,
1083 Some(true) => stats.failures += 1,
1084 None => stats.outcome_unknown += 1,
1085 },
1086 // `item.failed` is the engine's own error branch: the tool call did
1087 // run and did not succeed.
1088 _ => stats.failures += 1,
1089 }
1090 if event != "item.started" {
1091 match elapsed_ms {
1092 Some(ms) => {
1093 stats.total_elapsed_ms = stats.total_elapsed_ms.saturating_add(ms);
1094 stats.elapsed_samples += 1;
1095 }
1096 None => stats.elapsed_unavailable += 1,
1097 }
1098 }
1099 }
1100
1101 fn compaction_receipt_identity(v: &Value, runtime: bool) -> Option<String> {
1102 let (session, id) = if runtime {
1103 (
1104 v.get("thread_id")?,
1105 v.pointer("/payload/item/metadata/compaction_id")?,
1106 )
1107 } else {
1108 (
1109 v.pointer("/details/thread_id")
1110 .filter(|id| id.is_string())
1111 .or_else(|| v.pointer("/details/session_id"))?,
1112 v.pointer("/details/compaction_id")?,
1113 )
1114 };
1115 Some(format!("{}:{}", session.as_str()?, id.as_str()?))
1116 }
1117
1118 /// A compaction is only counted when it completed. The size reduction comes
1119 /// from the two persisted message counts or it stays unknown — a compaction
1120 /// with no counts must never average in as a 0% reduction.
1121 fn record_compaction_item_receipt(event: &str, v: &Value, rollup: &mut Rollup) {
1122 if event != "item.completed" {
1123 return;
1124 }
1125 if let Some(id) = compaction_receipt_identity(v, true)
1126 && !rollup.compaction.receipt_ids.insert(id)
1127 {
1128 return;
1129 }
1130 rollup.compaction.events += 1;
1131 let before = v
1132 .pointer("/payload/messages_before")
1133 .and_then(Value::as_u64);
1134 let after = v.pointer("/payload/messages_after").and_then(Value::as_u64);
1135 let (Some(before), Some(after)) = (before, after) else {
1136 return;
1137 };
1138 if before == 0 {
1139 return;
1140 }
1141 rollup.compaction.ratio_sum += 1.0 - (after as f64 / before as f64);
1142 rollup.compaction.ratio_samples += 1;
1143 }
1144
1145 /// Exact interval between two persisted item timestamps, or `None`.
1146 ///
1147 /// Both fields are optional on the record, and a reversed pair is not a
1148 /// measurement. Neither case may contribute a 0 ms sample, and neither is
1149 /// evidence about a provider charge.
1150 fn item_elapsed_ms(item: &Value) -> Option<u64> {
1151 let started = parse_ts_field(item, "started_at")?;
1152 let ended = parse_ts_field(item, "ended_at")?;
1153 u64::try_from((ended - started).num_milliseconds()).ok()
1154 }
1155
1156 fn record_provider_usage_receipt(v: &Value, rollup: &mut Rollup, dedup: &mut RuntimeEventDedup) {
1157 let Some(identity) = runtime_event_identity(v) else {
1158 rollup
1159 .runtime_requests
1160 .provider_usage_receipts_without_identity = rollup
1161 .runtime_requests
1162 .provider_usage_receipts_without_identity
1163 .saturating_add(1);
1164 return;
1165 };
1166 if !dedup.event_records.insert(identity) {
1167 rollup
1168 .runtime_requests
1169 .duplicate_provider_usage_receipts_skipped = rollup
1170 .runtime_requests
1171 .duplicate_provider_usage_receipts_skipped
1172 .saturating_add(1);
1173 return;
1174 }
1175
1176 let stats = &mut rollup.runtime_requests;
1177 let Some(input_tokens) = v
1178 .pointer("/payload/usage/input_tokens")
1179 .and_then(Value::as_u64)
1180 else {
1181 stats.provider_usage_receipts_incomplete =
1182 stats.provider_usage_receipts_incomplete.saturating_add(1);
1183 return;
1184 };
1185 let Some(output_tokens) = v
1186 .pointer("/payload/usage/output_tokens")
1187 .and_then(Value::as_u64)
1188 else {
1189 stats.provider_usage_receipts_incomplete =
1190 stats.provider_usage_receipts_incomplete.saturating_add(1);
1191 return;
1192 };
1193 stats.provider_usage_receipts = stats.provider_usage_receipts.saturating_add(1);
1194 stats.provider_reported_input_tokens = stats
1195 .provider_reported_input_tokens
1196 .saturating_add(input_tokens);
1197 stats.provider_reported_output_tokens = stats
1198 .provider_reported_output_tokens
1199 .saturating_add(output_tokens);
1200 }
1201
1202 // ──────────────────────────────────────────────────────────────────────────────
1203 // Output formatters
1204 // ──────────────────────────────────────────────────────────────────────────────
1205
1206 fn print_json(rollup: &Rollup) -> Result<()> {
1207 println!("{}", serde_json::to_string_pretty(rollup)?);
1208 Ok(())
1209 }
1210
1211 fn print_human(rollup: &Rollup, since: Option<DateTime<Utc>>) {
1212 // Period header. When a --since cutoff yields nothing, print it: a bare
1213 // `--since 7` is seven seconds, and the empty window must not read the
1214 // same as a genuinely idle period (#6315).
1215 match (rollup.earliest_ts, rollup.latest_ts) {
1216 (Some(start), Some(end)) => {
1217 let days = (end - start).num_days();
1218 println!(
1219 "Period: {} → {} ({} days)",
1220 start.format("%Y-%m-%d"),
1221 end.format("%Y-%m-%d"),
1222 days
1223 );
1224 }
1225 (Some(start), None) | (None, Some(start)) => {
1226 println!("Period: {} → (unknown)", start.format("%Y-%m-%d"));
1227 }
1228 (None, None) => match since {
1229 Some(cutoff) => println!(
1230 "Period: (no data since {})",
1231 cutoff.format("%Y-%m-%d %H:%M UTC")
1232 ),
1233 None => println!("Period: (no data)"),
1234 },
1235 }
1236
1237 // ── Tools ──────────────────────────────────────────────────────────────
1238 let total_calls = rollup.total_tool_calls();
1239 if total_calls > 0 {
1240 // Overall success rate from session-file data (where we have result info).
1241 let total_ok: u64 = rollup.tools.values().map(|t| t.successes).sum();
1242 let total_judged: u64 = rollup
1243 .tools
1244 .values()
1245 .map(|t| t.successes + t.failures)
1246 .sum();
1247 let mut overall_rate = if total_judged > 0 {
1248 format!(
1249 "{:.1}% success",
1250 total_ok as f64 / total_judged as f64 * 100.0
1251 )
1252 } else {
1253 // Only approval events — show prompt breakdown.
1254 let auto: u64 = rollup.tools.values().map(|t| t.auto_approved).sum();
1255 let prompted: u64 = rollup.tools.values().map(|t| t.prompted).sum();
1256 format!("{auto} auto-approved, {prompted} prompted")
1257 };
1258 // Denied and outcome-unknown calls are excluded from the rate above by
1259 // construction, so they are named rather than silently dropped.
1260 let total_denied: u64 = rollup.tools.values().map(|t| t.denied).sum();
1261 if total_denied > 0 {
1262 overall_rate.push_str(&format!(", {} denied", fmt_num(total_denied)));
1263 }
1264 let total_unknown: u64 = rollup.tools.values().map(|t| t.outcome_unknown).sum();
1265 if total_unknown > 0 {
1266 overall_rate.push_str(&format!(", {} outcome unknown", fmt_num(total_unknown)));
1267 }
1268
1269 println!(
1270 "Tools: {:>6} calls ({})",
1271 fmt_num(total_calls),
1272 overall_rate
1273 );
1274
1275 // Sort tools by call count descending, top 15.
1276 let mut tools: Vec<(&String, &ToolStats)> = rollup.tools.iter().collect();
1277 tools.sort_by_key(|b| std::cmp::Reverse(b.1.calls));
1278 for (name, stats) in tools.iter().take(15) {
1279 let rate_str = match stats.success_rate_pct() {
1280 Some(pct) => format!("{pct:5.1}%"),
1281 None if stats.denied > 0 => {
1282 // Nothing ran, so an approval breakdown would read as if
1283 // it had.
1284 format!("{} denied", fmt_num(stats.denied))
1285 }
1286 None if stats.outcome_unknown > 0 => {
1287 format!("{} unknown", fmt_num(stats.outcome_unknown))
1288 }
1289 None => {
1290 // Only approval data available — show auto/prompted breakdown.
1291 let a = stats.auto_approved;
1292 let p = stats.prompted;
1293 if p == 0 {
1294 format!("auto×{a} ")
1295 } else {
1296 format!("auto×{a}/prompted×{p}")
1297 }
1298 }
1299 };
1300 let avg_str = match stats.avg_elapsed_ms() {
1301 Some(ms) => format!(" avg {ms}ms"),
1302 None => String::new(),
1303 };
1304 println!(
1305 " {name:<22} {:>6} {rate_str}{avg_str}",
1306 fmt_num(stats.calls)
1307 );
1308 }
1309 if tools.len() > 15 {
1310 println!(" … and {} more tools", tools.len() - 15);
1311 }
1312 } else {
1313 println!("Tools: (no data)");
1314 }
1315
1316 // ── Compaction ─────────────────────────────────────────────────────────
1317 let compaction_refusals: u64 = rollup.compaction.refusals.values().sum();
1318 if rollup.compaction.events > 0 || compaction_refusals > 0 {
1319 let avg_str = match rollup.compaction.avg_reduction_pct() {
1320 Some(pct) => format!(", avg {pct:.0}% size reduction"),
1321 // No message counts were recorded. Saying nothing here reads as
1322 // "no reduction"; say that it is unknown.
1323 None => ", size reduction unknown".to_string(),
1324 };
1325 let usage = if rollup.compaction.summarizer_usage_samples > 0 {
1326 format!(
1327 "{} input / {} output tokens across {} measured passes",
1328 fmt_num(rollup.compaction.summarizer_input_tokens),
1329 fmt_num(rollup.compaction.summarizer_output_tokens),
1330 fmt_num(rollup.compaction.summarizer_usage_samples)
1331 )
1332 } else {
1333 "usage unavailable".to_string()
1334 };
1335 println!(
1336 "Compaction: {} completed, {} refused{}; summarizer {}",
1337 fmt_num(rollup.compaction.events),
1338 fmt_num(compaction_refusals),
1339 avg_str,
1340 usage
1341 );
1342 } else {
1343 println!("Compaction: (no data)");
1344 }
1345
1346 // ── Sub-agents ─────────────────────────────────────────────────────────
1347 println!("{}", rollup.agents.summary());
1348
1349 // ── Capacity interventions ─────────────────────────────────────────────
1350 if rollup.capacity.total > 0 {
1351 let cat_str: String = {
1352 let mut cats: Vec<(&String, &u64)> = rollup.capacity.by_category.iter().collect();
1353 cats.sort_by(|a, b| b.1.cmp(a.1));
1354 cats.iter()
1355 .map(|(k, v)| format!("{v} {k}"))
1356 .collect::<Vec<_>>()
1357 .join(", ")
1358 };
1359 println!(
1360 "Capacity interventions: {} ({})",
1361 fmt_num(rollup.capacity.total),
1362 cat_str
1363 );
1364 } else {
1365 println!("Capacity interventions: (no data)");
1366 }
1367
1368 // ── Runtime request and provider-usage receipts ───────────────────────
1369 let runtime = &rollup.runtime_requests;
1370 if runtime.terminal_turn_receipts == 0 {
1371 println!("Runtime requests: (no terminal receipts; model-client counts unknown)");
1372 } else if runtime.diagnostics_turn_receipts == 0 {
1373 println!(
1374 "Runtime requests: (diagnostics unavailable for all {} terminal receipts)",
1375 fmt_num(runtime.terminal_turn_receipts)
1376 );
1377 } else {
1378 println!(
1379 "Runtime requests: {} model-client calls, {} stream resumes, {} transparent retries (diagnostics for {}/{} terminal receipts; status events excluded)",
1380 fmt_num(runtime.model_requests_started),
1381 fmt_num(runtime.stream_resumes),
1382 fmt_num(runtime.transparent_stream_retries),
1383 fmt_num(runtime.diagnostics_turn_receipts),
1384 fmt_num(runtime.terminal_turn_receipts),
1385 );
1386 }
1387 if runtime.provider_usage_receipts == 0 {
1388 println!("Provider usage receipts: (none recorded; this is not zero usage)");
1389 } else {
1390 println!(
1391 "Provider usage receipts: {} records, {} input tokens, {} output tokens",
1392 fmt_num(runtime.provider_usage_receipts),
1393 fmt_num(runtime.provider_reported_input_tokens),
1394 fmt_num(runtime.provider_reported_output_tokens),
1395 );
1396 }
1397 if runtime.diagnostics_unavailable_turn_receipts > 0
1398 || runtime.diagnostics_incomplete_turn_receipts > 0
1399 || runtime.terminal_receipts_without_identity > 0
1400 || runtime.provider_usage_receipts_without_identity > 0
1401 || runtime.provider_usage_receipts_incomplete > 0
1402 || runtime.duplicate_terminal_receipts_skipped > 0
1403 || runtime.duplicate_provider_usage_receipts_skipped > 0
1404 {
1405 println!(
1406 "Runtime receipt coverage: {} diagnostics unavailable, {} diagnostics incomplete, {} terminal receipts without identity, {} usage receipts without identity, {} usage receipts incomplete, {} duplicate terminal receipts skipped, {} duplicate usage receipts skipped",
1407 fmt_num(runtime.diagnostics_unavailable_turn_receipts),
1408 fmt_num(runtime.diagnostics_incomplete_turn_receipts),
1409 fmt_num(runtime.terminal_receipts_without_identity),
1410 fmt_num(runtime.provider_usage_receipts_without_identity),
1411 fmt_num(runtime.provider_usage_receipts_incomplete),
1412 fmt_num(runtime.duplicate_terminal_receipts_skipped),
1413 fmt_num(runtime.duplicate_provider_usage_receipts_skipped),
1414 );
1415 }
1416
1417 // ── Credentials ────────────────────────────────────────────────────────
1418 if rollup.credentials.saves > 0 || rollup.credentials.clears > 0 {
1419 println!(
1420 "Credentials: {} saves, {} clears",
1421 rollup.credentials.saves, rollup.credentials.clears
1422 );
1423 }
1424 }
1425
1426 // ──────────────────────────────────────────────────────────────────────────────
1427 // Helpers
1428 // ──────────────────────────────────────────────────────────────────────────────
1429
1430 /// An explicit home is an isolation boundary. Default installs can have
1431 /// distinct audit histories in both roots, even after a copied migration.
1432 fn resolve_audit_roots() -> Result<Vec<PathBuf>> {
1433 let primary = codewhale_config::codewhale_home()?;
1434 let mut roots = vec![primary];
1435 if !codewhale_config::codewhale_home_is_explicit() {
1436 let legacy = codewhale_config::legacy_deepseek_home()?;
1437 if !roots.contains(&legacy) {
1438 roots.push(legacy);
1439 }
1440 }
1441 Ok(roots)
1442 }
1443
1444 /// Resolve the tool a durable audit record is about.
1445 ///
1446 /// `~/.codewhale/audit.log` nests its payload under `details`; the opt-in
1447 /// `CODEWHALE_TOOL_AUDIT_LOG` file writes `tool_name` at the top level.
1448 fn audit_tool_name(v: &Value) -> &str {
1449 v.pointer("/details/tool_name")
1450 .or_else(|| v.pointer("/payload/tool_name"))
1451 .or_else(|| v.get("tool_name"))
1452 .and_then(Value::as_str)
1453 .unwrap_or("unknown")
1454 }
1455
1456 /// Parse a timestamp from a JSON value field (tries RFC3339).
1457 fn parse_ts_field(v: &Value, field: &str) -> Option<DateTime<Utc>> {
1458 v.get(field)?.as_str()?.parse::<DateTime<Utc>>().ok()
1459 }
1460
1461 /// Format a number with thousands separators.
1462 fn fmt_num(n: u64) -> String {
1463 let s = n.to_string();
1464 let mut result = String::with_capacity(s.len() + s.len() / 3);
1465 for (i, ch) in s.chars().rev().enumerate() {
1466 if i > 0 && i % 3 == 0 {
1467 result.push(',');
1468 }
1469 result.push(ch);
1470 }
1471 result.chars().rev().collect()
1472 }
1473
1474 // ──────────────────────────────────────────────────────────────────────────────
1475 // Tests
1476 // ──────────────────────────────────────────────────────────────────────────────
1477
1478 #[cfg(test)]
1479 mod tests {
1480 use super::*;
1481
1482 #[test]
1483 fn compaction_audit_counts_usage_refusals_and_deduplicates_runtime_receipt() {
1484 let tmp = tempfile::NamedTempFile::new().unwrap();
1485 let completed = serde_json::json!({"ts": "2026-09-19T12:00:00Z", "event": "compaction.completed", "details": {
1486 "session_id": "engine-session-a", "thread_id": "thread-a", "compaction_id": "pass-a", "trigger": "manual", "path": "summary",
1487 "reduction_ratio": 0.75, "summarizer_usage": {"input_tokens": 120, "output_tokens": 15}
1488 }});
1489 let refused = serde_json::json!({"ts": "2026-09-19T12:01:00Z", "event": "compaction.refused", "details": {"reason": "retained_floor"}});
1490 std::fs::write(tmp.path(), format!("{completed}\n{completed}\n{refused}\n")).unwrap();
1491 let mut rollup = Rollup::default();
1492 read_audit_test_log(tmp.path(), None, &mut rollup);
1493 record_compaction_item_receipt(
1494 "item.completed",
1495 &serde_json::json!({
1496 "thread_id": "thread-a", "payload": {"item": {"metadata": {"compaction_id": "pass-a"}}, "messages_before": 4, "messages_after": 1}
1497 }),
1498 &mut rollup,
1499 );
1500 assert_eq!(rollup.compaction.events, 1);
1501 assert_eq!(rollup.compaction.ratio_samples, 1);
1502 assert_eq!(rollup.compaction.avg_reduction_pct(), Some(75.0));
1503 assert_eq!(rollup.compaction.refusals["retained_floor"], 1);
1504 assert_eq!(rollup.compaction.triggers["manual"], 1);
1505 assert_eq!(rollup.compaction.paths["summary"], 1);
1506 assert_eq!(rollup.compaction.summarizer_input_tokens, 120);
1507 assert_eq!(rollup.compaction.summarizer_output_tokens, 15);
1508 }
1509
1510 fn read_audit_test_log(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
1511 super::read_audit_log(path, since, rollup, &HashMap::new(), &mut HashMap::new());
1512 }
1513
1514 fn read_runtime_test_log(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rollup) {
1515 super::read_events_jsonl(path, since, rollup, &mut RuntimeEventDedup::default());
1516 }
1517
1518 fn runtime_event(
1519 seq: u64,
1520 timestamp: &str,
1521 thread_id: &str,
1522 turn_id: Option<&str>,
1523 event: &str,
1524 payload: Value,
1525 ) -> Value {
1526 serde_json::json!({
1527 "schema_version": 4,
1528 "seq": seq,
1529 "timestamp": timestamp,
1530 "thread_id": thread_id,
1531 "turn_id": turn_id,
1532 "event": event,
1533 "payload": payload,
1534 })
1535 }
1536
1537 fn write_runtime_events(events: &[Value]) -> tempfile::NamedTempFile {
1538 use std::io::Write;
1539
1540 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1541 for event in events {
1542 writeln!(tmp, "{event}").unwrap();
1543 }
1544 tmp
1545 }
1546
1547 #[test]
1548 fn runtime_worker_completion_uses_owner_outcome_not_completed_receipt_status() {
1549 let statuses = [
1550 serde_json::json!("completed"),
1551 serde_json::json!("failed"),
1552 serde_json::json!("cancelled"),
1553 serde_json::json!("interrupted"),
1554 serde_json::json!("budget_exhausted"),
1555 Value::Null,
1556 ];
1557 let events: Vec<_> = statuses
1558 .into_iter()
1559 .enumerate()
1560 .map(|(seq, worker_status)| {
1561 runtime_event(
1562 seq as u64,
1563 "2026-09-08T10:00:00Z",
1564 "thread-a",
1565 Some("turn-a"),
1566 "agent.completed",
1567 serde_json::json!({
1568 "item": { "kind": "status", "status": "completed" },
1569 "agent_id": format!("worker-{seq}"),
1570 "worker_status": worker_status,
1571 "parent_run_id": "run-a",
1572 "spawn_depth": 1,
1573 "continuable": false,
1574 }),
1575 )
1576 })
1577 .collect();
1578 let tmp = write_runtime_events(&events);
1579 let mut rollup = Rollup::default();
1580 read_runtime_test_log(tmp.path(), None, &mut rollup);
1581 let agents = &rollup.agents;
1582 assert_eq!(agents.successes, 1, "a settled item is not worker success");
1583 assert_eq!(agents.failures, 1);
1584 assert_eq!(agents.cancelled, 1);
1585 assert_eq!(agents.interrupted, 1);
1586 assert_eq!(agents.budget_exhausted, 1);
1587 assert_eq!(agents.unknown_outcomes, 1);
1588 assert_eq!(agents.spawns, 0);
1589 let summary = agents.summary();
1590 assert!(summary.contains("1 failed"));
1591 assert!(summary.contains("1 outcome unconfirmed"));
1592 assert!(
1593 !summary.contains("no data"),
1594 "terminal-only windows have data"
1595 );
1596 assert!(
1597 !summary.contains("%"),
1598 "partial receipts are not a success rate"
1599 );
1600 }
1601
1602 #[test]
1603 fn runtime_worker_usage_sums_tokens_across_completion_receipts() {
1604 // #6315: completions with usage receipts sum into the rollup; a
1605 // completion without usage is a missing receipt, never zero tokens.
1606 let events = vec![
1607 runtime_event(
1608 0,
1609 "2026-09-08T10:00:00Z",
1610 "thread-a",
1611 Some("turn-a"),
1612 "agent.completed",
1613 serde_json::json!({
1614 "agent_id": "worker-0",
1615 "worker_status": "completed",
1616 "usage": {
1617 "status": "completed",
1618 "input_tokens": 800,
1619 "output_tokens": 200,
1620 "total_tokens": 1000,
1621 "cost_microusd": 50,
1622 },
1623 }),
1624 ),
1625 runtime_event(
1626 1,
1627 "2026-09-08T10:01:00Z",
1628 "thread-a",
1629 Some("turn-a"),
1630 "agent.completed",
1631 serde_json::json!({
1632 "agent_id": "worker-1",
1633 "worker_status": "completed",
1634 }),
1635 ),
1636 ];
1637 let tmp = write_runtime_events(&events);
1638 let mut rollup = Rollup::default();
1639 read_runtime_test_log(tmp.path(), None, &mut rollup);
1640 let agents = &rollup.agents;
1641 assert_eq!(agents.successes, 2);
1642 assert_eq!(agents.usage_receipts, 1);
1643 assert_eq!(agents.input_tokens, 800);
1644 assert_eq!(agents.output_tokens, 200);
1645 assert_eq!(agents.total_tokens, 1000);
1646 assert_eq!(agents.cost_microusd, 50);
1647 let summary = agents.summary();
1648 assert!(summary.contains("800 in/200 out/1,000 total (1 receipt)"));
1649 }
1650
1651 #[test]
1652 fn runtime_legacy_worker_receipts_require_explicit_success_evidence() {
1653 let payloads = [
1654 serde_json::json!({ "success": true }),
1655 serde_json::json!({ "success": false }),
1656 serde_json::json!({}),
1657 serde_json::json!({ "success": "true" }),
1658 serde_json::json!({ "worker_status": "failed", "success": true }),
1659 serde_json::json!({ "worker_status": null, "success": true }),
1660 serde_json::json!({ "worker_status": "running", "success": true }),
1661 serde_json::json!({ "worker_status": { "completed": true }, "success": true }),
1662 serde_json::json!({ "worker_status": "future_outcome", "success": true }),
1663 ];
1664 let events: Vec<_> = payloads
1665 .into_iter()
1666 .enumerate()
1667 .map(|(seq, payload)| {
1668 runtime_event(
1669 seq as u64,
1670 "2026-09-08T10:00:00Z",
1671 "thread-a",
1672 Some("turn-a"),
1673 "agent.completed",
1674 payload,
1675 )
1676 })
1677 .collect();
1678 let tmp = write_runtime_events(&events);
1679 let mut rollup = Rollup::default();
1680 read_runtime_test_log(tmp.path(), None, &mut rollup);
1681 assert_eq!(rollup.agents.successes, 1);
1682 assert_eq!(rollup.agents.failures, 2);
1683 assert_eq!(rollup.agents.unknown_outcomes, 6);
1684 }
1685
1686 #[test]
1687 fn audit_worker_receipts_share_typed_and_legacy_outcome_rules() {
1688 let events = [
1689 serde_json::json!({ "event": "agent.completed", "details": { "worker_status": "failed", "success": true } }),
1690 serde_json::json!({ "event": "subagent.completed", "payload": { "status": "cancelled", "success": true } }),
1691 serde_json::json!({ "event": "subagent.completed", "details": { "status": "completed" } }),
1692 serde_json::json!({ "event": "agent.completed", "details": { "success": false } }),
1693 serde_json::json!({ "event": "agent.completed", "payload": { "success": true } }),
1694 serde_json::json!({ "event": "agent.completed", "details": { "worker_status": null, "success": true } }),
1695 serde_json::json!({ "event": "agent.completed", "details": {} }),
1696 ];
1697 let tmp = write_runtime_events(&events);
1698 let mut rollup = Rollup::default();
1699 read_audit_test_log(tmp.path(), None, &mut rollup);
1700 assert_eq!(rollup.agents.successes, 2);
1701 assert_eq!(rollup.agents.failures, 2);
1702 assert_eq!(rollup.agents.cancelled, 1);
1703 assert_eq!(rollup.agents.unknown_outcomes, 2);
1704 let json = serde_json::to_value(&rollup).unwrap();
1705 assert_eq!(json["agents"]["unknown_outcomes"], 2);
1706 assert_eq!(json["agents"]["cancelled"], 1);
1707 }
1708
1709 // ── Duration parser ──
1710
1711 #[test]
1712 fn parse_since_7d() {
1713 let cutoff = parse_since("7d").unwrap();
1714 let expected = Utc::now() - Duration::days(7);
1715 // Allow ±2s for test execution time.
1716 assert!((cutoff - expected).num_seconds().abs() < 2);
1717 }
1718
1719 #[test]
1720 fn parse_since_24h() {
1721 let cutoff = parse_since("24h").unwrap();
1722 let expected = Utc::now() - Duration::hours(24);
1723 assert!((cutoff - expected).num_seconds().abs() < 2);
1724 }
1725
1726 #[test]
1727 fn parse_since_30m() {
1728 let cutoff = parse_since("30m").unwrap();
1729 let expected = Utc::now() - Duration::minutes(30);
1730 assert!((cutoff - expected).num_seconds().abs() < 2);
1731 }
1732
1733 #[test]
1734 fn parse_since_now_prefix() {
1735 // "now-2h" should strip "now-" and parse "2h".
1736 let cutoff = parse_since("now-2h").unwrap();
1737 let expected = Utc::now() - Duration::hours(2);
1738 assert!((cutoff - expected).num_seconds().abs() < 2);
1739 }
1740
1741 #[test]
1742 fn parse_since_compound() {
1743 let cutoff = parse_since("2h30m").unwrap();
1744 let expected = Utc::now() - Duration::seconds(2 * 3600 + 30 * 60);
1745 assert!((cutoff - expected).num_seconds().abs() < 2);
1746 }
1747
1748 #[test]
1749 fn parse_since_compound_days_hours() {
1750 let cutoff = parse_since("1d12h").unwrap();
1751 let expected = Utc::now() - Duration::seconds(36 * 3600);
1752 assert!((cutoff - expected).num_seconds().abs() < 2);
1753 }
1754
1755 #[test]
1756 fn parse_since_error_on_invalid() {
1757 assert!(parse_since("xyz").is_err());
1758 assert!(parse_since("").is_err());
1759 }
1760
1761 // ── fmt_num ──
1762
1763 #[test]
1764 fn fmt_num_zero() {
1765 assert_eq!(fmt_num(0), "0");
1766 }
1767
1768 #[test]
1769 fn fmt_num_thousands() {
1770 assert_eq!(fmt_num(1_000), "1,000");
1771 assert_eq!(fmt_num(12_453), "12,453");
1772 assert_eq!(fmt_num(1_000_000), "1,000,000");
1773 }
1774
1775 // ── Rollup from audit log ──
1776
1777 fn make_audit_line(event: &str, tool: &str, ts: &str) -> String {
1778 format!(
1779 r#"{{"details":{{"mode":"YOLO","session_id":null,"tool_name":"{tool}"}},"event":"{event}","ts":"{ts}"}}"#
1780 )
1781 }
1782
1783 #[test]
1784 fn audit_log_empty_file() {
1785 let mut rollup = Rollup::default();
1786 // Non-existent path — should not panic, rollup stays empty.
1787 read_audit_test_log(Path::new("/nonexistent/audit.log"), None, &mut rollup);
1788 assert_eq!(rollup.total_lines, 0);
1789 }
1790
1791 #[test]
1792 fn audit_log_parses_auto_approve() {
1793 use std::io::Write;
1794 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1795 let line1 = make_audit_line(
1796 "tool.approval.auto_approve",
1797 "exec_shell",
1798 "2026-04-01T10:00:00+00:00",
1799 );
1800 let line2 = make_audit_line(
1801 "tool.approval.auto_approve",
1802 "read_file",
1803 "2026-04-02T10:00:00+00:00",
1804 );
1805 writeln!(tmp, "{line1}").unwrap();
1806 writeln!(tmp, "{line2}").unwrap();
1807
1808 let mut rollup = Rollup::default();
1809 read_audit_test_log(tmp.path(), None, &mut rollup);
1810
1811 assert_eq!(rollup.parsed_lines, 2);
1812 assert_eq!(rollup.tools["exec_shell"].calls, 1);
1813 assert_eq!(rollup.tools["exec_shell"].auto_approved, 1);
1814 assert_eq!(rollup.tools["read_file"].calls, 1);
1815 }
1816
1817 #[test]
1818 fn audit_log_skips_malformed_lines() {
1819 use std::io::Write;
1820 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1821 writeln!(tmp, "not json at all").unwrap();
1822 writeln!(
1823 tmp,
1824 r#"{{"event":"credential.save","ts":"2026-04-01T10:00:00+00:00"}}"#
1825 )
1826 .unwrap();
1827
1828 let mut rollup = Rollup::default();
1829 read_audit_test_log(tmp.path(), None, &mut rollup);
1830
1831 // 2 lines total, 1 malformed skipped, 1 parsed.
1832 assert_eq!(rollup.total_lines, 2);
1833 assert_eq!(rollup.parsed_lines, 1);
1834 assert_eq!(rollup.credentials.saves, 1);
1835 }
1836
1837 #[test]
1838 fn audit_log_since_filter() {
1839 use std::io::Write;
1840 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1841 let line_old = make_audit_line(
1842 "tool.approval.auto_approve",
1843 "exec_shell",
1844 "2025-01-01T00:00:00+00:00",
1845 );
1846 let line_new = make_audit_line(
1847 "tool.approval.auto_approve",
1848 "read_file",
1849 "2026-04-01T00:00:00+00:00",
1850 );
1851 writeln!(tmp, "{line_old}").unwrap();
1852 writeln!(tmp, "{line_new}").unwrap();
1853
1854 let cutoff: DateTime<Utc> = "2026-01-01T00:00:00Z".parse().unwrap();
1855 let mut rollup = Rollup::default();
1856 read_audit_test_log(tmp.path(), Some(cutoff), &mut rollup);
1857
1858 // Only the newer line should be counted.
1859 assert_eq!(rollup.parsed_lines, 1);
1860 assert!(!rollup.tools.contains_key("exec_shell"));
1861 assert_eq!(rollup.tools["read_file"].calls, 1);
1862 }
1863
1864 #[test]
1865 fn total_tool_calls_sums_across_tools() {
1866 let mut rollup = Rollup::default();
1867 rollup.tool_mut("read_file").calls = 4_012;
1868 rollup.tool_mut("exec_shell").calls = 1_118;
1869 assert_eq!(rollup.total_tool_calls(), 5_130);
1870 }
1871
1872 // ── Runtime request and provider-usage receipts ──
1873
1874 #[test]
1875 fn runtime_receipts_separate_terminal_requests_from_per_request_usage() {
1876 let timestamp = "2026-09-08T10:00:00Z";
1877 let terminal = runtime_event(
1878 2,
1879 timestamp,
1880 "thread-a",
1881 Some("turn-a"),
1882 "turn.completed",
1883 serde_json::json!({
1884 "turn": {
1885 "usage": { "input_tokens": 10_000, "output_tokens": 9_000 },
1886 "modelRequestDiagnostics": {
1887 "modelRequestsStarted": 2,
1888 "transparentStreamRetries": 1,
1889 "streamResumes": 1,
1890 },
1891 },
1892 }),
1893 );
1894 let usage_one = runtime_event(
1895 3,
1896 timestamp,
1897 "thread-a",
1898 Some("turn-a"),
1899 "turn.usage",
1900 serde_json::json!({ "usage": { "input_tokens": 7, "output_tokens": 2 } }),
1901 );
1902 let usage_two = runtime_event(
1903 4,
1904 timestamp,
1905 "thread-a",
1906 Some("turn-a"),
1907 "turn.usage",
1908 serde_json::json!({ "usage": { "input_tokens": 11, "output_tokens": 3 } }),
1909 );
1910 let duplicate_terminal = runtime_event(
1911 5,
1912 timestamp,
1913 "thread-a",
1914 Some("turn-a"),
1915 "turn.completed",
1916 serde_json::json!({
1917 "turn": {
1918 "modelRequestDiagnostics": {
1919 "modelRequestsStarted": 99,
1920 "transparentStreamRetries": 99,
1921 "streamResumes": 99,
1922 },
1923 },
1924 }),
1925 );
1926 let legacy_terminal = runtime_event(
1927 6,
1928 timestamp,
1929 "thread-a",
1930 Some("turn-b"),
1931 "turn.completed",
1932 serde_json::json!({ "turn": { "usage": { "input_tokens": 50, "output_tokens": 5 } } }),
1933 );
1934 let status = runtime_event(
1935 7,
1936 timestamp,
1937 "thread-a",
1938 Some("turn-a"),
1939 "item.completed",
1940 serde_json::json!({ "item": { "kind": "status" } }),
1941 );
1942 let tmp = write_runtime_events(&[
1943 terminal,
1944 usage_one,
1945 usage_two,
1946 duplicate_terminal,
1947 legacy_terminal,
1948 status,
1949 ]);
1950 let mut rollup = Rollup::default();
1951 read_runtime_test_log(tmp.path(), None, &mut rollup);
1952
1953 let runtime = &rollup.runtime_requests;
1954 assert_eq!(runtime.terminal_turn_receipts, 2);
1955 assert_eq!(runtime.diagnostics_turn_receipts, 1);
1956 assert_eq!(runtime.diagnostics_unavailable_turn_receipts, 1);
1957 assert_eq!(runtime.duplicate_terminal_receipts_skipped, 1);
1958 assert_eq!(runtime.model_requests_started, 2);
1959 assert_eq!(runtime.transparent_stream_retries, 1);
1960 assert_eq!(runtime.stream_resumes, 1);
1961 assert_eq!(runtime.provider_usage_receipts, 2);
1962 assert_eq!(runtime.provider_reported_input_tokens, 18);
1963 assert_eq!(runtime.provider_reported_output_tokens, 5);
1964 assert_ne!(runtime.provider_reported_input_tokens, 10_018);
1965 assert_eq!(runtime.model_requests_started, 2, "status is not a request");
1966 }
1967
1968 #[test]
1969 fn runtime_usage_receipts_deduplicate_by_runtime_event_identity() {
1970 let usage = runtime_event(
1971 20,
1972 "2026-09-08T10:00:00Z",
1973 "thread-a",
1974 Some("turn-a"),
1975 "turn.usage",
1976 serde_json::json!({ "usage": { "input_tokens": 7, "output_tokens": 2 } }),
1977 );
1978 let tmp = write_runtime_events(&[usage.clone(), usage]);
1979 let mut rollup = Rollup::default();
1980 read_runtime_test_log(tmp.path(), None, &mut rollup);
1981
1982 let runtime = &rollup.runtime_requests;
1983 assert_eq!(runtime.provider_usage_receipts, 1);
1984 assert_eq!(runtime.provider_reported_input_tokens, 7);
1985 assert_eq!(runtime.provider_reported_output_tokens, 2);
1986 assert_eq!(runtime.duplicate_provider_usage_receipts_skipped, 1);
1987 }
1988
1989 #[test]
1990 fn runtime_receipt_coverage_marks_unidentified_or_incomplete_old_records_unknown() {
1991 let terminal_without_identity = serde_json::json!({
1992 "timestamp": "2026-09-08T10:00:00Z",
1993 "event": "turn.completed",
1994 "payload": {
1995 "turn": {
1996 "modelRequestDiagnostics": {
1997 "modelRequestsStarted": 3,
1998 "transparentStreamRetries": 1,
1999 "streamResumes": 2,
2000 },
2001 },
2002 },
2003 });
2004 let usage_without_identity = serde_json::json!({
2005 "timestamp": "2026-09-08T10:00:00Z",
2006 "event": "turn.usage",
2007 "payload": { "usage": { "input_tokens": 9, "output_tokens": 4 } },
2008 });
2009 let incomplete_diagnostics = runtime_event(
2010 30,
2011 "2026-09-08T10:00:00Z",
2012 "thread-a",
2013 Some("turn-b"),
2014 "turn.completed",
2015 serde_json::json!({
2016 "turn": { "modelRequestDiagnostics": { "modelRequestsStarted": 3 } },
2017 }),
2018 );
2019 let incomplete_usage = runtime_event(
2020 31,
2021 "2026-09-08T10:00:00Z",
2022 "thread-a",
2023 Some("turn-b"),
2024 "turn.usage",
2025 serde_json::json!({ "usage": { "input_tokens": 9 } }),
2026 );
2027 let tmp = write_runtime_events(&[
2028 terminal_without_identity,
2029 usage_without_identity,
2030 incomplete_diagnostics,
2031 incomplete_usage,
2032 ]);
2033 let mut rollup = Rollup::default();
2034 read_runtime_test_log(tmp.path(), None, &mut rollup);
2035
2036 let runtime = &rollup.runtime_requests;
2037 assert_eq!(runtime.terminal_receipts_without_identity, 1);
2038 assert_eq!(runtime.terminal_turn_receipts, 1);
2039 assert_eq!(runtime.diagnostics_incomplete_turn_receipts, 1);
2040 assert_eq!(runtime.model_requests_started, 0);
2041 assert_eq!(runtime.provider_usage_receipts_without_identity, 1);
2042 assert_eq!(runtime.provider_usage_receipts_incomplete, 1);
2043 assert_eq!(runtime.provider_usage_receipts, 0);
2044 assert_eq!(runtime.provider_reported_input_tokens, 0);
2045 }
2046
2047 #[test]
2048 fn runtime_receipts_respect_since_cutoff_without_crossing_snapshot_boundaries() {
2049 let old_terminal = runtime_event(
2050 40,
2051 "2026-09-01T10:00:00Z",
2052 "thread-a",
2053 Some("turn-old"),
2054 "turn.completed",
2055 serde_json::json!({
2056 "turn": { "modelRequestDiagnostics": {
2057 "modelRequestsStarted": 4,
2058 "transparentStreamRetries": 1,
2059 "streamResumes": 2,
2060 } },
2061 }),
2062 );
2063 let old_usage = runtime_event(
2064 41,
2065 "2026-09-01T10:00:00Z",
2066 "thread-a",
2067 Some("turn-old"),
2068 "turn.usage",
2069 serde_json::json!({ "usage": { "input_tokens": 40, "output_tokens": 4 } }),
2070 );
2071 let new_terminal = runtime_event(
2072 42,
2073 "2026-09-08T10:00:00Z",
2074 "thread-a",
2075 Some("turn-new"),
2076 "turn.completed",
2077 serde_json::json!({
2078 "turn": { "modelRequestDiagnostics": {
2079 "modelRequestsStarted": 1,
2080 "transparentStreamRetries": 0,
2081 "streamResumes": 0,
2082 } },
2083 }),
2084 );
2085 let new_usage = runtime_event(
2086 43,
2087 "2026-09-08T10:00:00Z",
2088 "thread-a",
2089 Some("turn-new"),
2090 "turn.usage",
2091 serde_json::json!({ "usage": { "input_tokens": 10, "output_tokens": 1 } }),
2092 );
2093 let tmp = write_runtime_events(&[old_terminal, old_usage, new_terminal, new_usage]);
2094 let mut rollup = Rollup::default();
2095 read_runtime_test_log(
2096 tmp.path(),
2097 Some("2026-09-08T00:00:00Z".parse().unwrap()),
2098 &mut rollup,
2099 );
2100
2101 let runtime = &rollup.runtime_requests;
2102 assert_eq!(runtime.terminal_turn_receipts, 1);
2103 assert_eq!(runtime.model_requests_started, 1);
2104 assert_eq!(runtime.provider_usage_receipts, 1);
2105 assert_eq!(runtime.provider_reported_input_tokens, 10);
2106 assert_eq!(runtime.provider_reported_output_tokens, 1);
2107 }
2108
2109 // ── Durable runtime item receipts ──
2110 //
2111 // These pin *which* runtime event names carry tool and compaction data.
2112 // Before the fix this reader matched `tool.started` / `tool.completed` /
2113 // `tool.failed` and `compaction.completed`, none of which the Runtime
2114 // store has ever written, so every per-tool counter was structurally 0.
2115
2116 fn tool_item(kind: &str, tool_name: &str, extra: Value) -> Value {
2117 let mut item = serde_json::json!({
2118 "schema_version": 4,
2119 "id": "item_abc",
2120 "turn_id": "turn-a",
2121 "kind": kind,
2122 "status": "completed",
2123 "summary": "exec_shell: ok",
2124 "metadata": { "tool_use_id": "call-1", "tool_name": tool_name },
2125 "started_at": "2026-09-08T10:00:00Z",
2126 });
2127 merge_json(&mut item, extra);
2128 item
2129 }
2130
2131 fn merge_json(target: &mut Value, extra: Value) {
2132 let Value::Object(extra) = extra else { return };
2133 let Some(target) = target.as_object_mut() else {
2134 return;
2135 };
2136 for (key, value) in extra {
2137 let nested = matches!(value, Value::Object(_))
2138 && matches!(target.get(&key), Some(Value::Object(_)));
2139 if nested {
2140 merge_json(target.get_mut(&key).expect("checked above"), value);
2141 } else {
2142 target.insert(key, value);
2143 }
2144 }
2145 }
2146
2147 #[test]
2148 fn runtime_tool_receipts_come_from_durable_item_events() {
2149 let started = runtime_event(
2150 60,
2151 "2026-09-08T10:00:00Z",
2152 "thread-a",
2153 Some("turn-a"),
2154 "item.started",
2155 serde_json::json!({
2156 "item": tool_item("tool_call", "exec_shell", serde_json::json!({
2157 "status": "in_progress",
2158 "metadata": { "tool_input": "{}" },
2159 })),
2160 "tool": { "id": "call-1", "name": "exec_shell", "input": {} },
2161 }),
2162 );
2163 let completed = runtime_event(
2164 61,
2165 "2026-09-08T10:00:02Z",
2166 "thread-a",
2167 Some("turn-a"),
2168 "item.completed",
2169 serde_json::json!({
2170 "item": tool_item("tool_call", "exec_shell", serde_json::json!({
2171 "ended_at": "2026-09-08T10:00:02Z",
2172 "metadata": { "is_error": false },
2173 })),
2174 }),
2175 );
2176 let tmp = write_runtime_events(&[started, completed]);
2177 let mut rollup = Rollup::default();
2178 read_runtime_test_log(tmp.path(), None, &mut rollup);
2179
2180 let stats = &rollup.tools["exec_shell"];
2181 assert_eq!(stats.calls, 1);
2182 assert_eq!(stats.successes, 1);
2183 assert_eq!(stats.failures, 0);
2184 assert_eq!(stats.outcome_unknown, 0);
2185 assert_eq!(stats.elapsed_samples, 1);
2186 assert_eq!(stats.total_elapsed_ms, 2_000);
2187 assert_eq!(stats.elapsed_unavailable, 0);
2188 }
2189
2190 #[test]
2191 fn runtime_file_change_and_command_execution_items_count_as_tools() {
2192 // `tool_kind_for_name` splits one tool call across three item kinds;
2193 // dropping two of them would hide every shell and edit receipt.
2194 let events: Vec<_> = [
2195 ("file_change", "apply_patch"),
2196 ("command_execution", "exec_shell"),
2197 ]
2198 .into_iter()
2199 .enumerate()
2200 .map(|(i, (kind, name))| {
2201 runtime_event(
2202 70 + i as u64,
2203 "2026-09-08T10:00:00Z",
2204 "thread-a",
2205 Some("turn-a"),
2206 "item.completed",
2207 serde_json::json!({
2208 "item": tool_item(kind, name, serde_json::json!({
2209 "ended_at": "2026-09-08T10:00:01Z",
2210 "metadata": { "is_error": false },
2211 })),
2212 }),
2213 )
2214 })
2215 .collect();
2216 let tmp = write_runtime_events(&events);
2217 let mut rollup = Rollup::default();
2218 read_runtime_test_log(tmp.path(), None, &mut rollup);
2219
2220 assert_eq!(rollup.tools["apply_patch"].successes, 1);
2221 assert_eq!(rollup.tools["exec_shell"].successes, 1);
2222 assert_eq!(rollup.tools["exec_shell"].total_elapsed_ms, 1_000);
2223 }
2224
2225 #[test]
2226 fn runtime_tool_outcome_without_is_error_is_unknown_not_success() {
2227 let completed = runtime_event(
2228 80,
2229 "2026-09-08T10:00:00Z",
2230 "thread-a",
2231 Some("turn-a"),
2232 "item.completed",
2233 serde_json::json!({
2234 "item": tool_item("tool_call", "exec_shell", serde_json::json!({})),
2235 }),
2236 );
2237 let tmp = write_runtime_events(&[completed]);
2238 let mut rollup = Rollup::default();
2239 read_runtime_test_log(tmp.path(), None, &mut rollup);
2240
2241 let stats = &rollup.tools["exec_shell"];
2242 assert_eq!(stats.successes, 0);
2243 assert_eq!(stats.failures, 0, "unknown is never folded into failures");
2244 assert_eq!(stats.outcome_unknown, 1);
2245 assert_eq!(stats.success_rate_pct(), None);
2246 assert_eq!(
2247 stats.elapsed_unavailable, 1,
2248 "a missing ended_at is not a 0 ms call"
2249 );
2250 assert_eq!(stats.elapsed_samples, 0);
2251 assert_eq!(stats.avg_elapsed_ms(), None);
2252 }
2253
2254 #[test]
2255 fn sse_only_tool_event_names_are_not_durable_receipts() {
2256 // `tool.started` / `tool.completed` / `tool.failed` are synthesized by
2257 // `map_compat_stream_event` for HTTP clients and never persisted.
2258 let events: Vec<_> = ["tool.started", "tool.completed", "tool.failed"]
2259 .into_iter()
2260 .enumerate()
2261 .map(|(i, event)| {
2262 runtime_event(
2263 90 + i as u64,
2264 "2026-09-08T10:00:00Z",
2265 "thread-a",
2266 Some("turn-a"),
2267 event,
2268 serde_json::json!({ "tool_name": "exec_shell", "elapsed_ms": 5 }),
2269 )
2270 })
2271 .collect();
2272 let tmp = write_runtime_events(&events);
2273 let mut rollup = Rollup::default();
2274 read_runtime_test_log(tmp.path(), None, &mut rollup);
2275
2276 assert_eq!(rollup.total_tool_calls(), 0);
2277 assert!(rollup.tools.is_empty());
2278 }
2279
2280 #[test]
2281 fn duplicate_item_receipts_are_counted_once() {
2282 let completed = runtime_event(
2283 100,
2284 "2026-09-08T10:00:00Z",
2285 "thread-a",
2286 Some("turn-a"),
2287 "item.completed",
2288 serde_json::json!({
2289 "item": tool_item("tool_call", "exec_shell", serde_json::json!({
2290 "ended_at": "2026-09-08T10:00:01Z",
2291 "metadata": { "is_error": false },
2292 })),
2293 }),
2294 );
2295 let tmp = write_runtime_events(&[completed.clone(), completed]);
2296 let mut rollup = Rollup::default();
2297 read_runtime_test_log(tmp.path(), None, &mut rollup);
2298
2299 let stats = &rollup.tools["exec_shell"];
2300 assert_eq!(stats.successes, 1);
2301 assert_eq!(stats.elapsed_samples, 1);
2302 assert_eq!(stats.total_elapsed_ms, 1_000);
2303 }
2304
2305 #[test]
2306 fn compaction_reduction_is_computed_from_message_counts() {
2307 let completed = runtime_event(
2308 110,
2309 "2026-09-08T10:00:00Z",
2310 "thread-a",
2311 Some("turn-a"),
2312 "item.completed",
2313 serde_json::json!({
2314 "item": { "kind": "context_compaction", "status": "completed" },
2315 "auto": true,
2316 "messages_before": 40,
2317 "messages_after": 10,
2318 }),
2319 );
2320 let tmp = write_runtime_events(&[completed]);
2321 let mut rollup = Rollup::default();
2322 read_runtime_test_log(tmp.path(), None, &mut rollup);
2323
2324 assert_eq!(rollup.compaction.events, 1);
2325 assert_eq!(rollup.compaction.ratio_samples, 1);
2326 assert_eq!(rollup.compaction.avg_reduction_pct(), Some(75.0));
2327 }
2328
2329 #[test]
2330 fn compaction_without_message_counts_stays_unknown() {
2331 let completed = runtime_event(
2332 120,
2333 "2026-09-08T10:00:00Z",
2334 "thread-a",
2335 Some("turn-a"),
2336 "item.completed",
2337 serde_json::json!({
2338 "item": { "kind": "context_compaction", "status": "completed" },
2339 "auto": true,
2340 }),
2341 );
2342 let tmp = write_runtime_events(&[completed]);
2343 let mut rollup = Rollup::default();
2344 read_runtime_test_log(tmp.path(), None, &mut rollup);
2345
2346 assert_eq!(rollup.compaction.events, 1);
2347 assert_eq!(rollup.compaction.ratio_samples, 0);
2348 assert_eq!(
2349 rollup.compaction.avg_reduction_pct(),
2350 None,
2351 "no counts is unknown, never a 0% reduction"
2352 );
2353 }
2354
2355 // ── Approval receipts ──
2356
2357 #[test]
2358 fn session_auto_approvals_are_counted_under_their_emitted_name() {
2359 let events = [
2360 serde_json::json!({
2361 "ts": "2026-09-08T10:00:00Z",
2362 "event": "tool.approval.auto_approve_session",
2363 "details": { "tool_name": "exec_shell" },
2364 }),
2365 serde_json::json!({
2366 "ts": "2026-09-08T10:00:01Z",
2367 "event": "tool.approval.auto_approve",
2368 "details": { "tool_name": "exec_shell" },
2369 }),
2370 ];
2371 let tmp = write_runtime_events(&events);
2372 let mut rollup = Rollup::default();
2373 read_audit_test_log(tmp.path(), None, &mut rollup);
2374
2375 let stats = &rollup.tools["exec_shell"];
2376 assert_eq!(stats.auto_approved, 2, "emitted name plus legacy alias");
2377 assert_eq!(stats.calls, 2);
2378 }
2379
2380 #[test]
2381 fn tool_denials_are_a_class_of_their_own() {
2382 let events: Vec<_> = [
2383 "tool.approval.auto_deny",
2384 "tool.approval.auto_deny_session",
2385 "tool.approval.auto_deny_auto_review",
2386 "tool.approval.auto_deny_full_access_policy",
2387 ]
2388 .into_iter()
2389 .map(|event| {
2390 serde_json::json!({
2391 "ts": "2026-09-08T10:00:00Z",
2392 "event": event,
2393 "details": { "tool_name": "exec_shell" },
2394 })
2395 })
2396 .collect();
2397 let tmp = write_runtime_events(&events);
2398 let mut rollup = Rollup::default();
2399 read_audit_test_log(tmp.path(), None, &mut rollup);
2400
2401 let stats = &rollup.tools["exec_shell"];
2402 assert_eq!(stats.denied, 4);
2403 assert_eq!(stats.calls, 4);
2404 assert_eq!(stats.successes, 0);
2405 assert_eq!(stats.failures, 0);
2406 assert_eq!(
2407 stats.success_rate_pct(),
2408 None,
2409 "a blocked call is not a judged outcome"
2410 );
2411 }
2412
2413 #[test]
2414 fn opt_in_tool_audit_records_resolve_their_top_level_tool_name() {
2415 // `emit_tool_audit` writes `tool_name` at the top level, not under
2416 // `details`, so pointing `--since` at that file used to bucket every
2417 // record as "unknown" and grade an absent outcome as a success.
2418 let events = [
2419 serde_json::json!({
2420 "event": "tool.result",
2421 "tool_id": "call-1",
2422 "tool_name": "exec_shell",
2423 "success": false,
2424 }),
2425 serde_json::json!({
2426 "event": "tool.result",
2427 "tool_id": "call-2",
2428 "tool_name": "exec_shell",
2429 }),
2430 ];
2431 let tmp = write_runtime_events(&events);
2432 let mut rollup = Rollup::default();
2433 read_audit_test_log(tmp.path(), None, &mut rollup);
2434
2435 let stats = &rollup.tools["exec_shell"];
2436 assert_eq!(stats.calls, 2);
2437 assert_eq!(stats.failures, 1);
2438 assert_eq!(stats.successes, 0);
2439 assert_eq!(stats.outcome_unknown, 1);
2440 assert!(!rollup.tools.contains_key("unknown"));
2441 }
2442
2443 #[test]
2444 fn rollup_json_exposes_the_new_tool_classes() {
2445 let mut rollup = Rollup::default();
2446 let stats = rollup.tool_mut("exec_shell");
2447 stats.denied = 2;
2448 stats.outcome_unknown = 1;
2449 stats.elapsed_unavailable = 3;
2450 let json = serde_json::to_value(&rollup).unwrap();
2451 assert_eq!(json["tools"]["exec_shell"]["denied"], 2);
2452 assert_eq!(json["tools"]["exec_shell"]["outcome_unknown"], 1);
2453 assert_eq!(json["tools"]["exec_shell"]["elapsed_unavailable"], 3);
2454 // Existing keys keep their names and positions for JSON consumers.
2455 assert_eq!(json["tools"]["exec_shell"]["calls"], 0);
2456 assert_eq!(json["tools"]["exec_shell"]["successes"], 0);
2457 assert_eq!(json["tools"]["exec_shell"]["failures"], 0);
2458 }
2459
2460 // ── Runtime store roots ──
2461
2462 #[test]
2463 fn every_runtime_store_root_is_read_including_session_scoped_stores() {
2464 let dir = tempfile::TempDir::new().unwrap();
2465 let tasks = dir.path().join("tasks");
2466 let sessions = dir.path().join("sessions");
2467 std::fs::create_dir_all(sessions.join("sess-1").join("runtime").join("events")).unwrap();
2468 std::fs::create_dir_all(&tasks).unwrap();
2469 std::fs::write(sessions.join("loose.json"), "{}").unwrap();
2470
2471 let _lock = crate::tests::env_lock();
2472 let _override = crate::tests::ScopedEnvVar::remove("CODEWHALE_RUNTIME_DIR");
2473 let _legacy = crate::tests::ScopedEnvVar::remove("DEEPSEEK_RUNTIME_DIR");
2474 assert_eq!(
2475 runtime_event_dirs(&tasks, &sessions),
2476 vec![
2477 tasks.join("runtime").join("events"),
2478 sessions.join("sess-1").join("runtime").join("events"),
2479 ],
2480 "a loose session file is not a store root"
2481 );
2482 }
2483
2484 #[test]
2485 fn an_explicit_runtime_dir_override_is_the_only_root_read() {
2486 let dir = tempfile::TempDir::new().unwrap();
2487 let override_dir = dir.path().join("elsewhere");
2488 let _lock = crate::tests::env_lock();
2489 let _override = crate::tests::ScopedEnvVar::set(
2490 "CODEWHALE_RUNTIME_DIR",
2491 &override_dir.to_string_lossy(),
2492 );
2493 assert_eq!(
2494 runtime_event_dirs(&dir.path().join("tasks"), &dir.path().join("sessions")),
2495 vec![override_dir.join("events")],
2496 "mixing an override with the default roots would double count"
2497 );
2498 }
2499
2500 // ── State-root resolution ──
2501 //
2502 // These pin *which* files the rollup reads. Before the fix the reader
2503 // resolved `$HOME/.deepseek`, which nothing has written since the v0.8.44
2504 // rename, so `codewhale metrics` printed an all-zero rollup as truth.
2505
2506 /// Isolate the ambient home so the resolver sees a clean, empty install.
2507 ///
2508 /// The returned guards must stay bound for the life of the test: dropping
2509 /// them restores the previous environment. Destructure the tuple so the
2510 /// bindings drop in reverse order — the environment is restored *before*
2511 /// the lock is released, or a concurrent env-mutating test sees a torn HOME.
2512 fn isolated_home() -> (
2513 tempfile::TempDir,
2514 std::sync::MutexGuard<'static, ()>,
2515 Vec<crate::tests::ScopedEnvVar>,
2516 ) {
2517 let guard = crate::tests::env_lock();
2518 let home = tempfile::TempDir::new().expect("tempdir");
2519 let vars = vec![
2520 crate::tests::ScopedEnvVar::set("HOME", &home.path().to_string_lossy()),
2521 crate::tests::ScopedEnvVar::set("USERPROFILE", &home.path().to_string_lossy()),
2522 crate::tests::ScopedEnvVar::remove("CODEWHALE_HOME"),
2523 crate::tests::ScopedEnvVar::remove("DEEPSEEK_HOME"),
2524 ];
2525 (home, guard, vars)
2526 }
2527
2528 #[test]
2529 fn default_audit_history_includes_both_roots_without_requiring_existing_files() {
2530 let (home, _lock, _env) = isolated_home();
2531 assert_eq!(
2532 resolve_audit_roots().expect("resolves"),
2533 vec![
2534 home.path().join(".codewhale"),
2535 home.path().join(".deepseek")
2536 ],
2537 );
2538 }
2539
2540 #[test]
2541 fn copied_audit_history_keeps_unique_legacy_and_rotated_records() {
2542 let dir = tempfile::TempDir::new().expect("tempdir");
2543 let primary = dir.path().join("primary");
2544 let legacy = dir.path().join("legacy");
2545 std::fs::create_dir_all(&primary).unwrap();
2546 std::fs::create_dir_all(&legacy).unwrap();
2547 let shared = r#"{"ts":"2026-09-01T00:00:00Z","event":"credential.save","details":{}}"#;
2548 let old = r#"{"ts":"2026-08-01T00:00:00Z","event":"credential.clear","details":{}}"#;
2549 let new = r#"{"ts":"2026-09-02T00:00:00Z","event":"credential.save","details":{}}"#;
2550 std::fs::write(primary.join("audit.log.1"), format!("{shared}\n{shared}\n")).unwrap();
2551 std::fs::write(primary.join("audit.log"), format!("{new}\nmalformed\n")).unwrap();
2552 std::fs::write(
2553 legacy.join("audit.log"),
2554 format!("{shared}\n{shared}\n{shared}\n"),
2555 )
2556 .unwrap();
2557 std::fs::write(legacy.join("audit.log.1"), format!("{old}\n")).unwrap();
2558 let roots = [primary, legacy];
2559 let before: Vec<_> = roots
2560 .iter()
2561 .flat_map(|root| {
2562 ["audit.log.1", "audit.log"].map(|name| {
2563 let path = root.join(name);
2564 (path.clone(), std::fs::read(path).unwrap())
2565 })
2566 })
2567 .collect();
2568 let mut rollup = Rollup::default();
2569 read_audit_history(&roots, None, &mut rollup);
2570 assert_eq!(
2571 rollup.credentials.saves, 4,
2572 "maximum occurrence count across copied roots"
2573 );
2574 assert_eq!(
2575 rollup.credentials.clears, 1,
2576 "unique old rotation is retained"
2577 );
2578 assert_eq!(rollup.parsed_lines, 5);
2579 for (path, bytes) in before {
2580 assert_eq!(
2581 std::fs::read(path).unwrap(),
2582 bytes,
2583 "source history is read-only"
2584 );
2585 }
2586 let mut recent = Rollup::default();
2587 read_audit_history(
2588 &roots,
2589 Some("2026-09-01T00:00:00Z".parse().unwrap()),
2590 &mut recent,
2591 );
2592 assert_eq!(recent.credentials.saves, 4);
2593 assert_eq!(recent.credentials.clears, 0);
2594 }
2595
2596 #[test]
2597 fn an_explicit_codewhale_home_is_an_audit_isolation_boundary() {
2598 let (home, _lock, _env) = isolated_home();
2599 let legacy = home.path().join(".deepseek");
2600 std::fs::create_dir_all(&legacy).unwrap();
2601 std::fs::write(legacy.join("audit.log"), r#"{"event":"credential.save"}"#).unwrap();
2602 let explicit = tempfile::TempDir::new().unwrap();
2603 let _pin =
2604 crate::tests::ScopedEnvVar::set("CODEWHALE_HOME", &explicit.path().to_string_lossy());
2605 let roots = resolve_audit_roots().unwrap();
2606 assert_eq!(roots, vec![explicit.path().to_path_buf()]);
2607 let mut rollup = Rollup::default();
2608 read_audit_history(&roots, None, &mut rollup);
2609 assert_eq!(rollup.parsed_lines, 0);
2610 }
2611
2612 #[test]
2613 fn the_legacy_deepseek_home_variable_is_no_longer_honoured() {
2614 let (home, _lock, _env) = isolated_home();
2615 let stale = tempfile::TempDir::new().unwrap();
2616 let _stale =
2617 crate::tests::ScopedEnvVar::set("DEEPSEEK_HOME", &stale.path().to_string_lossy());
2618 assert_eq!(
2619 resolve_audit_roots().unwrap(),
2620 vec![
2621 home.path().join(".codewhale"),
2622 home.path().join(".deepseek")
2623 ],
2624 );
2625 }
2626 }
2627
2627 lines RUST