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