1use 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#[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
146pub 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 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}