Skip to main content

winx_code_agent/
report.rs

1//! Offline aggregation for privacy-safe `winx::usage` JSONL logs.
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::fs::{self, File};
5use std::io::{BufRead as _, BufReader};
6use std::path::{Path, PathBuf};
7
8use serde::Serialize;
9use serde_json::Value;
10use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
11
12use crate::errors::{Result, WinxError};
13
14const MAX_REPORT_EVENTS: usize = 100_000;
15
16#[derive(Debug, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct UsageReport {
19    pub base_path: String,
20    pub source_files: usize,
21    pub lines_seen: u64,
22    pub invalid_lines: u64,
23    pub events: usize,
24    pub first_timestamp: Option<String>,
25    pub last_timestamp: Option<String>,
26    pub tool_calls: usize,
27    pub http_requests: usize,
28    pub correlated_requests: usize,
29    pub tool_calls_without_http: usize,
30    pub http_without_tool_call: usize,
31    pub duplicate_tool_request_ids: usize,
32    pub http_statuses: BTreeMap<String, u64>,
33    pub result_statuses: BTreeMap<String, u64>,
34    pub error_codes: BTreeMap<String, u64>,
35    pub command_kinds: BTreeMap<String, u64>,
36    pub build_identities: BTreeMap<String, u64>,
37    pub workspace_coherence: BTreeMap<String, u64>,
38    pub tools: BTreeMap<String, ToolUsage>,
39    pub latency: LatencySummary,
40    pub http_overhead: LatencySummary,
41    pub response_bytes: u64,
42    pub initialize: InitializeUsage,
43    pub recovery: RecoveryUsage,
44    pub execution: ExecutionUsage,
45    pub trace_audit: TraceAudit,
46}
47
48#[derive(Debug, Default, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct ToolUsage {
51    pub calls: u64,
52    pub error_results: u64,
53    pub response_bytes: u64,
54    pub latency: LatencySummary,
55}
56
57#[derive(Clone, Debug, Default, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct LatencySummary {
60    pub samples: usize,
61    pub average_ms: u64,
62    pub p50_ms: u64,
63    pub p95_ms: u64,
64    pub max_ms: u64,
65}
66
67/// Call-level execution observations, including polls and idempotent replays.
68/// Unknown metadata must never be counted as exit code zero or complete output.
69#[derive(Debug, Default, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ExecutionUsage {
72    pub observations: u64,
73    pub exit_codes: BTreeMap<i64, u64>,
74    pub exit_code_unknown: u64,
75    pub output_truncated: u64,
76    pub output_not_truncated: u64,
77    pub output_truncation_unknown: u64,
78    pub calls_with_execution_id: u64,
79    pub calls_with_background_id: u64,
80    pub calls_with_verification_id: u64,
81    pub calls_with_mutation_receipt_id: u64,
82    pub verification_replays: u64,
83    pub requested_wait_policies: BTreeMap<String, u64>,
84    pub requested_wait_policy_unknown: u64,
85    pub requested_wait_ms: LatencySummary,
86}
87
88#[derive(Debug, Default, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct InitializeUsage {
91    pub calls: u64,
92    pub reused: u64,
93    pub response_bytes: u64,
94    pub transitions: BTreeMap<String, u64>,
95}
96
97#[derive(Debug, Default, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct RecoveryUsage {
100    pub next_actions: u64,
101    pub required_reads: u64,
102    pub repeated_attempts: u64,
103    pub escalated: u64,
104    pub mutation_receipts_persisted: u64,
105}
106
107#[derive(Debug, Default, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct TraceAudit {
110    pub healthy: bool,
111    pub violations: Vec<TraceViolation>,
112    pub workspace_coherence_rejections: u64,
113    pub audited_tool_events: u64,
114    pub legacy_unverifiable_tool_events: u64,
115}
116
117#[derive(Debug, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct TraceViolation {
120    pub request_id: String,
121    pub rule: &'static str,
122}
123
124#[derive(Debug)]
125struct UsageEvent {
126    timestamp: Option<String>,
127    fields: serde_json::Map<String, Value>,
128}
129
130#[derive(Default)]
131struct ToolAccumulator {
132    calls: u64,
133    error_results: u64,
134    response_bytes: u64,
135    durations: Vec<u64>,
136}
137
138/// Aggregate recent usage events. `since_minutes=0` disables the time filter;
139/// `build_filter` matches either the exact display identity or package version.
140pub fn usage_report(
141    path: Option<&Path>,
142    last_events: usize,
143    since_minutes: u64,
144    build_filter: Option<&str>,
145) -> Result<UsageReport> {
146    if last_events == 0 || last_events > MAX_REPORT_EVENTS {
147        return Err(WinxError::InvalidInput(format!(
148            "--last must be between 1 and {MAX_REPORT_EVENTS}"
149        )));
150    }
151    let base = path.map_or_else(default_usage_path, Path::to_path_buf);
152    let sources = usage_sources(&base)?;
153    let cutoff = (since_minutes > 0)
154        .then(|| OffsetDateTime::now_utc() - Duration::minutes(i64_saturating(since_minutes)));
155    let mut recent = VecDeque::with_capacity(last_events.min(4096));
156    let mut lines_seen = 0_u64;
157    let mut invalid_lines = 0_u64;
158    for source in &sources {
159        let reader =
160            BufReader::new(File::open(source).map_err(|error| WinxError::FileAccessError {
161                path: source.clone(),
162                message: format!("opening usage log: {error}"),
163            })?);
164        for line in reader.lines() {
165            lines_seen = lines_seen.saturating_add(1);
166            let Ok(line) = line else {
167                invalid_lines = invalid_lines.saturating_add(1);
168                continue;
169            };
170            let Some(event) = parse_event(&line) else {
171                invalid_lines = invalid_lines.saturating_add(1);
172                continue;
173            };
174            if cutoff.is_some_and(|cutoff| {
175                event
176                    .timestamp
177                    .as_deref()
178                    .and_then(parse_timestamp)
179                    .is_some_and(|timestamp| timestamp < cutoff)
180            }) {
181                continue;
182            }
183            if build_filter.is_some_and(|filter| {
184                ![string(&event.fields, "build"), string(&event.fields, "build_version")]
185                    .contains(&filter)
186            }) {
187                continue;
188            }
189            if recent.len() == last_events {
190                recent.pop_front();
191            }
192            recent.push_back(event);
193        }
194    }
195    Ok(aggregate(&base, sources.len(), lines_seen, invalid_lines, &recent))
196}
197
198#[derive(Default)]
199struct ReportAccumulator {
200    result_statuses: BTreeMap<String, u64>,
201    error_codes: BTreeMap<String, u64>,
202    command_kinds: BTreeMap<String, u64>,
203    build_identities: BTreeMap<String, u64>,
204    workspace_coherence: BTreeMap<String, u64>,
205    http_statuses: BTreeMap<String, u64>,
206    tools: BTreeMap<String, ToolAccumulator>,
207    initialize: InitializeUsage,
208    recovery: RecoveryUsage,
209    execution: ExecutionUsage,
210    requested_wait_samples: Vec<u64>,
211    trace_audit: TraceAudit,
212    all_durations: Vec<u64>,
213    tool_request_ids: BTreeSet<String>,
214    duplicate_tool_request_ids: usize,
215    http_request_ids: BTreeSet<String>,
216    tool_durations: BTreeMap<String, u64>,
217    http_durations: BTreeMap<String, u64>,
218    tool_calls: usize,
219    http_requests: usize,
220    response_bytes: u64,
221}
222
223impl ReportAccumulator {
224    fn record(&mut self, fields: &serde_json::Map<String, Value>) {
225        match string(fields, "event") {
226            "tool_call" => self.record_tool(fields),
227            "http_request" => self.record_http(fields),
228            _ => {}
229        }
230    }
231
232    fn record_tool(&mut self, fields: &serde_json::Map<String, Value>) {
233        self.tool_calls += 1;
234        let tool = string(fields, "tool").to_string();
235        increment_nonempty(&mut self.result_statuses, string(fields, "result_status"));
236        increment_nonempty(&mut self.error_codes, string(fields, "error_code"));
237        increment_nonempty(&mut self.command_kinds, string(fields, "command_kind"));
238        increment_nonempty(&mut self.build_identities, string(fields, "build"));
239        increment_nonempty(&mut self.workspace_coherence, string(fields, "workspace_coherence"));
240        let bytes = number(fields, "response_bytes");
241        let duration = number(fields, "duration_ms");
242        self.response_bytes = self.response_bytes.saturating_add(bytes);
243        self.all_durations.push(duration);
244
245        let accumulator = self.tools.entry(tool.clone()).or_default();
246        accumulator.calls = accumulator.calls.saturating_add(1);
247        accumulator.error_results = accumulator
248            .error_results
249            .saturating_add(u64::from(!string(fields, "error_code").is_empty()));
250        accumulator.response_bytes = accumulator.response_bytes.saturating_add(bytes);
251        accumulator.durations.push(duration);
252
253        let request_id = string(fields, "request_id");
254        if !request_id.is_empty() {
255            if !self.tool_request_ids.insert(request_id.to_string()) {
256                self.duplicate_tool_request_ids += 1;
257            }
258            self.tool_durations.insert(request_id.to_string(), duration);
259        }
260        if tool == "Initialize" {
261            self.initialize.calls = self.initialize.calls.saturating_add(1);
262            self.initialize.response_bytes = self.initialize.response_bytes.saturating_add(bytes);
263            self.initialize.reused = self
264                .initialize
265                .reused
266                .saturating_add(u64::from(boolean(fields, "initialize_reused")));
267            increment_nonempty(
268                &mut self.initialize.transitions,
269                string(fields, "initialize_transition"),
270            );
271        }
272        self.record_recovery(fields);
273        self.record_execution(fields);
274        audit_tool_event(fields, &mut self.trace_audit);
275    }
276
277    fn record_execution(&mut self, fields: &serde_json::Map<String, Value>) {
278        let tool = string(fields, "tool");
279        let edit_verification = tool == "EditFiles"
280            && (!string(fields, "verification_status").is_empty()
281                || !string(fields, "verification_id").is_empty());
282        if !matches!(tool, "BashCommand" | "VerifyEdit") && !edit_verification {
283            return;
284        }
285        let execution = &mut self.execution;
286        execution.observations = execution.observations.saturating_add(1);
287        match fields.get("exit_code").and_then(Value::as_i64) {
288            Some(code) => {
289                let count = execution.exit_codes.entry(code).or_default();
290                *count = count.saturating_add(1);
291            }
292            None => execution.exit_code_unknown = execution.exit_code_unknown.saturating_add(1),
293        }
294        match fields.get("output_truncated").and_then(Value::as_bool) {
295            Some(true) => execution.output_truncated = execution.output_truncated.saturating_add(1),
296            Some(false) => {
297                execution.output_not_truncated = execution.output_not_truncated.saturating_add(1);
298            }
299            None => {
300                execution.output_truncation_unknown =
301                    execution.output_truncation_unknown.saturating_add(1);
302            }
303        }
304        execution.calls_with_execution_id = execution
305            .calls_with_execution_id
306            .saturating_add(u64::from(!string(fields, "execution_id").is_empty()));
307        execution.calls_with_background_id = execution
308            .calls_with_background_id
309            .saturating_add(u64::from(!string(fields, "bg_command_id").is_empty()));
310        execution.calls_with_verification_id = execution
311            .calls_with_verification_id
312            .saturating_add(u64::from(!string(fields, "verification_id").is_empty()));
313        execution.calls_with_mutation_receipt_id = execution
314            .calls_with_mutation_receipt_id
315            .saturating_add(u64::from(!string(fields, "mutation_receipt_id").is_empty()));
316        if fields.get("verification_replayed").and_then(Value::as_bool) == Some(true) {
317            execution.verification_replays = execution.verification_replays.saturating_add(1);
318        }
319        match fields.get("requested_wait_policy").and_then(Value::as_str) {
320            Some(policy @ ("adaptive" | "until_complete" | "return_early")) => {
321                increment_nonempty(&mut execution.requested_wait_policies, policy);
322            }
323            _ => {
324                execution.requested_wait_policy_unknown =
325                    execution.requested_wait_policy_unknown.saturating_add(1);
326            }
327        }
328        if let Some(wait) = fields.get("requested_wait_ms").and_then(Value::as_u64) {
329            self.requested_wait_samples.push(wait);
330        }
331    }
332
333    fn record_recovery(&mut self, fields: &serde_json::Map<String, Value>) {
334        self.recovery.next_actions = self
335            .recovery
336            .next_actions
337            .saturating_add(u64::from(!string(fields, "next_action_tool").is_empty()));
338        self.recovery.required_reads =
339            self.recovery.required_reads.saturating_add(number(fields, "required_read_count"));
340        self.recovery.repeated_attempts = self
341            .recovery
342            .repeated_attempts
343            .saturating_add(u64::from(number(fields, "recovery_attempt") > 1));
344        self.recovery.escalated = self
345            .recovery
346            .escalated
347            .saturating_add(u64::from(string(fields, "recovery_level") == "escalated"));
348        self.recovery.mutation_receipts_persisted = self
349            .recovery
350            .mutation_receipts_persisted
351            .saturating_add(u64::from(string(fields, "mutation_receipt_state") == "persisted"));
352    }
353
354    fn record_http(&mut self, fields: &serde_json::Map<String, Value>) {
355        self.http_requests += 1;
356        let status = fields.get("status").map(value_text).unwrap_or_default();
357        increment_nonempty(&mut self.http_statuses, &status);
358        let request_id = string(fields, "request_id");
359        if !request_id.is_empty() {
360            self.http_request_ids.insert(request_id.to_string());
361            self.http_durations.insert(request_id.to_string(), number(fields, "duration_ms"));
362        }
363    }
364
365    fn finish(mut self, window: ReportWindow<'_>) -> UsageReport {
366        self.execution.requested_wait_ms = latency(self.requested_wait_samples);
367        let correlated_requests =
368            self.tool_request_ids.intersection(&self.http_request_ids).count();
369        let overhead = self
370            .tool_durations
371            .iter()
372            .filter_map(|(id, tool)| {
373                self.http_durations.get(id).map(|http| http.saturating_sub(*tool))
374            })
375            .collect::<Vec<_>>();
376        let tools = self
377            .tools
378            .into_iter()
379            .map(|(name, accumulator)| {
380                (
381                    name,
382                    ToolUsage {
383                        calls: accumulator.calls,
384                        error_results: accumulator.error_results,
385                        response_bytes: accumulator.response_bytes,
386                        latency: latency(accumulator.durations),
387                    },
388                )
389            })
390            .collect();
391        self.trace_audit.healthy = self.trace_audit.violations.is_empty();
392
393        UsageReport {
394            base_path: window.base.to_string_lossy().into_owned(),
395            source_files: window.source_files,
396            lines_seen: window.lines_seen,
397            invalid_lines: window.invalid_lines,
398            events: window.events,
399            first_timestamp: window.first_timestamp,
400            last_timestamp: window.last_timestamp,
401            tool_calls: self.tool_calls,
402            http_requests: self.http_requests,
403            correlated_requests,
404            tool_calls_without_http: self
405                .tool_request_ids
406                .len()
407                .saturating_sub(correlated_requests),
408            http_without_tool_call: self.http_request_ids.len().saturating_sub(correlated_requests),
409            duplicate_tool_request_ids: self.duplicate_tool_request_ids,
410            http_statuses: self.http_statuses,
411            result_statuses: self.result_statuses,
412            error_codes: self.error_codes,
413            command_kinds: self.command_kinds,
414            build_identities: self.build_identities,
415            workspace_coherence: self.workspace_coherence,
416            tools,
417            latency: latency(self.all_durations),
418            http_overhead: latency(overhead),
419            response_bytes: self.response_bytes,
420            initialize: self.initialize,
421            recovery: self.recovery,
422            execution: self.execution,
423            trace_audit: self.trace_audit,
424        }
425    }
426}
427
428struct ReportWindow<'a> {
429    base: &'a Path,
430    source_files: usize,
431    lines_seen: u64,
432    invalid_lines: u64,
433    events: usize,
434    first_timestamp: Option<String>,
435    last_timestamp: Option<String>,
436}
437
438fn aggregate(
439    base: &Path,
440    source_files: usize,
441    lines_seen: u64,
442    invalid_lines: u64,
443    events: &VecDeque<UsageEvent>,
444) -> UsageReport {
445    let mut accumulator = ReportAccumulator::default();
446    for event in events {
447        accumulator.record(&event.fields);
448    }
449    accumulator.finish(ReportWindow {
450        base,
451        source_files,
452        lines_seen,
453        invalid_lines,
454        events: events.len(),
455        first_timestamp: events.front().and_then(|event| event.timestamp.clone()),
456        last_timestamp: events.back().and_then(|event| event.timestamp.clone()),
457    })
458}
459
460fn audit_tool_event(fields: &serde_json::Map<String, Value>, audit: &mut TraceAudit) {
461    if matches!(
462        string(fields, "workspace_coherence"),
463        "mismatch" | "rejected" | "binding_mismatch" | "thread_mismatch"
464    ) {
465        audit.workspace_coherence_rejections =
466            audit.workspace_coherence_rejections.saturating_add(1);
467    }
468    if number(fields, "usage_schema") == 0 {
469        // Older logs did not record the recovery booleans/actions needed to
470        // prove these invariants. Treat them as unknown, never as violations.
471        audit.legacy_unverifiable_tool_events =
472            audit.legacy_unverifiable_tool_events.saturating_add(1);
473        return;
474    }
475    audit.audited_tool_events = audit.audited_tool_events.saturating_add(1);
476    let request_id = string(fields, "request_id");
477    let mut violation = |rule| {
478        if audit.violations.len() < 100 {
479            audit.violations.push(TraceViolation { request_id: request_id.to_string(), rule });
480        }
481    };
482    if boolean(fields, "retry_same_call") {
483        violation("retry_same_call_must_be_false");
484    }
485    if boolean(fields, "edit_applied") && boolean(fields, "retry_same_call") {
486        violation("committed_edit_must_never_be_retried");
487    }
488    if string(fields, "result_status") == "needs_read"
489        && string(fields, "next_action_tool") != "ReadFiles"
490    {
491        violation("needs_read_must_point_to_read_files");
492    }
493    if matches!(string(fields, "error_code"), "search_block_not_found" | "search_block_ambiguous")
494        && !boolean(fields, "fresh_read_required")
495    {
496        violation("search_conflict_must_invalidate_read_evidence");
497    }
498}
499
500fn usage_sources(base: &Path) -> Result<Vec<PathBuf>> {
501    let parent = base.parent().unwrap_or_else(|| Path::new("."));
502    let name = base.file_name().and_then(|name| name.to_str()).ok_or_else(|| {
503        WinxError::InvalidInput(format!("usage log path is not UTF-8: {}", base.display()))
504    })?;
505    let prefix = format!("{name}.");
506    let mut sources = fs::read_dir(parent)
507        .map_err(|error| WinxError::FileAccessError {
508            path: parent.to_path_buf(),
509            message: format!("listing usage logs: {error}"),
510        })?
511        .filter_map(std::result::Result::ok)
512        .filter_map(|entry| {
513            let entry_name = entry.file_name();
514            let entry_name = entry_name.to_str()?;
515            let metadata = entry.metadata().ok()?;
516            (metadata.is_file() && (entry_name == name || entry_name.starts_with(&prefix)))
517                .then_some(entry.path())
518        })
519        .collect::<Vec<_>>();
520    sources.sort();
521    if sources.is_empty() {
522        return Err(WinxError::FileNotFound { path: base.to_path_buf() });
523    }
524    Ok(sources)
525}
526
527fn default_usage_path() -> PathBuf {
528    std::env::var_os("WINX_USAGE_LOG").map_or_else(
529        || {
530            home::home_dir()
531                .unwrap_or_else(|| PathBuf::from("."))
532                .join(".local/state/winx/usage.jsonl")
533        },
534        PathBuf::from,
535    )
536}
537
538fn parse_event(line: &str) -> Option<UsageEvent> {
539    let value: Value = serde_json::from_str(line).ok()?;
540    (value.get("target").and_then(Value::as_str) == Some("winx::usage")).then_some(())?;
541    Some(UsageEvent {
542        timestamp: value.get("timestamp").and_then(Value::as_str).map(ToString::to_string),
543        fields: value.get("fields")?.as_object()?.clone(),
544    })
545}
546
547fn parse_timestamp(value: &str) -> Option<OffsetDateTime> {
548    OffsetDateTime::parse(value, &Rfc3339).ok()
549}
550
551fn latency(mut samples: Vec<u64>) -> LatencySummary {
552    if samples.is_empty() {
553        return LatencySummary::default();
554    }
555    samples.sort_unstable();
556    let sum = samples.iter().fold(0_u128, |sum, value| sum.saturating_add(u128::from(*value)));
557    let average = sum / samples.len() as u128;
558    LatencySummary {
559        samples: samples.len(),
560        average_ms: u64::try_from(average).unwrap_or(u64::MAX),
561        p50_ms: percentile(&samples, 50),
562        p95_ms: percentile(&samples, 95),
563        max_ms: samples.last().copied().unwrap_or_default(),
564    }
565}
566
567fn percentile(sorted: &[u64], percentile: usize) -> u64 {
568    let index = sorted.len().saturating_mul(percentile).div_ceil(100).saturating_sub(1);
569    sorted[index.min(sorted.len() - 1)]
570}
571
572fn increment_nonempty(counts: &mut BTreeMap<String, u64>, value: &str) {
573    if !value.is_empty() {
574        counts.entry(value.to_string()).and_modify(|count| *count += 1).or_insert(1);
575    }
576}
577
578fn string<'a>(fields: &'a serde_json::Map<String, Value>, key: &str) -> &'a str {
579    fields.get(key).and_then(Value::as_str).unwrap_or_default()
580}
581
582fn number(fields: &serde_json::Map<String, Value>, key: &str) -> u64 {
583    fields.get(key).and_then(Value::as_u64).unwrap_or_default()
584}
585
586fn boolean(fields: &serde_json::Map<String, Value>, key: &str) -> bool {
587    fields.get(key).and_then(Value::as_bool).unwrap_or_default()
588}
589
590fn value_text(value: &Value) -> String {
591    value.as_str().map_or_else(|| value.to_string(), ToString::to_string)
592}
593
594fn i64_saturating(value: u64) -> i64 {
595    i64::try_from(value).unwrap_or(i64::MAX)
596}
597
598#[cfg(test)]
599mod tests {
600    use std::io::Write as _;
601
602    use super::*;
603
604    #[test]
605    fn aggregates_correlated_trace_without_reading_sensitive_arguments() -> Result<()> {
606        let temp = tempfile::tempdir()?;
607        let base = temp.path().join("usage.jsonl");
608        let path = temp.path().join("usage.jsonl.2026-08-25");
609        let mut file = File::create(path)?;
610        writeln!(
611            file,
612            "{}",
613            serde_json::json!({
614                "timestamp": "2026-08-25T12:00:00Z",
615                "target": "winx::usage",
616                "fields": {
617                    "event":"tool_call", "usage_schema":1,
618                    "tool":"BashCommand", "request_id":"req",
619                    "result_status":"completed", "duration_ms":10, "response_bytes":20,
620                    "command_kind":"rust_toolchain", "build":"0.2.343+abc"
621                }
622            })
623        )?;
624        writeln!(
625            file,
626            "{}",
627            serde_json::json!({
628                "timestamp": "2026-08-25T12:00:00Z",
629                "target": "winx::usage",
630                "fields": {
631                    "event":"http_request", "request_id":"req", "status":200,
632                    "duration_ms":12
633                }
634            })
635        )?;
636        let report = usage_report(Some(&base), 100, 0, None)?;
637        assert_eq!(report.tool_calls, 1);
638        assert_eq!(report.http_requests, 1);
639        assert_eq!(report.correlated_requests, 1);
640        assert_eq!(report.http_overhead.p95_ms, 2);
641        assert_eq!(report.command_kinds["rust_toolchain"], 1);
642        Ok(())
643    }
644
645    #[test]
646    fn legacy_missing_recovery_fields_are_unknown_not_violations() -> Result<()> {
647        let temp = tempfile::tempdir()?;
648        let base = temp.path().join("usage.jsonl");
649        let mut file = File::create(&base)?;
650        writeln!(
651            file,
652            "{}",
653            serde_json::json!({
654                "timestamp": "2026-08-25T12:00:00Z",
655                "target": "winx::usage",
656                "fields": {
657                    "event": "tool_call",
658                    "tool": "MultiFileEdit",
659                    "request_id": "legacy",
660                    "result_status": "conflict",
661                    "error_code": "search_block_not_found"
662                }
663            })
664        )?;
665
666        let report = usage_report(Some(&base), 100, 0, None)?;
667        assert!(report.trace_audit.healthy);
668        assert_eq!(report.trace_audit.audited_tool_events, 0);
669        assert_eq!(report.trace_audit.legacy_unverifiable_tool_events, 1);
670        assert!(report.trace_audit.violations.is_empty());
671        Ok(())
672    }
673
674    fn report_execution_fields(fields: &[Value]) -> Result<UsageReport> {
675        let temp = tempfile::tempdir()?;
676        let base = temp.path().join("usage.jsonl");
677        let mut file = File::create(&base)?;
678        for (index, fields) in fields.iter().enumerate() {
679            let mut fields = fields
680                .as_object()
681                .ok_or_else(|| {
682                    WinxError::SerializationError("expected object fixture".to_string())
683                })?
684                .clone();
685            fields.insert("event".into(), serde_json::json!("tool_call"));
686            fields.insert("request_id".into(), serde_json::json!(format!("request-{index}")));
687            fields.entry("usage_schema").or_insert(serde_json::json!(1));
688            writeln!(
689                file,
690                "{}",
691                serde_json::json!({
692                    "timestamp": "2026-10-27T12:00:00Z",
693                    "target": "winx::usage",
694                    "fields": fields,
695                })
696            )?;
697        }
698        usage_report(Some(&base), 100, 0, None)
699    }
700
701    #[test]
702    fn execution_summary_keeps_v1_metadata_unknown() -> Result<()> {
703        let report = report_execution_fields(&[
704            serde_json::json!({"tool":"BashCommand", "result_status":"completed", "outcome":"ok"}),
705            serde_json::json!({"tool":"ReadFiles", "result_status":"completed"}),
706            serde_json::json!({"tool":"EditFiles", "result_status":"completed"}),
707        ])?;
708        assert!(report.trace_audit.healthy);
709        let value = serde_json::to_value(report)
710            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
711        let execution = &value["execution"];
712        assert_eq!(execution["observations"], 1);
713        assert_eq!(execution["exitCodeUnknown"], 1);
714        assert_eq!(execution["exitCodes"], serde_json::json!({}));
715        assert_eq!(execution["outputTruncationUnknown"], 1);
716        assert_eq!(execution["outputNotTruncated"], 0);
717        assert_eq!(execution["requestedWaitMs"]["samples"], 0);
718        Ok(())
719    }
720
721    #[test]
722    fn execution_summary_preserves_typed_exit_codes_and_truncation() -> Result<()> {
723        let report = report_execution_fields(&[
724            serde_json::json!({"tool":"BashCommand", "exit_code":0, "output_truncated":false}),
725            serde_json::json!({"tool":"BashCommand", "exit_code":7, "output_truncated":true}),
726            serde_json::json!({"tool":"BashCommand", "exit_code":-1, "output_truncated":false}),
727            serde_json::json!({"tool":"BashCommand", "exit_code":null, "output_truncated":null}),
728            serde_json::json!({"tool":"BashCommand", "exit_code":"0", "output_truncated":"false"}),
729        ])?;
730        let value = serde_json::to_value(report)
731            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
732        let execution = &value["execution"];
733        assert_eq!(execution["exitCodes"], serde_json::json!({"-1":1, "0":1, "7":1}));
734        assert_eq!(execution["exitCodeUnknown"], 2);
735        assert_eq!(execution["outputTruncated"], 1);
736        assert_eq!(execution["outputNotTruncated"], 2);
737        assert_eq!(execution["outputTruncationUnknown"], 2);
738        Ok(())
739    }
740
741    #[test]
742    fn execution_summary_includes_flattened_edit_verification() -> Result<()> {
743        let report = report_execution_fields(&[serde_json::json!({
744            "tool":"EditFiles", "result_status":"completed_with_issues",
745            "verification_status":"failed", "error_code":"verification_failed",
746            "exit_code":3, "output_truncated":false,
747            "execution_id":"exec-fingerprint", "verification_id":"verify-fingerprint",
748            "mutation_receipt_id":"receipt-fingerprint"
749        })])?;
750        assert_eq!(report.tools["EditFiles"].error_results, 1);
751        let value = serde_json::to_value(report)
752            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
753        let execution = &value["execution"];
754        assert_eq!(execution["observations"], 1);
755        assert_eq!(execution["exitCodes"], serde_json::json!({"3":1}));
756        assert_eq!(execution["callsWithExecutionId"], 1);
757        assert_eq!(execution["callsWithVerificationId"], 1);
758        assert_eq!(execution["callsWithMutationReceiptId"], 1);
759        Ok(())
760    }
761
762    #[test]
763    fn execution_summary_replay_does_not_invent_zero_exit() -> Result<()> {
764        let report = report_execution_fields(&[serde_json::json!({
765            "tool":"VerifyEdit", "result_status":"completed", "outcome":"ok",
766            "verification_id":"verify-fingerprint", "verification_passed":true,
767            "verification_replayed":true
768        })])?;
769        let value = serde_json::to_value(report)
770            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
771        let execution = &value["execution"];
772        assert_eq!(execution["observations"], 1);
773        assert_eq!(execution["verificationReplays"], 1);
774        assert_eq!(execution["exitCodeUnknown"], 1);
775        assert_eq!(execution["exitCodes"], serde_json::json!({}));
776        Ok(())
777    }
778
779    #[test]
780    fn execution_summary_counts_polls_as_observations() -> Result<()> {
781        let report = report_execution_fields(&[
782            serde_json::json!({"tool":"BashCommand", "action":"command", "result_status":"running", "execution_id":"same-execution", "bg_command_id":"same-background"}),
783            serde_json::json!({"tool":"BashCommand", "action":"status_check", "result_status":"running", "execution_id":"same-execution", "bg_command_id":"same-background"}),
784            serde_json::json!({"tool":"BashCommand", "action":"status_check", "result_status":"completed", "execution_id":"same-execution", "bg_command_id":"same-background", "exit_code":0}),
785        ])?;
786        let value = serde_json::to_value(report)
787            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
788        let execution = &value["execution"];
789        assert_eq!(execution["observations"], 3);
790        assert_eq!(execution["callsWithExecutionId"], 3);
791        assert_eq!(execution["callsWithBackgroundId"], 3);
792        assert_eq!(execution["exitCodes"], serde_json::json!({"0":1}));
793        assert_eq!(execution["exitCodeUnknown"], 2);
794        Ok(())
795    }
796
797    #[test]
798    fn execution_summary_records_requested_wait_without_sensitive_content() -> Result<()> {
799        let report = report_execution_fields(&[
800            serde_json::json!({
801                "tool":"BashCommand", "requested_wait_policy":"return_early", "requested_wait_ms":0,
802                "duration_ms":17, "arguments":{"command":"SECRET-COMMAND"}, "output":"SECRET-OUTPUT"
803            }),
804            serde_json::json!({"tool":"BashCommand", "requested_wait_policy":"until_complete", "requested_wait_ms":5000, "duration_ms":17}),
805            serde_json::json!({"tool":"BashCommand", "requested_wait_policy":"SECRET-POLICY", "requested_wait_ms":"5000", "duration_ms":17}),
806        ])?;
807        assert_eq!(report.latency.max_ms, 17);
808        let value = serde_json::to_value(report)
809            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
810        let execution = &value["execution"];
811        assert_eq!(
812            execution["requestedWaitPolicies"],
813            serde_json::json!({"return_early":1,"until_complete":1})
814        );
815        assert_eq!(execution["requestedWaitPolicyUnknown"], 1);
816        assert_eq!(execution["requestedWaitMs"]["samples"], 2);
817        assert_eq!(execution["requestedWaitMs"]["averageMs"], 2500);
818        assert_eq!(execution["requestedWaitMs"]["maxMs"], 5000);
819        let serialized = value.to_string();
820        assert!(!serialized.contains("SECRET-COMMAND"));
821        assert!(!serialized.contains("SECRET-OUTPUT"));
822        assert!(!serialized.contains("SECRET-POLICY"));
823        Ok(())
824    }
825}