Files
codex/codex-rs/tui/src/auto_review_denials.rs
rhan-oai 99016ec732 [codex-analytics] plumb protocol-native review timing (#21434)
## Why

We want terminal tool review analytics, but the reducer should not stamp
review timing from its own wall clock.

This PR plumbs review timing through the real protocol and app-server
seams so downstream analytics can consume the emitter's timestamps
directly. Guardian reviews keep their enriched `started_at` /
`completed_at` analytics fields by deriving those legacy second-based
values from the same protocol-native millisecond lifecycle timestamps,
rather than sampling a separate analytics clock.

## What changed

- add `started_at_ms` to user approval request payloads
- add `started_at_ms` / `completed_at_ms` to guardian review
notifications
- preserve Guardian review `started_at` / `completed_at` enrichment from
the protocol-native timing source
- stamp typed `ServerResponse` analytics facts with app-server-observed
`completed_at_ms`
- thread the new timing fields through core, protocol, app-server, TUI,
and analytics fixtures

## Verification

- `cargo test -p codex-app-server outgoing_message --manifest-path
codex-rs/Cargo.toml`
- `cargo test -p codex-app-server-protocol guardian --manifest-path
codex-rs/Cargo.toml`
- `cargo test -p codex-tui guardian --manifest-path codex-rs/Cargo.toml`
- `cargo test -p codex-analytics analytics_client_tests --manifest-path
codex-rs/Cargo.toml`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/21434).
* #18748
* __->__ #21434
* #18747
* #17090
* #17089
* #20514
2026-05-07 20:31:41 -07:00

134 lines
4.2 KiB
Rust

use std::collections::VecDeque;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::approvals::GuardianAssessmentStatus;
const MAX_RECENT_DENIALS: usize = 10;
#[derive(Debug, Default)]
pub(crate) struct RecentAutoReviewDenials {
entries: VecDeque<GuardianAssessmentEvent>,
}
impl RecentAutoReviewDenials {
pub(crate) fn push(&mut self, event: GuardianAssessmentEvent) {
if event.status != GuardianAssessmentStatus::Denied {
return;
}
self.entries.retain(|entry| entry.id != event.id);
self.entries.push_front(event);
self.entries.truncate(MAX_RECENT_DENIALS);
}
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn entries(&self) -> impl Iterator<Item = &GuardianAssessmentEvent> {
self.entries.iter()
}
pub(crate) fn take(&mut self, id: &str) -> Option<GuardianAssessmentEvent> {
let idx = self.entries.iter().position(|entry| entry.id == id)?;
self.entries.remove(idx)
}
}
pub(crate) fn action_summary(action: &GuardianAssessmentAction) -> String {
match action {
GuardianAssessmentAction::Command { command, .. } => command.clone(),
GuardianAssessmentAction::Execve { program, argv, .. } => {
let command = if argv.is_empty() {
vec![program.clone()]
} else {
argv.clone()
};
shlex::try_join(command.iter().map(String::as_str))
.unwrap_or_else(|_| command.join(" "))
}
GuardianAssessmentAction::ApplyPatch { files, .. } => {
if files.len() == 1 {
format!("apply_patch touching {}", files[0].display())
} else {
format!("apply_patch touching {} files", files.len())
}
}
GuardianAssessmentAction::NetworkAccess { target, .. } => {
format!("network access to {target}")
}
GuardianAssessmentAction::McpToolCall {
server,
tool_name,
connector_name,
..
} => {
let label = connector_name.as_deref().unwrap_or(server.as_str());
format!("MCP {tool_name} on {label}")
}
GuardianAssessmentAction::RequestPermissions { reason, .. } => reason
.as_deref()
.map(|reason| format!("permission request: {reason}"))
.unwrap_or_else(|| "permission request".to_string()),
}
}
#[cfg(test)]
mod tests {
use codex_protocol::approvals::GuardianCommandSource;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use super::*;
fn denied_event(id: usize) -> GuardianAssessmentEvent {
GuardianAssessmentEvent {
id: format!("review-{id}"),
target_item_id: None,
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Denied,
risk_level: None,
user_authorization: None,
rationale: Some(format!("rationale {id}")),
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: format!("rm -rf /tmp/test-{id}"),
cwd: test_path_buf("/tmp").abs(),
},
}
}
#[test]
fn keeps_only_ten_most_recent_denials() {
let mut denials = RecentAutoReviewDenials::default();
for id in 0..12 {
denials.push(denied_event(id));
}
let ids = denials
.entries()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
ids,
vec![
"review-11",
"review-10",
"review-9",
"review-8",
"review-7",
"review-6",
"review-5",
"review-4",
"review-3",
"review-2",
]
);
}
}