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