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