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