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 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#[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
153pub 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 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}