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