| 1 | //! Session receipts: what a session or turn actually did, read back from the |
| 2 | //! records Codewhale already persists. |
| 3 | //! |
| 4 | //! There is one builder. It reads two persisted shapes and nothing else: |
| 5 | //! |
| 6 | //! - a terminal session: the saved transcript (`sessions/<id>.json`, whose |
| 7 | //! `tool_use`/`tool_result` blocks are the calls) plus the session's |
| 8 | //! approval log (`sessions/<id>/approval_receipts.jsonl`); |
| 9 | //! - a Runtime thread (the app, `codewhale serve`): the thread's turn and item |
| 10 | //! records plus the `approval.*` events in its append-only event log. |
| 11 | //! |
| 12 | //! A terminal session also reads the workspace snapshots the engine already |
| 13 | //! takes before and after each turn (`crate::snapshot`, when snapshots are |
| 14 | //! on): their difference is every file the turn changed, including files a |
| 15 | //! shell command changed, which no tool record names. |
| 16 | //! |
| 17 | //! Both are normalized into [`ToolStep`]s and [`ApprovalStep`]s and then |
| 18 | //! classified by the same code, so `/receipts`, `codewhale receipts`, and |
| 19 | //! `GET /v1/threads/{id}/receipt` cannot disagree about what happened. |
| 20 | //! |
| 21 | //! The builder only reads. It never calls a provider, runs a tool, or writes |
| 22 | //! a file (reading the snapshots runs `git diff` inside the side repo, which |
| 23 | //! touches neither the work tree nor the user's repository). It exports no reasoning text and no raw tool output: commands, |
| 24 | //! queries, and error lines are bounded and passed through the shared secret |
| 25 | //! redactor. A fact the record does not hold is reported as not recorded, |
| 26 | //! never inferred from display text (see `docs/RECEIPTS.md`). |
| 27 | //! |
| 28 | //! Known limits: a Runtime thread's engine does not tag its snapshots with |
| 29 | //! the thread, so a thread receipt cannot read them and says that shell file |
| 30 | //! changes are not itemized. A snapshot difference covers everything that |
| 31 | //! wrote to the workspace during the turn, not only this agent. Snapshots are |
| 32 | //! pruned to the newest [`crate::snapshot::DEFAULT_MAX_SNAPSHOTS`], so older |
| 33 | //! turns have none. |
| 34 | |
| 35 | use std::collections::{BTreeSet, HashMap}; |
| 36 | |
| 37 | use chrono::{DateTime, Utc}; |
| 38 | use codewhale_execpolicy::ApprovalMode; |
| 39 | use codewhale_models::{ContentBlock, Message}; |
| 40 | use serde_json::Value; |
| 41 | |
| 42 | use crate::approval_log::{ApprovalOutcome, ApprovalReceipt, ApprovalReplay}; |
| 43 | use crate::runtime_threads::{ |
| 44 | RuntimeEventRecord, RuntimeTurnStatus, ThreadRecord, TurnItemKind, TurnItemLifecycleStatus, |
| 45 | TurnItemRecord, TurnRecord, |
| 46 | }; |
| 47 | |
| 48 | const MAX_COMMAND_CHARS: usize = 200; |
| 49 | const MAX_ERROR_CHARS: usize = 160; |
| 50 | const MAX_QUERY_CHARS: usize = 120; |
| 51 | const MAX_FILES_PER_ACTION: usize = 50; |
| 52 | const MAX_NESTED_CALLS: usize = 20; |
| 53 | |
| 54 | /// Terminal sessions have kept an approval log since 0.9.10 (commit |
| 55 | /// 11717b48ff, released 2026-08-20). A session that started earlier has no |
| 56 | /// record of which calls asked first, so its receipt does not count calls that |
| 57 | /// "ran without asking". |
| 58 | const APPROVAL_LOG_SINCE: &str = "2026-08-20T00:00:00Z"; |
| 59 | |
| 60 | const CLAIM_CEILING: [&str; 3] = [ |
| 61 | "local_record_only", |
| 62 | "not_safety_certification", |
| 63 | "not_provider_compatibility_certification", |
| 64 | ]; |
| 65 | |
| 66 | // --------------------------------------------------------------------------- |
| 67 | // Output shape |
| 68 | // --------------------------------------------------------------------------- |
| 69 | |
| 70 | pub use codewhale_command_contract::facets::debug_receipts::*; |
| 71 | |
| 72 | // --------------------------------------------------------------------------- |
| 73 | // Normalized input |
| 74 | // --------------------------------------------------------------------------- |
| 75 | |
| 76 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 77 | enum StepOutcome { |
| 78 | Ok, |
| 79 | Failed, |
| 80 | Interrupted, |
| 81 | Running, |
| 82 | Unknown, |
| 83 | } |
| 84 | |
| 85 | /// One tool call as a persisted record holds it. |
| 86 | #[derive(Debug, Clone)] |
| 87 | struct ToolStep { |
| 88 | turn: Option<String>, |
| 89 | call_id: Option<String>, |
| 90 | name: String, |
| 91 | input: Value, |
| 92 | outcome: StepOutcome, |
| 93 | output: Option<String>, |
| 94 | metadata: Option<Value>, |
| 95 | started_at: Option<DateTime<Utc>>, |
| 96 | ended_at: Option<DateTime<Utc>>, |
| 97 | } |
| 98 | |
| 99 | /// A turn and the permission posture its own record names, if any. |
| 100 | type TurnPosture = (String, Option<&'static str>); |
| 101 | |
| 102 | /// A turn and the user's prompt text, if it had any. |
| 103 | type TurnPrompt = (String, Option<String>); |
| 104 | |
| 105 | /// Each matched turn's workspace change, and how many turns had no pair. |
| 106 | type TurnChanges = (Vec<(String, TurnWorkspaceChange)>, usize); |
| 107 | |
| 108 | /// Files a turn changed, from its before/after workspace snapshots. |
| 109 | #[derive(Debug, Clone, Default)] |
| 110 | struct TurnWorkspaceChange { |
| 111 | files: Vec<FileTouch>, |
| 112 | /// More paths changed than [`MAX_FILES_PER_ACTION`]. |
| 113 | truncated: bool, |
| 114 | } |
| 115 | |
| 116 | #[derive(Debug, Clone)] |
| 117 | struct ApprovalStep { |
| 118 | turn: Option<String>, |
| 119 | call_id: Option<String>, |
| 120 | tool: String, |
| 121 | fact: ApprovalFact, |
| 122 | } |
| 123 | |
| 124 | // --------------------------------------------------------------------------- |
| 125 | // Entry points |
| 126 | // --------------------------------------------------------------------------- |
| 127 | |
| 128 | /// Receipt for a terminal session: its transcript plus its approval log. |
| 129 | /// `turn` is a 1-based turn number; `None` covers the whole session. |
| 130 | pub(crate) fn session_receipt( |
| 131 | source: ReceiptSource, |
| 132 | messages: &[Message], |
| 133 | approval_receipts: &[ApprovalReceipt], |
| 134 | turn: Option<&str>, |
| 135 | ) -> anyhow::Result<Receipt> { |
| 136 | let mut notes = BTreeSet::new(); |
| 137 | let (steps, turn_postures, turn_prompts) = steps_from_messages(messages); |
| 138 | let approvals = match ApprovalReplay::from_receipts(approval_receipts) { |
| 139 | Ok(replay) => approvals_from_replay(&replay), |
| 140 | Err(error) => { |
| 141 | notes.insert(format!( |
| 142 | "Approvals: the session's approval log did not replay ({error}), so approvals are left out." |
| 143 | )); |
| 144 | Vec::new() |
| 145 | } |
| 146 | }; |
| 147 | if let Some(turn) = turn { |
| 148 | let count = turn_postures.len(); |
| 149 | if !turn |
| 150 | .parse::<usize>() |
| 151 | .is_ok_and(|number| (1..=count).contains(&number)) |
| 152 | { |
| 153 | anyhow::bail!( |
| 154 | "turn '{turn}' is not a turn in this session; it has {}", |
| 155 | plural(count, "turn", "turns") |
| 156 | ); |
| 157 | } |
| 158 | } |
| 159 | notes.insert( |
| 160 | "Timestamps: a terminal session saves calls in order, not when each one ran.".to_string(), |
| 161 | ); |
| 162 | let log_since = DateTime::parse_from_rfc3339(APPROVAL_LOG_SINCE) |
| 163 | .map(|at| at.with_timezone(&Utc)) |
| 164 | .ok(); |
| 165 | let approvals_recorded = match (source.started_at, log_since) { |
| 166 | (Some(started), Some(since)) => started >= since, |
| 167 | _ => true, |
| 168 | }; |
| 169 | if !approvals_recorded { |
| 170 | notes.insert( |
| 171 | "Approvals: this session started before Codewhale kept an approval log (0.9.10, 2026-08-20), so it cannot show which calls asked first.".to_string(), |
| 172 | ); |
| 173 | } |
| 174 | let snapshot_changes = match source.workspace.as_deref() { |
| 175 | Some(workspace) => snapshot_turn_changes( |
| 176 | std::path::Path::new(workspace), |
| 177 | &source.id, |
| 178 | &turn_prompts, |
| 179 | turn, |
| 180 | ), |
| 181 | None => Ok(None), |
| 182 | }; |
| 183 | let workspace_changes = match snapshot_changes { |
| 184 | Ok(Some((changes, unmatched))) => { |
| 185 | notes.insert( |
| 186 | "Files changed outside file tools come from the workspace snapshots taken before and after each turn, so they include anything that wrote to the workspace during the turn, not only Codewhale. They leave out what snapshots do not track: ignored and skipped paths (.gitignore entries, .env, node_modules, target, and the like) and anything outside the workspace.".to_string(), |
| 187 | ); |
| 188 | if unmatched > 0 { |
| 189 | notes.insert(format!( |
| 190 | "Shell file changes: {} without a before/after snapshot (snapshots off, or pruned: the newest {} are kept; or a repeated prompt whose snapshots could not be told apart), so files a command changed there are not itemized.", |
| 191 | plural(unmatched, "turn", "turns"), |
| 192 | crate::snapshot::DEFAULT_MAX_SNAPSHOTS |
| 193 | )); |
| 194 | } |
| 195 | changes |
| 196 | } |
| 197 | Ok(None) => { |
| 198 | notes.insert( |
| 199 | "Shell file changes: this workspace has no snapshots (snapshots are off, or the workspace is too large for them), so files a command changed are not itemized; only file tools are.".to_string(), |
| 200 | ); |
| 201 | Vec::new() |
| 202 | } |
| 203 | Err(error) => { |
| 204 | notes.insert(format!( |
| 205 | "Shell file changes: the workspace snapshots could not be read ({}), so files a command changed are not itemized; only file tools are.", |
| 206 | bounded(&error, MAX_ERROR_CHARS) |
| 207 | )); |
| 208 | Vec::new() |
| 209 | } |
| 210 | }; |
| 211 | Ok(assemble( |
| 212 | source, |
| 213 | steps, |
| 214 | approvals, |
| 215 | Vec::new(), |
| 216 | workspace_changes, |
| 217 | Assembly { |
| 218 | turn, |
| 219 | kind: SourceKind::Session, |
| 220 | turn_postures, |
| 221 | approvals_recorded, |
| 222 | }, |
| 223 | notes, |
| 224 | )) |
| 225 | } |
| 226 | |
| 227 | /// Receipt for a Runtime thread from its snapshot and event log. `turn` is a |
| 228 | /// turn id of this thread; `None` covers every turn. |
| 229 | pub(crate) fn thread_receipt( |
| 230 | thread: &ThreadRecord, |
| 231 | turns: &[TurnRecord], |
| 232 | items: &[TurnItemRecord], |
| 233 | events: &[RuntimeEventRecord], |
| 234 | turn: Option<&str>, |
| 235 | ) -> anyhow::Result<Receipt> { |
| 236 | if let Some(turn) = turn |
| 237 | && !turns.iter().any(|record| record.id == turn) |
| 238 | { |
| 239 | anyhow::bail!("turn '{turn}' does not belong to thread '{}'", thread.id); |
| 240 | } |
| 241 | let mut ordered: Vec<&TurnRecord> = turns.iter().collect(); |
| 242 | ordered.sort_by_key(|record| record.created_at); |
| 243 | let items_by_id: HashMap<&str, &TurnItemRecord> = |
| 244 | items.iter().map(|item| (item.id.as_str(), item)).collect(); |
| 245 | let mut steps = Vec::new(); |
| 246 | let mut failures = Vec::new(); |
| 247 | let mut turn_postures: Vec<TurnPosture> = Vec::new(); |
| 248 | for record in &ordered { |
| 249 | turn_postures.push(( |
| 250 | record.id.clone(), |
| 251 | record.permission_posture.as_deref().and_then(posture_label), |
| 252 | )); |
| 253 | let mut turn_items: Vec<&TurnItemRecord> = record |
| 254 | .item_ids |
| 255 | .iter() |
| 256 | .filter_map(|id| items_by_id.get(id.as_str()).copied()) |
| 257 | .collect(); |
| 258 | // Items a turn record does not list yet (a live turn) still belong |
| 259 | // to it by their own turn id. |
| 260 | for item in items { |
| 261 | if item.turn_id == record.id && !record.item_ids.contains(&item.id) { |
| 262 | turn_items.push(item); |
| 263 | } |
| 264 | } |
| 265 | for item in turn_items { |
| 266 | if let Some(step) = step_from_item(item) { |
| 267 | steps.push(step); |
| 268 | } |
| 269 | } |
| 270 | if record.status == RuntimeTurnStatus::Failed { |
| 271 | failures.push((record.id.clone(), record.ended_at, record.error.clone())); |
| 272 | } |
| 273 | } |
| 274 | let approvals = approvals_from_events(events); |
| 275 | let source = ReceiptSource { |
| 276 | kind: SourceKind::Thread, |
| 277 | id: thread.id.clone(), |
| 278 | title: thread.title.clone().or_else(|| { |
| 279 | ordered |
| 280 | .first() |
| 281 | .map(|record| record.input_summary.clone()) |
| 282 | .filter(|text| !text.trim().is_empty()) |
| 283 | }), |
| 284 | workspace: Some(thread.workspace.display().to_string()), |
| 285 | model: Some(thread.model.clone()), |
| 286 | started_at: Some(thread.created_at), |
| 287 | updated_at: Some(thread.updated_at), |
| 288 | }; |
| 289 | let notes = BTreeSet::from([ |
| 290 | "Shell file changes: a Runtime thread's workspace snapshots are not tagged with the thread, so files a command changed are not itemized; only file tools are.".to_string(), |
| 291 | ]); |
| 292 | Ok(assemble( |
| 293 | source, |
| 294 | steps, |
| 295 | approvals, |
| 296 | failures, |
| 297 | Vec::new(), |
| 298 | Assembly { |
| 299 | turn, |
| 300 | kind: SourceKind::Thread, |
| 301 | turn_postures, |
| 302 | approvals_recorded: true, |
| 303 | }, |
| 304 | notes, |
| 305 | )) |
| 306 | } |
| 307 | |
| 308 | // --------------------------------------------------------------------------- |
| 309 | // Loading from disk (`codewhale receipts`) |
| 310 | // --------------------------------------------------------------------------- |
| 311 | |
| 312 | #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] |
| 313 | pub enum ReceiptFormat { |
| 314 | Md, |
| 315 | Json, |
| 316 | } |
| 317 | |
| 318 | /// Receipt for a Runtime thread read straight from its store. |
| 319 | pub(crate) fn thread_receipt_from_store( |
| 320 | store: &crate::runtime_threads::RuntimeThreadStore, |
| 321 | thread_id: &str, |
| 322 | turn: Option<&str>, |
| 323 | ) -> anyhow::Result<Receipt> { |
| 324 | let thread = store.load_thread(thread_id)?; |
| 325 | let turns = store.list_turns_for_thread(thread_id)?; |
| 326 | let turn_ids: Vec<String> = turns.iter().map(|record| record.id.clone()).collect(); |
| 327 | let items: Vec<TurnItemRecord> = store |
| 328 | .list_items_for_turns_map(&turn_ids)? |
| 329 | .into_values() |
| 330 | .flatten() |
| 331 | .collect(); |
| 332 | let events: Vec<RuntimeEventRecord> = store |
| 333 | .events_since(thread_id, None)? |
| 334 | .into_iter() |
| 335 | .filter(|event| event.event.starts_with("approval.")) |
| 336 | .collect(); |
| 337 | thread_receipt(&thread, &turns, &items, &events, turn) |
| 338 | } |
| 339 | |
| 340 | pub(crate) fn session_source(metadata: &crate::session_manager::SessionMetadata) -> ReceiptSource { |
| 341 | ReceiptSource { |
| 342 | kind: SourceKind::Session, |
| 343 | id: metadata.id.clone(), |
| 344 | title: Some(metadata.title.clone()).filter(|title| !title.trim().is_empty()), |
| 345 | workspace: Some(metadata.workspace.display().to_string()), |
| 346 | model: Some(metadata.model.clone()).filter(|model| !model.is_empty()), |
| 347 | started_at: Some(metadata.created_at), |
| 348 | updated_at: Some(metadata.updated_at), |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | /// `codewhale receipts [ID|--last] [--turn T] [--format md|json]`. |
| 353 | /// |
| 354 | /// `ID` is a saved session id (or unique prefix) or a Runtime thread id |
| 355 | /// (`thr_…`). With neither an id nor `--last`, the most recently updated |
| 356 | /// session or thread is used. Reads only. |
| 357 | pub(crate) fn run_receipts_command( |
| 358 | id: Option<&str>, |
| 359 | turn: Option<&str>, |
| 360 | format: ReceiptFormat, |
| 361 | ) -> anyhow::Result<()> { |
| 362 | use anyhow::Context as _; |
| 363 | let sessions = crate::session_manager::SessionManager::default_location() |
| 364 | .context("could not open the saved sessions directory")?; |
| 365 | let runtime_root = crate::runtime_threads::RuntimeThreadManagerConfig::from_task_data_dir( |
| 366 | crate::task_manager::default_tasks_dir(), |
| 367 | ) |
| 368 | .data_dir; |
| 369 | let store = crate::runtime_threads::RuntimeThreadStore::open_read_only(runtime_root)?; |
| 370 | |
| 371 | let receipt = match id.map(str::trim).filter(|id| !id.is_empty()) { |
| 372 | Some(id) if id.starts_with("thr_") => { |
| 373 | let store = store.context("no Runtime thread store exists on this machine")?; |
| 374 | thread_receipt_from_store(&store, id, turn)? |
| 375 | } |
| 376 | Some(prefix) => { |
| 377 | let id = sessions.resolve_session_id_prefix(prefix)?; |
| 378 | let session = sessions.load_session_snapshot(&id)?; |
| 379 | session_receipt( |
| 380 | session_source(&session.metadata), |
| 381 | &session.messages, |
| 382 | &session.approval_receipts, |
| 383 | turn, |
| 384 | )? |
| 385 | } |
| 386 | None => { |
| 387 | let latest_session = sessions.list_sessions()?.into_iter().next(); |
| 388 | let latest_thread = store |
| 389 | .as_ref() |
| 390 | .map(|store| store.list_threads()) |
| 391 | .transpose()? |
| 392 | .and_then(|threads| threads.into_iter().find(|thread| !thread.archived)); |
| 393 | let thread_is_newer = match (&latest_session, &latest_thread) { |
| 394 | (Some(session), Some(thread)) => thread.updated_at > session.updated_at, |
| 395 | (None, Some(_)) => true, |
| 396 | _ => false, |
| 397 | }; |
| 398 | match (latest_session, latest_thread, store.as_ref()) { |
| 399 | (_, Some(thread), Some(store)) if thread_is_newer => { |
| 400 | thread_receipt_from_store(store, &thread.id, turn)? |
| 401 | } |
| 402 | (Some(metadata), _, _) => { |
| 403 | let session = sessions.load_session_snapshot(&metadata.id)?; |
| 404 | session_receipt( |
| 405 | session_source(&session.metadata), |
| 406 | &session.messages, |
| 407 | &session.approval_receipts, |
| 408 | turn, |
| 409 | )? |
| 410 | } |
| 411 | _ => anyhow::bail!("no saved session or Runtime thread to read"), |
| 412 | } |
| 413 | } |
| 414 | }; |
| 415 | let text = match format { |
| 416 | ReceiptFormat::Md => render_markdown(&receipt), |
| 417 | ReceiptFormat::Json => render_json(&receipt), |
| 418 | }; |
| 419 | println!("{}", text.trim_end()); |
| 420 | Ok(()) |
| 421 | } |
| 422 | |
| 423 | // --------------------------------------------------------------------------- |
| 424 | // Readers: persisted shape -> normalized steps |
| 425 | // --------------------------------------------------------------------------- |
| 426 | |
| 427 | /// Tool calls in transcript order, plus each turn's posture. A turn starts at |
| 428 | /// a real user prompt, by the same rule edit-last-turn and titles use |
| 429 | /// ([`crate::runtime_handoff::classify_user_turn_prompt`]); runtime-injected |
| 430 | /// messages and tool results do not start one. |
| 431 | /// Each turn's prompt text (the user's words, without the `<turn_meta>` |
| 432 | /// block) comes back too: it labels the turn's workspace snapshots. |
| 433 | fn steps_from_messages(messages: &[Message]) -> (Vec<ToolStep>, Vec<TurnPosture>, Vec<TurnPrompt>) { |
| 434 | let mut steps: Vec<ToolStep> = Vec::new(); |
| 435 | let mut by_id = HashMap::new(); |
| 436 | let mut call_counts = HashMap::<codewhale_models::ToolCallKey<'_>, usize>::new(); |
| 437 | let mut raw_counts = HashMap::<&str, usize>::new(); |
| 438 | let mut results = HashMap::new(); |
| 439 | for block in messages.iter().flat_map(|message| &message.content) { |
| 440 | let Some(key) = block.tool_call_key() else { |
| 441 | continue; |
| 442 | }; |
| 443 | match block { |
| 444 | ContentBlock::ToolUse { .. } | ContentBlock::ServerToolUse { .. } => { |
| 445 | *call_counts.entry(key).or_default() += 1; |
| 446 | *raw_counts.entry(key.as_str()).or_default() += 1; |
| 447 | } |
| 448 | ContentBlock::ToolResult { tool_use_id, .. } => { |
| 449 | results |
| 450 | .entry(key) |
| 451 | .and_modify(|entry| *entry = None) |
| 452 | .or_insert(Some(tool_use_id.as_str())); |
| 453 | } |
| 454 | _ => {} |
| 455 | } |
| 456 | } |
| 457 | let mut turn_postures: Vec<TurnPosture> = Vec::new(); |
| 458 | let mut turn_prompts: Vec<TurnPrompt> = Vec::new(); |
| 459 | let mut turn = 0usize; |
| 460 | for message in messages { |
| 461 | if crate::runtime_handoff::classify_user_turn_prompt(message) |
| 462 | != crate::runtime_handoff::UserTurnPromptKind::NotPrompt |
| 463 | { |
| 464 | turn += 1; |
| 465 | turn_postures.push((turn.to_string(), turn_meta_posture(message))); |
| 466 | turn_prompts.push((turn.to_string(), prompt_text(message))); |
| 467 | } |
| 468 | for block in &message.content { |
| 469 | match block { |
| 470 | ContentBlock::ToolUse { |
| 471 | id, name, input, .. |
| 472 | } |
| 473 | | ContentBlock::ServerToolUse { id, name, input } => { |
| 474 | let key = block.tool_call_key().expect("tool use key"); |
| 475 | let unambiguous = !key.as_str().trim().is_empty() |
| 476 | && call_counts.get(&key) == Some(&1) |
| 477 | && raw_counts.get(key.as_str()) == Some(&1) |
| 478 | && results |
| 479 | .get(&key) |
| 480 | .is_none_or(|result| result.as_deref() == Some(id.as_str())); |
| 481 | if unambiguous { |
| 482 | by_id.insert(key, (steps.len(), id.as_str())); |
| 483 | } |
| 484 | steps.push(ToolStep { |
| 485 | turn: (turn > 0).then(|| turn.to_string()), |
| 486 | call_id: unambiguous.then(|| key.as_str().to_string()), |
| 487 | name: name.clone(), |
| 488 | input: input.clone(), |
| 489 | outcome: StepOutcome::Unknown, |
| 490 | output: None, |
| 491 | metadata: None, |
| 492 | started_at: None, |
| 493 | ended_at: None, |
| 494 | }); |
| 495 | } |
| 496 | ContentBlock::ToolResult { |
| 497 | tool_use_id, |
| 498 | content, |
| 499 | is_error, |
| 500 | .. |
| 501 | } => { |
| 502 | if let Some(&(index, provider_id)) = |
| 503 | block.tool_call_key().and_then(|key| by_id.get(&key)) |
| 504 | && provider_id == tool_use_id |
| 505 | { |
| 506 | let step = &mut steps[index]; |
| 507 | step.outcome = if is_error.unwrap_or(false) { |
| 508 | StepOutcome::Failed |
| 509 | } else { |
| 510 | StepOutcome::Ok |
| 511 | }; |
| 512 | step.output = Some(content.clone()); |
| 513 | } |
| 514 | } |
| 515 | _ => {} |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | (steps, turn_postures, turn_prompts) |
| 520 | } |
| 521 | |
| 522 | /// The prompt a user message carries: its text blocks, less the |
| 523 | /// `<turn_meta>` block the engine appends. |
| 524 | fn prompt_text(message: &Message) -> Option<String> { |
| 525 | let meta_index = crate::runtime_handoff::turn_metadata_text(message).map(|(index, _)| index); |
| 526 | message |
| 527 | .content |
| 528 | .iter() |
| 529 | .enumerate() |
| 530 | .find_map(|(index, block)| match block { |
| 531 | ContentBlock::Text { text, .. } if Some(index) != meta_index => Some(text.clone()), |
| 532 | _ => None, |
| 533 | }) |
| 534 | } |
| 535 | |
| 536 | /// Files each turn changed, from the `pre-turn:N` / `post-turn:N` snapshots |
| 537 | /// the engine takes around a turn in this session (`core::turn`). A turn is |
| 538 | /// matched to its pair by the prompt snippet the labels carry, in order, the |
| 539 | /// same way `/restore` listings are read ([`crate::core::turn:: |
| 540 | /// snapshot_label_prompt_snippet`]). When another turn has the same snippet |
| 541 | /// ("continue", "yes", or none), the snippet cannot say whose pair it is, so |
| 542 | /// the pair's turn number `N` must agree too ([`seq_fits`]); otherwise the |
| 543 | /// turn counts as unmatched rather than taking a later turn's files. The |
| 544 | /// count of unmatched turns is returned beside the changes. `None` when this |
| 545 | /// workspace has no snapshot repo. |
| 546 | fn snapshot_turn_changes( |
| 547 | workspace: &std::path::Path, |
| 548 | session_id: &str, |
| 549 | turn_prompts: &[TurnPrompt], |
| 550 | wanted: Option<&str>, |
| 551 | ) -> Result<Option<TurnChanges>, String> { |
| 552 | use crate::core::turn::{parse_snapshot_label, snapshot_label_prompt_snippet}; |
| 553 | let Some(repo) = crate::snapshot::SnapshotRepo::open_existing(workspace) |
| 554 | .map_err(|error| error.to_string())? |
| 555 | else { |
| 556 | return Ok(None); |
| 557 | }; |
| 558 | let mut snapshots = repo.list(usize::MAX).map_err(|error| error.to_string())?; |
| 559 | snapshots.reverse(); |
| 560 | // Pair each post-turn:N with the open pre-turn:N of this session, oldest |
| 561 | // first. The sequence restarts when the session is resumed, so a pair |
| 562 | // closes on the first matching post-turn. |
| 563 | let mut open: HashMap<u64, (crate::snapshot::SnapshotId, Option<String>)> = HashMap::new(); |
| 564 | let mut pairs = Vec::new(); |
| 565 | for snapshot in snapshots |
| 566 | .into_iter() |
| 567 | .filter(|snapshot| snapshot.session_id.as_deref() == Some(session_id)) |
| 568 | { |
| 569 | let label = parse_snapshot_label(&snapshot.label); |
| 570 | let Some(seq) = label.seq else { continue }; |
| 571 | match label.kind.as_str() { |
| 572 | "pre-turn" => { |
| 573 | open.insert(seq, (snapshot.id, label.prompt_snippet)); |
| 574 | } |
| 575 | "post-turn" => { |
| 576 | if let Some((pre, snippet)) = open.remove(&seq) { |
| 577 | pairs.push((pre, snapshot.id, snippet, seq)); |
| 578 | } |
| 579 | } |
| 580 | _ => {} |
| 581 | } |
| 582 | } |
| 583 | let snippets: Vec<Option<String>> = turn_prompts |
| 584 | .iter() |
| 585 | .map(|(_, prompt)| prompt.as_deref().and_then(snapshot_label_prompt_snippet)) |
| 586 | .collect(); |
| 587 | let mut changes = Vec::new(); |
| 588 | let mut unmatched = 0usize; |
| 589 | let mut next_pair = 0usize; |
| 590 | // The last turn given a pair: its place in the transcript (1-based) and |
| 591 | // the pair's turn number. |
| 592 | let mut last: Option<(u64, u64)> = None; |
| 593 | let mut last_position = 0u64; |
| 594 | for (position, ((turn, _), snippet)) in turn_prompts.iter().zip(&snippets).enumerate() { |
| 595 | let position = position as u64 + 1; |
| 596 | let repeated = snippets |
| 597 | .iter() |
| 598 | .enumerate() |
| 599 | .any(|(other, label)| other as u64 + 1 != position && label == snippet); |
| 600 | let found = pairs[next_pair..] |
| 601 | .iter() |
| 602 | .position(|(_, _, label, _)| label == snippet) |
| 603 | .map(|offset| next_pair + offset) |
| 604 | .filter(|&index| { |
| 605 | // Engine turns with no transcript prompt (a `!` shell |
| 606 | // command) number a pair too; count the ones still listed. |
| 607 | let extra = pairs[next_pair..index] |
| 608 | .iter() |
| 609 | .filter(|(_, _, label, _)| !snippets.contains(label)) |
| 610 | .count() as u64; |
| 611 | let since = position - last_position + extra; |
| 612 | !repeated || seq_fits(pairs[index].3, since, last.map(|(_, seq)| seq)) |
| 613 | }); |
| 614 | let Some(index) = found else { |
| 615 | if wanted.is_none_or(|wanted| wanted == turn) { |
| 616 | unmatched += 1; |
| 617 | } |
| 618 | continue; |
| 619 | }; |
| 620 | next_pair = index + 1; |
| 621 | last = Some((position, pairs[index].3)); |
| 622 | last_position = position; |
| 623 | if wanted.is_some_and(|wanted| wanted != turn) { |
| 624 | continue; |
| 625 | } |
| 626 | let (pre, post, _, _) = &pairs[index]; |
| 627 | let (paths, truncated) = repo |
| 628 | .path_changes_between(pre, post, MAX_FILES_PER_ACTION) |
| 629 | .map_err(|error| error.to_string())?; |
| 630 | let files = paths |
| 631 | .into_iter() |
| 632 | .map(|change| FileTouch { |
| 633 | path: change.path, |
| 634 | change: match change.status { |
| 635 | 'A' => FileChangeKind::Created, |
| 636 | 'D' => FileChangeKind::Deleted, |
| 637 | _ => FileChangeKind::Edited, |
| 638 | }, |
| 639 | lines_added: change.added, |
| 640 | lines_removed: change.removed, |
| 641 | }) |
| 642 | .collect(); |
| 643 | changes.push((turn.clone(), TurnWorkspaceChange { files, truncated })); |
| 644 | } |
| 645 | Ok(Some((changes, unmatched))) |
| 646 | } |
| 647 | |
| 648 | /// Whether a snapshot pair numbered `seq` can belong to a turn that comes |
| 649 | /// `since` engine turns after the last matched pair (numbered `last_seq`), |
| 650 | /// or `since` turns after the session started when none matched yet. The |
| 651 | /// engine numbers turns from 1 each time it starts, so within one run the |
| 652 | /// number moves in step with the turns; after a resume it restarts, and can |
| 653 | /// be at most `since`. |
| 654 | fn seq_fits(seq: u64, since: u64, last_seq: Option<u64>) -> bool { |
| 655 | let restarted = (1..=since).contains(&seq); |
| 656 | match last_seq { |
| 657 | Some(last_seq) => seq == last_seq + since || restarted, |
| 658 | None => restarted, |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | /// `path` relative to `workspace` when it is inside it, without a leading |
| 663 | /// `./`, for comparing a file tool's path with a snapshot's. |
| 664 | fn workspace_relative(path: &str, workspace: Option<&str>) -> String { |
| 665 | let relative = workspace |
| 666 | .and_then(|workspace| { |
| 667 | path.strip_prefix(workspace.trim_end_matches('/')) |
| 668 | .and_then(|rest| rest.strip_prefix('/')) |
| 669 | }) |
| 670 | .unwrap_or(path); |
| 671 | relative.trim_start_matches("./").to_string() |
| 672 | } |
| 673 | |
| 674 | /// The posture line the engine writes into a prompt's `<turn_meta>` block |
| 675 | /// ([`crate::core::engine::PERMISSION_POSTURE_LINE`]). |
| 676 | fn turn_meta_posture(message: &Message) -> Option<&'static str> { |
| 677 | let (_, meta) = crate::runtime_handoff::turn_metadata_text(message)?; |
| 678 | meta.lines().find_map(|line| { |
| 679 | line.trim() |
| 680 | .strip_prefix(crate::core::engine::PERMISSION_POSTURE_LINE) |
| 681 | .and_then(posture_label) |
| 682 | }) |
| 683 | } |
| 684 | |
| 685 | /// The chip label for a posture the host wrote: a `<turn_meta>` label |
| 686 | /// (`Full Access`) or a Runtime turn's wire value (`full_access`). Anything |
| 687 | /// else is not a posture and yields `None`. |
| 688 | fn posture_label(value: &str) -> Option<&'static str> { |
| 689 | let value = value.trim(); |
| 690 | [ |
| 691 | ApprovalMode::Suggest, |
| 692 | ApprovalMode::Auto, |
| 693 | ApprovalMode::Bypass, |
| 694 | ApprovalMode::Never, |
| 695 | ] |
| 696 | .into_iter() |
| 697 | .map(ApprovalMode::permission_chip_label) |
| 698 | .find(|label| label.eq_ignore_ascii_case(value)) |
| 699 | .or_else(|| ApprovalMode::from_config_value(value).map(ApprovalMode::permission_chip_label)) |
| 700 | } |
| 701 | |
| 702 | fn step_from_item(item: &TurnItemRecord) -> Option<ToolStep> { |
| 703 | if !matches!( |
| 704 | item.kind, |
| 705 | TurnItemKind::ToolCall | TurnItemKind::FileChange | TurnItemKind::CommandExecution |
| 706 | ) { |
| 707 | return None; |
| 708 | } |
| 709 | let metadata = item.metadata.clone(); |
| 710 | let meta = metadata.as_ref(); |
| 711 | let name = meta |
| 712 | .and_then(|meta| meta.get("tool_name")) |
| 713 | .and_then(Value::as_str)? |
| 714 | .to_string(); |
| 715 | let call_id = meta |
| 716 | .and_then(|meta| meta.get("tool_use_id").or_else(|| meta.get("tool_call_id"))) |
| 717 | .and_then(Value::as_str) |
| 718 | .map(str::to_string); |
| 719 | let input = meta |
| 720 | .and_then(|meta| meta.get("tool_input")) |
| 721 | .map(|raw| match raw { |
| 722 | Value::String(text) => serde_json::from_str(text).unwrap_or(Value::Null), |
| 723 | other => other.clone(), |
| 724 | }) |
| 725 | .unwrap_or(Value::Null); |
| 726 | let outcome = match item.status { |
| 727 | TurnItemLifecycleStatus::Completed => StepOutcome::Ok, |
| 728 | TurnItemLifecycleStatus::Failed => StepOutcome::Failed, |
| 729 | TurnItemLifecycleStatus::Interrupted | TurnItemLifecycleStatus::Canceled => { |
| 730 | StepOutcome::Interrupted |
| 731 | } |
| 732 | TurnItemLifecycleStatus::Queued | TurnItemLifecycleStatus::InProgress => { |
| 733 | StepOutcome::Running |
| 734 | } |
| 735 | }; |
| 736 | let finished = !matches!(outcome, StepOutcome::Running); |
| 737 | Some(ToolStep { |
| 738 | turn: Some(item.turn_id.clone()), |
| 739 | call_id, |
| 740 | name, |
| 741 | input, |
| 742 | outcome, |
| 743 | output: finished.then(|| item.detail.clone()).flatten(), |
| 744 | metadata, |
| 745 | started_at: item.started_at, |
| 746 | ended_at: item.ended_at, |
| 747 | }) |
| 748 | } |
| 749 | |
| 750 | fn approvals_from_replay(replay: &ApprovalReplay) -> Vec<ApprovalStep> { |
| 751 | let mut out = Vec::new(); |
| 752 | for completed in &replay.completed { |
| 753 | let (decision, implied) = match &completed.outcome { |
| 754 | ApprovalOutcome::ApprovedOnce => (ApprovalDecisionLabel::Approved, None), |
| 755 | // The Runtime answers "deny" for a request it could not put in |
| 756 | // front of anyone (no active turn, the turn stopped, the channel |
| 757 | // closed). That is nobody answering, not a no. |
| 758 | ApprovalOutcome::Denied if completed.decided_by == Some(ApprovalDecider::Host) => { |
| 759 | (ApprovalDecisionLabel::Unavailable, None) |
| 760 | } |
| 761 | ApprovalOutcome::Denied => (ApprovalDecisionLabel::Denied, None), |
| 762 | ApprovalOutcome::Timeout => (ApprovalDecisionLabel::TimedOut, None), |
| 763 | ApprovalOutcome::Cancelled => ( |
| 764 | ApprovalDecisionLabel::Cancelled, |
| 765 | Some(ApprovalDecider::Host), |
| 766 | ), |
| 767 | ApprovalOutcome::Unavailable => ( |
| 768 | ApprovalDecisionLabel::Unavailable, |
| 769 | Some(ApprovalDecider::Host), |
| 770 | ), |
| 771 | ApprovalOutcome::RetryWithPolicy { .. } => { |
| 772 | (ApprovalDecisionLabel::ApprovedWithPolicy, None) |
| 773 | } |
| 774 | }; |
| 775 | out.push(ApprovalStep { |
| 776 | turn: None, |
| 777 | call_id: Some(completed.ask.approval_id().to_string()), |
| 778 | tool: completed.ask.tool_name().unwrap_or("unknown").to_string(), |
| 779 | fact: ApprovalFact { |
| 780 | decision, |
| 781 | decided_by: completed.decided_by.or(implied), |
| 782 | at: Some(completed.decided_at), |
| 783 | }, |
| 784 | }); |
| 785 | } |
| 786 | for ask in &replay.unmatched_asks { |
| 787 | out.push(ApprovalStep { |
| 788 | turn: None, |
| 789 | call_id: Some(ask.approval_id().to_string()), |
| 790 | tool: ask.tool_name().unwrap_or("unknown").to_string(), |
| 791 | fact: ApprovalFact { |
| 792 | decision: ApprovalDecisionLabel::Pending, |
| 793 | decided_by: None, |
| 794 | at: Some(ask.created_at()), |
| 795 | }, |
| 796 | }); |
| 797 | } |
| 798 | out |
| 799 | } |
| 800 | |
| 801 | /// Pair `approval.required` with `approval.decided` by approval id. Who |
| 802 | /// decided is read from the flags the Runtime writes on the decision event: |
| 803 | /// `timeout`, `cancelled`, `posture`, `auto` (+ `grant_id` for a session |
| 804 | /// rule). A decision with none of them came from a client acting for a |
| 805 | /// person. |
| 806 | fn approvals_from_events(events: &[RuntimeEventRecord]) -> Vec<ApprovalStep> { |
| 807 | let mut order: Vec<String> = Vec::new(); |
| 808 | let mut steps: HashMap<String, ApprovalStep> = HashMap::new(); |
| 809 | for event in events { |
| 810 | let payload = &event.payload; |
| 811 | let Some(approval_id) = payload |
| 812 | .get("approval_id") |
| 813 | .or_else(|| payload.get("id")) |
| 814 | .and_then(Value::as_str) |
| 815 | else { |
| 816 | continue; |
| 817 | }; |
| 818 | match event.event.as_str() { |
| 819 | "approval.required" => { |
| 820 | if !steps.contains_key(approval_id) { |
| 821 | order.push(approval_id.to_string()); |
| 822 | } |
| 823 | steps.insert( |
| 824 | approval_id.to_string(), |
| 825 | ApprovalStep { |
| 826 | turn: event.turn_id.clone(), |
| 827 | call_id: payload |
| 828 | .get("tool_call_id") |
| 829 | .and_then(Value::as_str) |
| 830 | .map(str::to_string), |
| 831 | tool: payload |
| 832 | .get("tool_name") |
| 833 | .and_then(Value::as_str) |
| 834 | .unwrap_or("unknown") |
| 835 | .to_string(), |
| 836 | fact: ApprovalFact { |
| 837 | decision: ApprovalDecisionLabel::Pending, |
| 838 | decided_by: None, |
| 839 | at: Some(event.timestamp), |
| 840 | }, |
| 841 | }, |
| 842 | ); |
| 843 | } |
| 844 | "approval.decided" => { |
| 845 | let flag = |key: &str| payload.get(key).and_then(Value::as_bool) == Some(true); |
| 846 | let allowed = payload.get("decision").and_then(Value::as_str) == Some("allow"); |
| 847 | let decision = if allowed { |
| 848 | ApprovalDecisionLabel::Approved |
| 849 | } else if flag("timeout") { |
| 850 | ApprovalDecisionLabel::TimedOut |
| 851 | } else if flag("cancelled") { |
| 852 | ApprovalDecisionLabel::Cancelled |
| 853 | } else { |
| 854 | ApprovalDecisionLabel::Denied |
| 855 | }; |
| 856 | let decided_by = if flag("timeout") { |
| 857 | None |
| 858 | } else if flag("cancelled") { |
| 859 | Some(ApprovalDecider::Host) |
| 860 | } else if payload.get("posture").is_some() { |
| 861 | Some(ApprovalDecider::Posture) |
| 862 | } else if flag("auto") { |
| 863 | if payload |
| 864 | .get("grant_id") |
| 865 | .is_some_and(|grant| !grant.is_null()) |
| 866 | { |
| 867 | Some(ApprovalDecider::SessionRule) |
| 868 | } else { |
| 869 | Some(ApprovalDecider::Posture) |
| 870 | } |
| 871 | } else { |
| 872 | Some(ApprovalDecider::User) |
| 873 | }; |
| 874 | let fact = ApprovalFact { |
| 875 | decision, |
| 876 | decided_by, |
| 877 | at: Some(event.timestamp), |
| 878 | }; |
| 879 | match steps.get_mut(approval_id) { |
| 880 | Some(step) => step.fact = fact, |
| 881 | None => { |
| 882 | order.push(approval_id.to_string()); |
| 883 | steps.insert( |
| 884 | approval_id.to_string(), |
| 885 | ApprovalStep { |
| 886 | turn: event.turn_id.clone(), |
| 887 | call_id: payload |
| 888 | .get("tool_call_id") |
| 889 | .and_then(Value::as_str) |
| 890 | .map(str::to_string), |
| 891 | tool: "unknown".to_string(), |
| 892 | fact, |
| 893 | }, |
| 894 | ); |
| 895 | } |
| 896 | } |
| 897 | } |
| 898 | _ => {} |
| 899 | } |
| 900 | } |
| 901 | order |
| 902 | .into_iter() |
| 903 | .filter_map(|id| steps.remove(&id)) |
| 904 | .collect() |
| 905 | } |
| 906 | |
| 907 | // --------------------------------------------------------------------------- |
| 908 | // Classification: normalized step -> action |
| 909 | // --------------------------------------------------------------------------- |
| 910 | |
| 911 | enum Classified { |
| 912 | Listed(ActionKind), |
| 913 | /// Counted as an other tool call; listed only if it failed. |
| 914 | Other, |
| 915 | /// A later call on an agent the receipt already lists (status, wait, |
| 916 | /// cancel): it updates that agent's outcome instead of adding a line. |
| 917 | AgentFollowUp { |
| 918 | agent_id: String, |
| 919 | status: Option<String>, |
| 920 | }, |
| 921 | } |
| 922 | |
| 923 | fn classify(step: &ToolStep, notes: &mut BTreeSet<String>) -> Classified { |
| 924 | let structured = structured_output(step); |
| 925 | let facts = step.metadata.as_ref().or(structured.as_ref()); |
| 926 | if let Some(files) = mutation_files(facts) { |
| 927 | return Classified::Listed(ActionKind::FileChange { files }); |
| 928 | } |
| 929 | let semantic = crate::tools::canonical_action::canonical_action_alias(&step.name, &step.input); |
| 930 | match semantic { |
| 931 | "write_file" | "edit_file" | "apply_patch" | "fim_edit" => { |
| 932 | let files = files_from_input(semantic, &step.input, step.outcome); |
| 933 | if files.is_empty() { |
| 934 | return Classified::Other; |
| 935 | } |
| 936 | if files.iter().any(|file| file.lines_added.is_none()) { |
| 937 | notes.insert( |
| 938 | "Line counts: some file changes have no saved diff, so their +/- counts are not recorded.".to_string(), |
| 939 | ); |
| 940 | } |
| 941 | Classified::Listed(ActionKind::FileChange { files }) |
| 942 | } |
| 943 | "exec_shell" | "task_shell_start" | "task_gate_run" | "run_tests" | "run_verifiers" => { |
| 944 | let exit_code = number(facts, &["exit_code", "return_code"]) |
| 945 | .or_else(|| closing_exit_code(step.output.as_deref())); |
| 946 | if exit_code.is_none() && matches!(step.outcome, StepOutcome::Ok) { |
| 947 | notes.insert( |
| 948 | "Exit codes: calls that saved no structured result (terminal sessions) show pass or fail, not the exit code.".to_string(), |
| 949 | ); |
| 950 | } |
| 951 | Classified::Listed(ActionKind::Command { |
| 952 | command: command_text(&step.name, semantic, &step.input), |
| 953 | cwd: string_field( |
| 954 | Some(&step.input), |
| 955 | &["cwd", "working_dir", "workdir", "workspace"], |
| 956 | ) |
| 957 | .or_else(|| string_field(facts, &["working_dir", "cwd"])), |
| 958 | exit_code, |
| 959 | }) |
| 960 | } |
| 961 | "code_execution" | "js_execution" | "execute_tools" | "rlm_eval" => { |
| 962 | Classified::Listed(ActionKind::Code { |
| 963 | exit_code: number(facts, &["return_code", "exit_code"]), |
| 964 | nested: nested_calls(structured.as_ref().or(facts)), |
| 965 | }) |
| 966 | } |
| 967 | "web_search" | "fetch_url" | "web.run" | "rlm_open" | "git_fetch" => { |
| 968 | let url = string_field(Some(&step.input), &["url", "uri"]); |
| 969 | let host = url |
| 970 | .as_deref() |
| 971 | .and_then(|url| reqwest::Url::parse(url).ok()) |
| 972 | .and_then(|url| url.host_str().map(str::to_string)) |
| 973 | .or_else(|| { |
| 974 | (semantic == "git_fetch") |
| 975 | .then(|| string_field(Some(&step.input), &["remote"])) |
| 976 | .flatten() |
| 977 | }); |
| 978 | let action = match semantic { |
| 979 | "web_search" => "search", |
| 980 | "git_fetch" => "git_fetch", |
| 981 | _ if url.is_some() => "fetch", |
| 982 | _ => "request", |
| 983 | }; |
| 984 | let query = string_field(Some(&step.input), &["query", "q", "search_query"]) |
| 985 | .map(|query| bounded(&redact(&query), MAX_QUERY_CHARS)); |
| 986 | Classified::Listed(ActionKind::Network { |
| 987 | action: action.to_string(), |
| 988 | host, |
| 989 | query, |
| 990 | }) |
| 991 | } |
| 992 | name if name.starts_with("github_") => Classified::Listed(ActionKind::Network { |
| 993 | action: name.trim_start_matches("github_").to_string(), |
| 994 | host: Some("github.com".to_string()), |
| 995 | query: None, |
| 996 | }), |
| 997 | name if name.starts_with("mcp_") => { |
| 998 | let server = crate::mcp::connected_app_server(name).map(str::to_string); |
| 999 | let plugin = server |
| 1000 | .as_deref() |
| 1001 | .is_some_and(|server| server.starts_with("plugin")); |
| 1002 | Classified::Listed(ActionKind::Mcp { server, plugin }) |
| 1003 | } |
| 1004 | "agent" => { |
| 1005 | let action = step |
| 1006 | .input |
| 1007 | .get("action") |
| 1008 | .and_then(Value::as_str) |
| 1009 | .unwrap_or("start"); |
| 1010 | let agent_id = string_field(facts, &["agent_id"]); |
| 1011 | let status = string_field(facts, &["status"]); |
| 1012 | if action == "start" { |
| 1013 | Classified::Listed(ActionKind::Subagent { |
| 1014 | name: string_field(Some(&step.input), &["name"]) |
| 1015 | .or_else(|| string_field(facts, &["name"])), |
| 1016 | agent_id, |
| 1017 | outcome: status, |
| 1018 | }) |
| 1019 | } else if let Some(agent_id) = agent_id { |
| 1020 | Classified::AgentFollowUp { agent_id, status } |
| 1021 | } else { |
| 1022 | Classified::Other |
| 1023 | } |
| 1024 | } |
| 1025 | _ => Classified::Other, |
| 1026 | } |
| 1027 | } |
| 1028 | |
| 1029 | /// The host-owned JSON a tool returned as its text result (agent, code, |
| 1030 | /// execute_tools), when the persisted record has no structured metadata. A |
| 1031 | /// leading approval note is skipped. Anything that is not a JSON object is |
| 1032 | /// ignored rather than read as prose, and so is JSON another party wrote |
| 1033 | /// ([`result_is_outside_text`]): an MCP server's reply cannot claim a file |
| 1034 | /// change or an exit code. |
| 1035 | fn structured_output(step: &ToolStep) -> Option<Value> { |
| 1036 | if result_is_outside_text(step) { |
| 1037 | return None; |
| 1038 | } |
| 1039 | let output = step.output.as_deref()?.trim_start(); |
| 1040 | let body = if output.starts_with("[approval] ") { |
| 1041 | output.split_once("\n\n").map(|(_, rest)| rest)? |
| 1042 | } else { |
| 1043 | output |
| 1044 | }; |
| 1045 | let value: Value = serde_json::from_str(body.trim()).ok()?; |
| 1046 | value.is_object().then_some(value) |
| 1047 | } |
| 1048 | |
| 1049 | fn mutation_files(facts: Option<&Value>) -> Option<Vec<FileTouch>> { |
| 1050 | let mutation = facts?.get("mutation")?; |
| 1051 | let files = mutation.get("files")?.as_array()?; |
| 1052 | let counts = mutation |
| 1053 | .get("diff") |
| 1054 | .and_then(Value::as_str) |
| 1055 | .map(diff_counts_by_path) |
| 1056 | .unwrap_or_default(); |
| 1057 | let mut out: Vec<FileTouch> = files |
| 1058 | .iter() |
| 1059 | .filter_map(|file| { |
| 1060 | let path = file.get("path")?.as_str()?.to_string(); |
| 1061 | let change = match file.get("outcome").and_then(Value::as_str) { |
| 1062 | Some("created") => FileChangeKind::Created, |
| 1063 | Some("deleted") => FileChangeKind::Deleted, |
| 1064 | _ => FileChangeKind::Edited, |
| 1065 | }; |
| 1066 | let (added, removed) = counts.get(&path).copied().unwrap_or((0, 0)); |
| 1067 | Some(FileTouch { |
| 1068 | path, |
| 1069 | change, |
| 1070 | lines_added: Some(added), |
| 1071 | lines_removed: Some(removed), |
| 1072 | }) |
| 1073 | }) |
| 1074 | .collect(); |
| 1075 | if let Some(renames) = mutation.get("renames").and_then(Value::as_array) { |
| 1076 | for rename in renames { |
| 1077 | if let Some(to) = rename.get("to").and_then(Value::as_str) { |
| 1078 | out.push(FileTouch { |
| 1079 | path: to.to_string(), |
| 1080 | change: FileChangeKind::Created, |
| 1081 | lines_added: Some(0), |
| 1082 | lines_removed: Some(0), |
| 1083 | }); |
| 1084 | } |
| 1085 | if let Some(from) = rename.get("from").and_then(Value::as_str) { |
| 1086 | out.push(FileTouch { |
| 1087 | path: from.to_string(), |
| 1088 | change: FileChangeKind::Deleted, |
| 1089 | lines_added: Some(0), |
| 1090 | lines_removed: Some(0), |
| 1091 | }); |
| 1092 | } |
| 1093 | } |
| 1094 | } |
| 1095 | out.truncate(MAX_FILES_PER_ACTION); |
| 1096 | (!out.is_empty()).then_some(out) |
| 1097 | } |
| 1098 | |
| 1099 | /// One line of a unified diff or patch, read in context. |
| 1100 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1101 | enum DiffLine<'a> { |
| 1102 | /// A `--- old` / `+++ new` file header pair, as their raw paths. |
| 1103 | Header { |
| 1104 | old: &'a str, |
| 1105 | new: &'a str, |
| 1106 | }, |
| 1107 | /// `diff --git …`: a new file section starts. |
| 1108 | FileStart, |
| 1109 | Added, |
| 1110 | Removed, |
| 1111 | Other(&'a str), |
| 1112 | } |
| 1113 | |
| 1114 | /// Classify a diff's lines. `--- ` and `+++ ` are file headers only as an |
| 1115 | /// adjacent pair outside a hunk: inside one they are a removed `-- …` or |
| 1116 | /// added `++ …` line (a SQL or Lua comment), and a hunk's `@@ -a,b +c,d @@` |
| 1117 | /// counts say where it ends. |
| 1118 | fn diff_lines(text: &str) -> Vec<DiffLine<'_>> { |
| 1119 | let lines: Vec<&str> = text.lines().collect(); |
| 1120 | let mut out = Vec::with_capacity(lines.len()); |
| 1121 | let (mut old_left, mut new_left) = (0u64, 0u64); |
| 1122 | let mut index = 0; |
| 1123 | while index < lines.len() { |
| 1124 | let line = lines[index]; |
| 1125 | index += 1; |
| 1126 | // Never hunk content, which always starts with ` `, `+`, or `-`. |
| 1127 | if line.starts_with("diff --git ") { |
| 1128 | (old_left, new_left) = (0, 0); |
| 1129 | out.push(DiffLine::FileStart); |
| 1130 | continue; |
| 1131 | } |
| 1132 | let in_hunk = old_left > 0 || new_left > 0; |
| 1133 | if !in_hunk { |
| 1134 | if let (Some(old), Some(new)) = ( |
| 1135 | line.strip_prefix("--- "), |
| 1136 | lines.get(index).and_then(|next| next.strip_prefix("+++ ")), |
| 1137 | ) { |
| 1138 | out.push(DiffLine::Header { old, new }); |
| 1139 | index += 1; |
| 1140 | continue; |
| 1141 | } |
| 1142 | if line.starts_with("@@") { |
| 1143 | if let Some((old, new)) = hunk_counts(line) { |
| 1144 | (old_left, new_left) = (old, new); |
| 1145 | } |
| 1146 | out.push(DiffLine::Other(line)); |
| 1147 | continue; |
| 1148 | } |
| 1149 | } |
| 1150 | out.push(match line.as_bytes().first() { |
| 1151 | Some(b'+') => { |
| 1152 | new_left = new_left.saturating_sub(1); |
| 1153 | DiffLine::Added |
| 1154 | } |
| 1155 | Some(b'-') => { |
| 1156 | old_left = old_left.saturating_sub(1); |
| 1157 | DiffLine::Removed |
| 1158 | } |
| 1159 | // A blank line is a context line whose leading space was trimmed. |
| 1160 | Some(b' ') | None => { |
| 1161 | old_left = old_left.saturating_sub(1); |
| 1162 | new_left = new_left.saturating_sub(1); |
| 1163 | DiffLine::Other(line) |
| 1164 | } |
| 1165 | _ => DiffLine::Other(line), |
| 1166 | }); |
| 1167 | } |
| 1168 | out |
| 1169 | } |
| 1170 | |
| 1171 | /// `(old, new)` line counts from `@@ -a[,b] +c[,d] @@`; a missing count is 1. |
| 1172 | fn hunk_counts(line: &str) -> Option<(u64, u64)> { |
| 1173 | let mut ranges = line.strip_prefix("@@ ")?.split_whitespace(); |
| 1174 | let count = |range: &str, sign: char| -> Option<u64> { |
| 1175 | let range = range.strip_prefix(sign)?; |
| 1176 | match range.split_once(',') { |
| 1177 | Some((_, count)) => count.parse().ok(), |
| 1178 | None => range.parse::<u64>().ok().map(|_| 1), |
| 1179 | } |
| 1180 | }; |
| 1181 | Some((count(ranges.next()?, '-')?, count(ranges.next()?, '+')?)) |
| 1182 | } |
| 1183 | |
| 1184 | /// Per-file `(+, -)` from a unified diff, keyed by the `+++ b/<path>` (or |
| 1185 | /// `--- a/<path>` for deletions) header. |
| 1186 | fn diff_counts_by_path(diff: &str) -> HashMap<String, (u64, u64)> { |
| 1187 | let mut counts: HashMap<String, (u64, u64)> = HashMap::new(); |
| 1188 | let mut current: Option<String> = None; |
| 1189 | for line in diff_lines(diff) { |
| 1190 | match line { |
| 1191 | DiffLine::Header { old, new } => { |
| 1192 | current = header_path(new).or_else(|| header_path(old)); |
| 1193 | if let Some(path) = ¤t { |
| 1194 | counts.entry(path.clone()).or_default(); |
| 1195 | } |
| 1196 | } |
| 1197 | DiffLine::FileStart => current = None, |
| 1198 | DiffLine::Added | DiffLine::Removed => { |
| 1199 | let Some(path) = ¤t else { continue }; |
| 1200 | let entry = counts.entry(path.clone()).or_default(); |
| 1201 | if line == DiffLine::Added { |
| 1202 | entry.0 += 1; |
| 1203 | } else { |
| 1204 | entry.1 += 1; |
| 1205 | } |
| 1206 | } |
| 1207 | DiffLine::Other(_) => {} |
| 1208 | } |
| 1209 | } |
| 1210 | counts |
| 1211 | } |
| 1212 | |
| 1213 | fn header_path(rest: &str) -> Option<String> { |
| 1214 | let path = rest.split('\t').next()?.trim(); |
| 1215 | if path == "/dev/null" { |
| 1216 | return None; |
| 1217 | } |
| 1218 | Some( |
| 1219 | path.strip_prefix("a/") |
| 1220 | .or_else(|| path.strip_prefix("b/")) |
| 1221 | .unwrap_or(path) |
| 1222 | .to_string(), |
| 1223 | ) |
| 1224 | } |
| 1225 | |
| 1226 | /// File changes from a call's own input, for records without a saved diff. |
| 1227 | /// A failed call changed nothing, so it lists its paths with no counts. |
| 1228 | fn files_from_input(semantic: &str, input: &Value, outcome: StepOutcome) -> Vec<FileTouch> { |
| 1229 | let succeeded = outcome == StepOutcome::Ok; |
| 1230 | let path = string_field(Some(input), &["path", "file_path", "filePath"]); |
| 1231 | match semantic { |
| 1232 | "write_file" => path |
| 1233 | .map(|path| { |
| 1234 | vec![FileTouch { |
| 1235 | path, |
| 1236 | change: FileChangeKind::Written, |
| 1237 | lines_added: None, |
| 1238 | lines_removed: None, |
| 1239 | }] |
| 1240 | }) |
| 1241 | .unwrap_or_default(), |
| 1242 | "edit_file" | "fim_edit" => { |
| 1243 | let Some(path) = path else { |
| 1244 | return Vec::new(); |
| 1245 | }; |
| 1246 | let (added, removed) = if succeeded { |
| 1247 | edit_line_counts(input) |
| 1248 | } else { |
| 1249 | (None, None) |
| 1250 | }; |
| 1251 | vec![FileTouch { |
| 1252 | path, |
| 1253 | change: FileChangeKind::Edited, |
| 1254 | lines_added: added, |
| 1255 | lines_removed: removed, |
| 1256 | }] |
| 1257 | } |
| 1258 | "apply_patch" => { |
| 1259 | let Some(patch) = string_field(Some(input), &["patch", "input"]) else { |
| 1260 | return path |
| 1261 | .map(|path| { |
| 1262 | vec![FileTouch { |
| 1263 | path, |
| 1264 | change: FileChangeKind::Edited, |
| 1265 | lines_added: None, |
| 1266 | lines_removed: None, |
| 1267 | }] |
| 1268 | }) |
| 1269 | .unwrap_or_default(); |
| 1270 | }; |
| 1271 | let mut files = patch_files(&patch, path.as_deref()); |
| 1272 | if !succeeded { |
| 1273 | for file in &mut files { |
| 1274 | file.lines_added = None; |
| 1275 | file.lines_removed = None; |
| 1276 | } |
| 1277 | } |
| 1278 | files |
| 1279 | } |
| 1280 | _ => Vec::new(), |
| 1281 | } |
| 1282 | } |
| 1283 | |
| 1284 | /// `(+, -)` from an edit call's replacement pairs: the lines of every |
| 1285 | /// replacement text against the lines of every searched text. |
| 1286 | fn edit_line_counts(input: &Value) -> (Option<u64>, Option<u64>) { |
| 1287 | const SEARCH: &[&str] = &["search", "old_string", "old_str", "oldText", "old_text"]; |
| 1288 | const REPLACE: &[&str] = &[ |
| 1289 | "replace", |
| 1290 | "new_string", |
| 1291 | "new_str", |
| 1292 | "newText", |
| 1293 | "new_text", |
| 1294 | "replacement", |
| 1295 | ]; |
| 1296 | let pairs: Vec<&Value> = match input.get("edits").and_then(Value::as_array) { |
| 1297 | Some(edits) => edits.iter().collect(), |
| 1298 | None => vec![input], |
| 1299 | }; |
| 1300 | let mut added = 0u64; |
| 1301 | let mut removed = 0u64; |
| 1302 | let mut any = false; |
| 1303 | for pair in pairs { |
| 1304 | let old = string_field(Some(pair), SEARCH); |
| 1305 | let new = string_field(Some(pair), REPLACE); |
| 1306 | if old.is_none() && new.is_none() { |
| 1307 | continue; |
| 1308 | } |
| 1309 | any = true; |
| 1310 | removed += line_count(old.as_deref().unwrap_or("")); |
| 1311 | added += line_count(new.as_deref().unwrap_or("")); |
| 1312 | } |
| 1313 | if any { |
| 1314 | (Some(added), Some(removed)) |
| 1315 | } else { |
| 1316 | (None, None) |
| 1317 | } |
| 1318 | } |
| 1319 | |
| 1320 | fn line_count(text: &str) -> u64 { |
| 1321 | if text.is_empty() { |
| 1322 | 0 |
| 1323 | } else { |
| 1324 | text.lines().count() as u64 |
| 1325 | } |
| 1326 | } |
| 1327 | |
| 1328 | /// Files and counts from a patch the call supplied: `*** Add/Update/Delete |
| 1329 | /// File:` envelopes or unified-diff headers. |
| 1330 | fn patch_files(patch: &str, fallback_path: Option<&str>) -> Vec<FileTouch> { |
| 1331 | let mut files: Vec<FileTouch> = Vec::new(); |
| 1332 | for line in diff_lines(patch) { |
| 1333 | let (added, removed) = match line { |
| 1334 | DiffLine::Header { old, new } => { |
| 1335 | let (path, change) = match (header_path(new), header_path(old)) { |
| 1336 | (Some(path), Some(_)) => (path, FileChangeKind::Edited), |
| 1337 | (Some(path), None) => (path, FileChangeKind::Created), |
| 1338 | (None, Some(old)) => (old, FileChangeKind::Deleted), |
| 1339 | (None, None) => continue, |
| 1340 | }; |
| 1341 | files.push(FileTouch { |
| 1342 | path, |
| 1343 | change, |
| 1344 | lines_added: Some(0), |
| 1345 | lines_removed: Some(0), |
| 1346 | }); |
| 1347 | continue; |
| 1348 | } |
| 1349 | DiffLine::FileStart => continue, |
| 1350 | DiffLine::Added => (1, 0), |
| 1351 | DiffLine::Removed => (0, 1), |
| 1352 | DiffLine::Other(line) => { |
| 1353 | envelope_file(line, &mut files); |
| 1354 | continue; |
| 1355 | } |
| 1356 | }; |
| 1357 | if files.is_empty() |
| 1358 | && let Some(path) = fallback_path |
| 1359 | { |
| 1360 | files.push(FileTouch { |
| 1361 | path: path.to_string(), |
| 1362 | change: FileChangeKind::Edited, |
| 1363 | lines_added: Some(0), |
| 1364 | lines_removed: Some(0), |
| 1365 | }); |
| 1366 | } |
| 1367 | if let Some(file) = files.last_mut() { |
| 1368 | *file.lines_added.get_or_insert(0) += added; |
| 1369 | *file.lines_removed.get_or_insert(0) += removed; |
| 1370 | } |
| 1371 | } |
| 1372 | files.truncate(MAX_FILES_PER_ACTION); |
| 1373 | files |
| 1374 | } |
| 1375 | |
| 1376 | /// A `*** Add/Update/Delete File: <path>` envelope line starts a file. |
| 1377 | fn envelope_file(line: &str, files: &mut Vec<FileTouch>) { |
| 1378 | let envelope = [ |
| 1379 | ("*** Add File: ", FileChangeKind::Created), |
| 1380 | ("*** Update File: ", FileChangeKind::Edited), |
| 1381 | ("*** Delete File: ", FileChangeKind::Deleted), |
| 1382 | ] |
| 1383 | .into_iter() |
| 1384 | .find_map(|(prefix, change)| line.strip_prefix(prefix).map(|path| (path, change))); |
| 1385 | if let Some((path, change)) = envelope { |
| 1386 | files.push(FileTouch { |
| 1387 | path: path.trim().to_string(), |
| 1388 | change, |
| 1389 | lines_added: Some(0), |
| 1390 | lines_removed: Some(0), |
| 1391 | }); |
| 1392 | } |
| 1393 | } |
| 1394 | |
| 1395 | fn command_text(tool: &str, semantic: &str, input: &Value) -> String { |
| 1396 | let raw = match input.get("command").or_else(|| input.get("cmd")) { |
| 1397 | Some(Value::String(command)) => command.clone(), |
| 1398 | Some(Value::Array(parts)) => parts |
| 1399 | .iter() |
| 1400 | .map(|part| { |
| 1401 | part.as_str() |
| 1402 | .map_or_else(|| part.to_string(), str::to_string) |
| 1403 | }) |
| 1404 | .collect::<Vec<_>>() |
| 1405 | .join(" "), |
| 1406 | _ => match semantic { |
| 1407 | "run_tests" | "run_verifiers" => { |
| 1408 | let what = if semantic == "run_tests" { |
| 1409 | "tests" |
| 1410 | } else { |
| 1411 | "verifiers" |
| 1412 | }; |
| 1413 | let names = input |
| 1414 | .get("commands") |
| 1415 | .and_then(Value::as_array) |
| 1416 | .map(|commands| { |
| 1417 | commands |
| 1418 | .iter() |
| 1419 | .filter_map(|command| command.get("name").and_then(Value::as_str)) |
| 1420 | .collect::<Vec<_>>() |
| 1421 | .join(", ") |
| 1422 | }) |
| 1423 | .filter(|names| !names.is_empty()); |
| 1424 | match names { |
| 1425 | Some(names) => format!("{tool} {what}: {names}"), |
| 1426 | None => format!("{tool} {what}"), |
| 1427 | } |
| 1428 | } |
| 1429 | _ => tool.to_string(), |
| 1430 | }, |
| 1431 | }; |
| 1432 | // Redact the text as written: the redactor finds a private-key block by |
| 1433 | // its lines, which flattening would join into one. |
| 1434 | let flat = redact(&raw) |
| 1435 | .split_whitespace() |
| 1436 | .collect::<Vec<_>>() |
| 1437 | .join(" "); |
| 1438 | bounded(&flat, MAX_COMMAND_CHARS) |
| 1439 | } |
| 1440 | |
| 1441 | /// The engine's own closing status line for a failed shell call, e.g. |
| 1442 | /// `Command exited with code 2`. This is a fixed format the shell tool writes, |
| 1443 | /// not model prose; anything else yields `None`. |
| 1444 | fn closing_exit_code(output: Option<&str>) -> Option<i64> { |
| 1445 | let last = output?.trim_end().lines().last()?.trim(); |
| 1446 | last.strip_prefix("Command exited with code ")?.parse().ok() |
| 1447 | } |
| 1448 | |
| 1449 | /// What a failed call's own result shows about whether it started. |
| 1450 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1451 | enum FailureEvidence { |
| 1452 | /// An exit code, or a status line the shell writes only after a process |
| 1453 | /// ran. |
| 1454 | Ran, |
| 1455 | /// Codewhale refused the call before it started. |
| 1456 | Refused, |
| 1457 | /// Stopped at an approval prompt. The text says `denied by user` for any |
| 1458 | /// decider (a host with nobody to ask writes it too), so without an |
| 1459 | /// approval-log record it proves the call did not run, not who stopped it. |
| 1460 | DeniedAtApproval, |
| 1461 | /// Neither. |
| 1462 | Unclear, |
| 1463 | } |
| 1464 | |
| 1465 | /// Lines the shell tools write only after a process ran |
| 1466 | /// (`tools/shell.rs`: `contract_bash_error_status` and the `exec_shell` |
| 1467 | /// result). |
| 1468 | const SHELL_RAN_LINES: [&str; 5] = [ |
| 1469 | "Command exited with code ", |
| 1470 | "Command failed (", |
| 1471 | "Command timed out", |
| 1472 | "Command canceled", |
| 1473 | "Command aborted", |
| 1474 | ]; |
| 1475 | |
| 1476 | /// The shell tools, by semantic name. Only these write `BLOCKED:` (their |
| 1477 | /// policy and safety blocks) and [`SHELL_RAN_LINES`]. |
| 1478 | const SHELL_TOOLS: [&str; 5] = [ |
| 1479 | "exec_shell", |
| 1480 | "task_shell_start", |
| 1481 | "task_gate_run", |
| 1482 | "run_tests", |
| 1483 | "run_verifiers", |
| 1484 | ]; |
| 1485 | |
| 1486 | /// Tools whose result text is someone else's words: an MCP server's or |
| 1487 | /// GitHub's reply, or a fetched page. Nothing in it is read as a fact about |
| 1488 | /// the call, not even JSON; only metadata Codewhale wrote is. |
| 1489 | fn result_is_outside_text(step: &ToolStep) -> bool { |
| 1490 | let semantic = crate::tools::canonical_action::canonical_action_alias(&step.name, &step.input); |
| 1491 | step.name.starts_with("mcp_") |
| 1492 | || semantic.starts_with("mcp_") |
| 1493 | || semantic.starts_with("github_") |
| 1494 | || matches!( |
| 1495 | semantic, |
| 1496 | "web_search" | "fetch_url" | "web.run" | "rlm_open" | "git_fetch" |
| 1497 | ) |
| 1498 | } |
| 1499 | |
| 1500 | /// Tools whose failed result can open with text nobody at Codewhale framed: |
| 1501 | /// [`result_is_outside_text`], plus a program's own output (code tools) and a |
| 1502 | /// sub-agent's words. Their failure text never proves a refusal. |
| 1503 | fn failure_text_is_outside(step: &ToolStep) -> bool { |
| 1504 | let semantic = crate::tools::canonical_action::canonical_action_alias(&step.name, &step.input); |
| 1505 | result_is_outside_text(step) |
| 1506 | || matches!( |
| 1507 | semantic, |
| 1508 | "code_execution" | "js_execution" | "execute_tools" | "rlm_eval" | "agent" |
| 1509 | ) |
| 1510 | } |
| 1511 | |
| 1512 | /// Whether a failed call's result shows it started. The record keeps no |
| 1513 | /// "blocked" flag, so only shapes Codewhale itself writes are read, never |
| 1514 | /// model prose or another program's text: |
| 1515 | /// |
| 1516 | /// - `side_effect_status: not_started` in the call's metadata (the engine |
| 1517 | /// writes it), or on the `Tool validation feedback:` line |
| 1518 | /// `dispatch::format_tool_error_with_schema` appends as the result's last |
| 1519 | /// line. |
| 1520 | /// - a call refused before it runs gets its error as the result. A terminal |
| 1521 | /// session saves `Error: ` plus `dispatch::format_tool_error_with_schema`; |
| 1522 | /// a Runtime thread saves the `ToolError`'s own text. A shell tool's policy |
| 1523 | /// and safety blocks start with `BLOCKED:`. |
| 1524 | /// - a process that ran leaves an exit code or one of [`SHELL_RAN_LINES`]. |
| 1525 | /// |
| 1526 | /// A tool whose failure text can come from outside Codewhale |
| 1527 | /// ([`failure_text_is_outside`]) is judged by metadata alone: an MCP server |
| 1528 | /// that answers `BLOCKED:` or `{"side_effect_status":"not_started"}` must not |
| 1529 | /// hide a call that ran. Such a call reads as failed, which over-counts what |
| 1530 | /// ran rather than under-counting it. |
| 1531 | fn failure_evidence(step: &ToolStep) -> FailureEvidence { |
| 1532 | let structured = structured_output(step); |
| 1533 | let facts = step.metadata.as_ref().or(structured.as_ref()); |
| 1534 | if number(facts, &["exit_code", "return_code"]).is_some() { |
| 1535 | return FailureEvidence::Ran; |
| 1536 | } |
| 1537 | if string_field(step.metadata.as_ref(), &["side_effect_status"]).as_deref() |
| 1538 | == Some("not_started") |
| 1539 | { |
| 1540 | return FailureEvidence::Refused; |
| 1541 | } |
| 1542 | if failure_text_is_outside(step) { |
| 1543 | return FailureEvidence::Unclear; |
| 1544 | } |
| 1545 | let Some(output) = step.output.as_deref() else { |
| 1546 | return FailureEvidence::Unclear; |
| 1547 | }; |
| 1548 | let lines = || output.lines().map(str::trim); |
| 1549 | if lines().any(|line| { |
| 1550 | SHELL_RAN_LINES |
| 1551 | .iter() |
| 1552 | .any(|prefix| line.starts_with(prefix)) |
| 1553 | }) { |
| 1554 | return FailureEvidence::Ran; |
| 1555 | } |
| 1556 | let not_started = lines() |
| 1557 | .rfind(|line| !line.is_empty()) |
| 1558 | .and_then(|last| last.strip_prefix("Tool validation feedback: ")) |
| 1559 | .and_then(|feedback| serde_json::from_str::<Value>(feedback).ok()) |
| 1560 | .is_some_and(|feedback| { |
| 1561 | feedback.get("side_effect_status").and_then(Value::as_str) == Some("not_started") |
| 1562 | }); |
| 1563 | if not_started { |
| 1564 | return FailureEvidence::Refused; |
| 1565 | } |
| 1566 | let Some(first) = lines().find(|line| !line.is_empty() && !line.starts_with("[approval]")) |
| 1567 | else { |
| 1568 | return FailureEvidence::Unclear; |
| 1569 | }; |
| 1570 | let first = first.strip_prefix("Error: ").unwrap_or(first); |
| 1571 | if first.starts_with("BLOCKED:") { |
| 1572 | let semantic = |
| 1573 | crate::tools::canonical_action::canonical_action_alias(&step.name, &step.input); |
| 1574 | return if SHELL_TOOLS.contains(&semantic) { |
| 1575 | FailureEvidence::Refused |
| 1576 | } else { |
| 1577 | FailureEvidence::Unclear |
| 1578 | }; |
| 1579 | } |
| 1580 | if first.starts_with("Tool '") && first.contains("' denied by user") { |
| 1581 | return FailureEvidence::DeniedAtApproval; |
| 1582 | } |
| 1583 | const REFUSED_PREFIXES: [&str; 6] = [ |
| 1584 | "Invalid input for tool '", |
| 1585 | "Path escapes workspace:", |
| 1586 | // `ToolError` text, as a Runtime thread saves it. |
| 1587 | "Failed to authorize tool execution:", |
| 1588 | "Failed to validate input:", |
| 1589 | "Failed to locate tool:", |
| 1590 | "Failed to resolve path '", |
| 1591 | ]; |
| 1592 | const REFUSED_MARKERS: [&str; 3] = [ |
| 1593 | "' was denied: ", |
| 1594 | "' is not available", |
| 1595 | "' is missing required field ", |
| 1596 | ]; |
| 1597 | let refused = REFUSED_PREFIXES |
| 1598 | .iter() |
| 1599 | .any(|prefix| first.starts_with(prefix)) |
| 1600 | || (first.starts_with("Tool '") |
| 1601 | && REFUSED_MARKERS.iter().any(|marker| first.contains(marker))) |
| 1602 | || first.contains("is not available in Plan mode"); |
| 1603 | if refused { |
| 1604 | FailureEvidence::Refused |
| 1605 | } else { |
| 1606 | FailureEvidence::Unclear |
| 1607 | } |
| 1608 | } |
| 1609 | |
| 1610 | fn nested_calls(facts: Option<&Value>) -> Vec<NestedCall> { |
| 1611 | let Some(calls) = facts |
| 1612 | .and_then(|facts| facts.get("calls")) |
| 1613 | .and_then(Value::as_array) |
| 1614 | else { |
| 1615 | return Vec::new(); |
| 1616 | }; |
| 1617 | calls |
| 1618 | .iter() |
| 1619 | .take(MAX_NESTED_CALLS) |
| 1620 | .filter_map(|call| { |
| 1621 | Some(NestedCall { |
| 1622 | tool: call.get("tool")?.as_str()?.to_string(), |
| 1623 | ok: call.get("ok").and_then(Value::as_bool).unwrap_or(false), |
| 1624 | elapsed_ms: call.get("elapsed_ms").and_then(Value::as_u64), |
| 1625 | }) |
| 1626 | }) |
| 1627 | .collect() |
| 1628 | } |
| 1629 | |
| 1630 | fn string_field(value: Option<&Value>, keys: &[&str]) -> Option<String> { |
| 1631 | let value = value?; |
| 1632 | keys.iter().find_map(|key| { |
| 1633 | value |
| 1634 | .get(*key) |
| 1635 | .and_then(Value::as_str) |
| 1636 | .map(str::trim) |
| 1637 | .filter(|text| !text.is_empty()) |
| 1638 | .map(str::to_string) |
| 1639 | }) |
| 1640 | } |
| 1641 | |
| 1642 | fn number(value: Option<&Value>, keys: &[&str]) -> Option<i64> { |
| 1643 | let value = value?; |
| 1644 | keys.iter() |
| 1645 | .find_map(|key| value.get(*key).and_then(Value::as_i64)) |
| 1646 | } |
| 1647 | |
| 1648 | fn redact(text: &str) -> String { |
| 1649 | codewhale_secrets::redact::redact_secrets(text) |
| 1650 | } |
| 1651 | |
| 1652 | use crate::diagnostics_reports::receipts::{bounded, plural}; |
| 1653 | |
| 1654 | fn first_error_line(output: Option<&str>) -> Option<String> { |
| 1655 | let line = output? |
| 1656 | .lines() |
| 1657 | .map(str::trim) |
| 1658 | .find(|line| !line.is_empty() && !line.starts_with("[approval]"))?; |
| 1659 | let line = line.strip_prefix("Error: ").unwrap_or(line); |
| 1660 | Some(bounded(&redact(line), MAX_ERROR_CHARS)) |
| 1661 | } |
| 1662 | |
| 1663 | // --------------------------------------------------------------------------- |
| 1664 | // Assembly |
| 1665 | // --------------------------------------------------------------------------- |
| 1666 | |
| 1667 | /// What [`assemble`] needs to know about the record besides its steps. |
| 1668 | struct Assembly<'a> { |
| 1669 | turn: Option<&'a str>, |
| 1670 | kind: SourceKind, |
| 1671 | turn_postures: Vec<TurnPosture>, |
| 1672 | /// False when the record predates its approval log, so "no approval on |
| 1673 | /// record" does not mean "did not ask". |
| 1674 | approvals_recorded: bool, |
| 1675 | } |
| 1676 | |
| 1677 | fn assemble( |
| 1678 | source: ReceiptSource, |
| 1679 | steps: Vec<ToolStep>, |
| 1680 | approvals: Vec<ApprovalStep>, |
| 1681 | turn_failures: Vec<(String, Option<DateTime<Utc>>, Option<String>)>, |
| 1682 | workspace_changes: Vec<(String, TurnWorkspaceChange)>, |
| 1683 | scope: Assembly<'_>, |
| 1684 | mut notes: BTreeSet<String>, |
| 1685 | ) -> Receipt { |
| 1686 | let Assembly { |
| 1687 | turn, |
| 1688 | kind, |
| 1689 | turn_postures, |
| 1690 | approvals_recorded, |
| 1691 | } = scope; |
| 1692 | let mut approvals_by_call = HashMap::new(); |
| 1693 | let mut calls_by_id = HashMap::<&str, usize>::new(); |
| 1694 | for step in &steps { |
| 1695 | if let Some(id) = step.call_id.as_deref() { |
| 1696 | *calls_by_id.entry(id).or_default() += 1; |
| 1697 | } |
| 1698 | } |
| 1699 | for (index, approval) in approvals.iter().enumerate() { |
| 1700 | if let Some(call_id) = &approval.call_id { |
| 1701 | approvals_by_call |
| 1702 | .entry(call_id.as_str()) |
| 1703 | .and_modify(|entry| *entry = None) |
| 1704 | .or_insert(Some(index)); |
| 1705 | } |
| 1706 | } |
| 1707 | if steps.iter().any(|step| step.call_id.is_none()) |
| 1708 | || calls_by_id.values().any(|count| *count > 1) |
| 1709 | || approvals_by_call.values().any(Option::is_none) |
| 1710 | { |
| 1711 | notes.insert("Tool identity: missing, mismatched or repeated call identities cannot establish a unique approval association.".to_string()); |
| 1712 | } |
| 1713 | let mut approval_used = vec![false; approvals.len()]; |
| 1714 | let mut totals = ReceiptTotals { |
| 1715 | line_counts_complete: true, |
| 1716 | ..ReceiptTotals::default() |
| 1717 | }; |
| 1718 | let mut actions: Vec<ReceiptAction> = Vec::new(); |
| 1719 | let mut agents: HashMap<String, usize> = HashMap::new(); |
| 1720 | let in_scope = |step_turn: Option<&str>| turn.is_none_or(|turn| step_turn == Some(turn)); |
| 1721 | |
| 1722 | for step in &steps { |
| 1723 | let approval_index = step |
| 1724 | .call_id |
| 1725 | .as_deref() |
| 1726 | .filter(|id| !id.trim().is_empty() && calls_by_id.get(id) == Some(&1)) |
| 1727 | .and_then(|id| approvals_by_call.get(id).copied().flatten()); |
| 1728 | if let Some(index) = approval_index { |
| 1729 | approval_used[index] = true; |
| 1730 | } |
| 1731 | let approval = approval_index.map(|index| approvals[index].fact); |
| 1732 | if !in_scope(step.turn.as_deref()) { |
| 1733 | continue; |
| 1734 | } |
| 1735 | let classified = classify(step, &mut notes); |
| 1736 | let is_command = matches!(classified, Classified::Listed(ActionKind::Command { .. })); |
| 1737 | let held_at_approval = approval.is_some_and(|fact| { |
| 1738 | !fact.decision.ran() && fact.decision != ApprovalDecisionLabel::Pending |
| 1739 | }); |
| 1740 | let status = match step.outcome { |
| 1741 | _ if held_at_approval => ActionStatus::NotRun, |
| 1742 | StepOutcome::Ok => ActionStatus::Ok, |
| 1743 | // A failed result is not proof the call ran: Codewhale answers a |
| 1744 | // call it blocks before running with an error result too. |
| 1745 | StepOutcome::Failed => match failure_evidence(step) { |
| 1746 | FailureEvidence::Ran => ActionStatus::Failed, |
| 1747 | FailureEvidence::Refused => ActionStatus::Blocked, |
| 1748 | FailureEvidence::DeniedAtApproval => ActionStatus::NotRun, |
| 1749 | FailureEvidence::Unclear if is_command => ActionStatus::Unknown, |
| 1750 | FailureEvidence::Unclear => ActionStatus::Failed, |
| 1751 | }, |
| 1752 | StepOutcome::Interrupted => ActionStatus::Interrupted, |
| 1753 | StepOutcome::Running => ActionStatus::Running, |
| 1754 | StepOutcome::Unknown => ActionStatus::Unknown, |
| 1755 | }; |
| 1756 | let what = match classified { |
| 1757 | Classified::Listed(what) => what, |
| 1758 | Classified::AgentFollowUp { agent_id, status } => { |
| 1759 | if let (Some(&index), Some(status)) = (agents.get(&agent_id), status) |
| 1760 | && let ActionKind::Subagent { outcome, .. } = &mut actions[index].what |
| 1761 | { |
| 1762 | *outcome = Some(status); |
| 1763 | } |
| 1764 | totals.other_tool_calls += 1; |
| 1765 | continue; |
| 1766 | } |
| 1767 | Classified::Other => { |
| 1768 | totals.other_tool_calls += 1; |
| 1769 | if !matches!( |
| 1770 | status, |
| 1771 | ActionStatus::Failed | ActionStatus::NotRun | ActionStatus::Blocked |
| 1772 | ) { |
| 1773 | continue; |
| 1774 | } |
| 1775 | ActionKind::Tool |
| 1776 | } |
| 1777 | }; |
| 1778 | if let ActionKind::Subagent { |
| 1779 | agent_id: Some(agent_id), |
| 1780 | .. |
| 1781 | } = &what |
| 1782 | { |
| 1783 | agents.insert(agent_id.clone(), actions.len()); |
| 1784 | } |
| 1785 | let duration_ms = number(step.metadata.as_ref(), &["duration_ms"]) |
| 1786 | .and_then(|ms| u64::try_from(ms).ok()) |
| 1787 | .or_else(|| match (step.started_at, step.ended_at) { |
| 1788 | (Some(start), Some(end)) if end >= start => { |
| 1789 | u64::try_from((end - start).num_milliseconds()).ok() |
| 1790 | } |
| 1791 | _ => None, |
| 1792 | }); |
| 1793 | actions.push(ReceiptAction { |
| 1794 | seq: 0, |
| 1795 | turn: step.turn.clone(), |
| 1796 | at: step.started_at, |
| 1797 | call_id: step.call_id.clone(), |
| 1798 | tool: step.name.clone(), |
| 1799 | what, |
| 1800 | status, |
| 1801 | duration_ms, |
| 1802 | approval, |
| 1803 | // A block's reason is the fact worth keeping; a held call's |
| 1804 | // approval already says why it did not run. |
| 1805 | error: matches!( |
| 1806 | status, |
| 1807 | ActionStatus::Failed | ActionStatus::Unknown | ActionStatus::Blocked |
| 1808 | ) |
| 1809 | .then(|| first_error_line(step.output.as_deref())) |
| 1810 | .flatten(), |
| 1811 | }); |
| 1812 | } |
| 1813 | |
| 1814 | for (index, approval) in approvals.iter().enumerate() { |
| 1815 | if approval_used[index] { |
| 1816 | continue; |
| 1817 | } |
| 1818 | // Session approvals carry no turn; they are in scope only for a |
| 1819 | // whole-session receipt. |
| 1820 | if turn.is_some() && approval.turn.as_deref() != turn { |
| 1821 | continue; |
| 1822 | } |
| 1823 | actions.push(ReceiptAction { |
| 1824 | seq: 0, |
| 1825 | turn: approval.turn.clone(), |
| 1826 | at: approval.fact.at, |
| 1827 | call_id: approval.call_id.clone(), |
| 1828 | tool: approval.tool.clone(), |
| 1829 | what: ActionKind::Approval, |
| 1830 | status: if approval.fact.decision.ran() { |
| 1831 | ActionStatus::Ok |
| 1832 | } else if approval.fact.decision == ApprovalDecisionLabel::Pending { |
| 1833 | ActionStatus::Running |
| 1834 | } else { |
| 1835 | ActionStatus::NotRun |
| 1836 | }, |
| 1837 | duration_ms: None, |
| 1838 | approval: Some(approval.fact), |
| 1839 | error: None, |
| 1840 | }); |
| 1841 | } |
| 1842 | |
| 1843 | for (turn_id, ended_at, error) in turn_failures { |
| 1844 | if !in_scope(Some(&turn_id)) { |
| 1845 | continue; |
| 1846 | } |
| 1847 | actions.push(ReceiptAction { |
| 1848 | seq: 0, |
| 1849 | turn: Some(turn_id), |
| 1850 | at: ended_at, |
| 1851 | call_id: None, |
| 1852 | tool: "turn".to_string(), |
| 1853 | what: ActionKind::TurnFailed, |
| 1854 | status: ActionStatus::Failed, |
| 1855 | duration_ms: None, |
| 1856 | approval: None, |
| 1857 | error: error.map(|error| bounded(&redact(&error), MAX_ERROR_CHARS)), |
| 1858 | }); |
| 1859 | } |
| 1860 | |
| 1861 | for (turn_id, change) in workspace_changes { |
| 1862 | if !in_scope(Some(&turn_id)) { |
| 1863 | continue; |
| 1864 | } |
| 1865 | // File tools already itemize their own paths in this turn. |
| 1866 | let tool_paths: BTreeSet<String> = actions |
| 1867 | .iter() |
| 1868 | .filter(|action| action.turn.as_deref() == Some(turn_id.as_str())) |
| 1869 | .filter(|action| action.status == ActionStatus::Ok) |
| 1870 | .filter_map(|action| match &action.what { |
| 1871 | ActionKind::FileChange { files } => Some(files), |
| 1872 | _ => None, |
| 1873 | }) |
| 1874 | .flatten() |
| 1875 | .map(|file| workspace_relative(&file.path, source.workspace.as_deref())) |
| 1876 | .collect(); |
| 1877 | let files: Vec<FileTouch> = change |
| 1878 | .files |
| 1879 | .into_iter() |
| 1880 | .filter(|file| !tool_paths.contains(&file.path)) |
| 1881 | .collect(); |
| 1882 | if files.is_empty() && !change.truncated { |
| 1883 | continue; |
| 1884 | } |
| 1885 | // Slot it after the turn's last listed action. |
| 1886 | let at = actions |
| 1887 | .iter() |
| 1888 | .rposition(|action| action.turn.as_deref() == Some(turn_id.as_str())) |
| 1889 | .map_or(actions.len(), |index| index + 1); |
| 1890 | actions.insert( |
| 1891 | at, |
| 1892 | ReceiptAction { |
| 1893 | seq: 0, |
| 1894 | turn: Some(turn_id), |
| 1895 | at: None, |
| 1896 | call_id: None, |
| 1897 | tool: "workspace".to_string(), |
| 1898 | what: ActionKind::WorkspaceChange { |
| 1899 | files, |
| 1900 | truncated: change.truncated, |
| 1901 | }, |
| 1902 | status: ActionStatus::Ok, |
| 1903 | duration_ms: None, |
| 1904 | approval: None, |
| 1905 | error: None, |
| 1906 | }, |
| 1907 | ); |
| 1908 | } |
| 1909 | |
| 1910 | if kind == SourceKind::Thread { |
| 1911 | // Runtime actions carry timestamps; keep each turn's order and slot |
| 1912 | // standalone approvals and turn failures where they happened. |
| 1913 | actions.sort_by_key(|action| action.at); |
| 1914 | } |
| 1915 | for (index, action) in actions.iter_mut().enumerate() { |
| 1916 | action.seq = index + 1; |
| 1917 | } |
| 1918 | |
| 1919 | tally(&actions, &mut totals); |
| 1920 | if approvals_recorded { |
| 1921 | totals.ran_without_asking = actions |
| 1922 | .iter() |
| 1923 | .filter(|action| { |
| 1924 | action.ran_without_asking() |
| 1925 | && action.call_id.as_deref().is_some_and(|id| { |
| 1926 | !id.trim().is_empty() |
| 1927 | && calls_by_id.get(id) == Some(&1) |
| 1928 | && approvals_by_call.get(id).is_none_or(Option::is_some) |
| 1929 | }) |
| 1930 | }) |
| 1931 | .count(); |
| 1932 | } |
| 1933 | let mut postures: Vec<&'static str> = Vec::new(); |
| 1934 | for (turn_id, posture) in &turn_postures { |
| 1935 | if let Some(posture) = posture |
| 1936 | && in_scope(Some(turn_id.as_str())) |
| 1937 | && !postures.contains(posture) |
| 1938 | { |
| 1939 | postures.push(posture); |
| 1940 | } |
| 1941 | } |
| 1942 | let decider_not_recorded = |
| 1943 | totals.approvals.approved_by.not_recorded + totals.approvals.denied_by.not_recorded; |
| 1944 | if decider_not_recorded > 0 { |
| 1945 | notes.insert(format!( |
| 1946 | "Who decided: {} decision(s) predate Codewhale recording the decider, or came from a sub-agent, so they show the decision without who made it.", |
| 1947 | decider_not_recorded |
| 1948 | )); |
| 1949 | } |
| 1950 | let unclear = actions |
| 1951 | .iter() |
| 1952 | .filter(|action| action.status == ActionStatus::Unknown) |
| 1953 | .count(); |
| 1954 | if unclear > 0 { |
| 1955 | notes.insert(format!( |
| 1956 | "Whether it ran: {unclear} call(s) have no result, or returned an error with no exit code, so the record does not show that they started. They are listed but not counted as run." |
| 1957 | )); |
| 1958 | } |
| 1959 | |
| 1960 | let omitted_actions = actions.len().saturating_sub(MAX_RECEIPT_ACTIONS); |
| 1961 | actions.truncate(MAX_RECEIPT_ACTIONS); |
| 1962 | Receipt { |
| 1963 | schema_id: RECEIPT_SCHEMA_ID, |
| 1964 | source, |
| 1965 | turn: turn.map(str::to_string), |
| 1966 | postures, |
| 1967 | totals, |
| 1968 | actions, |
| 1969 | omitted_actions, |
| 1970 | not_recorded: notes.into_iter().collect(), |
| 1971 | claim_ceiling: CLAIM_CEILING, |
| 1972 | } |
| 1973 | } |
| 1974 | |
| 1975 | fn tally(actions: &[ReceiptAction], totals: &mut ReceiptTotals) { |
| 1976 | let mut changed: BTreeSet<&str> = BTreeSet::new(); |
| 1977 | let mut created: BTreeSet<&str> = BTreeSet::new(); |
| 1978 | let mut deleted: BTreeSet<&str> = BTreeSet::new(); |
| 1979 | let mut outside: BTreeSet<&str> = BTreeSet::new(); |
| 1980 | for action in actions { |
| 1981 | let ran = !matches!( |
| 1982 | action.status, |
| 1983 | ActionStatus::NotRun | ActionStatus::Blocked | ActionStatus::Unknown |
| 1984 | ); |
| 1985 | match action.status { |
| 1986 | ActionStatus::Failed => totals.failures += 1, |
| 1987 | ActionStatus::Blocked => totals.blocked += 1, |
| 1988 | _ => {} |
| 1989 | } |
| 1990 | match &action.what { |
| 1991 | ActionKind::FileChange { files } | ActionKind::WorkspaceChange { files, .. } |
| 1992 | if action.status == ActionStatus::Ok => |
| 1993 | { |
| 1994 | if matches!(action.what, ActionKind::WorkspaceChange { .. }) { |
| 1995 | outside.extend(files.iter().map(|file| file.path.as_str())); |
| 1996 | } |
| 1997 | for file in files { |
| 1998 | changed.insert(&file.path); |
| 1999 | match file.change { |
| 2000 | FileChangeKind::Created => { |
| 2001 | created.insert(&file.path); |
| 2002 | } |
| 2003 | FileChangeKind::Deleted => { |
| 2004 | deleted.insert(&file.path); |
| 2005 | } |
| 2006 | FileChangeKind::Edited | FileChangeKind::Written => {} |
| 2007 | } |
| 2008 | match (file.lines_added, file.lines_removed) { |
| 2009 | (Some(added), Some(removed)) => { |
| 2010 | totals.lines_added += added; |
| 2011 | totals.lines_removed += removed; |
| 2012 | } |
| 2013 | _ => totals.line_counts_complete = false, |
| 2014 | } |
| 2015 | } |
| 2016 | } |
| 2017 | ActionKind::Command { .. } if ran => { |
| 2018 | totals.commands += 1; |
| 2019 | if action.status == ActionStatus::Failed { |
| 2020 | totals.commands_failed += 1; |
| 2021 | } |
| 2022 | } |
| 2023 | ActionKind::Code { .. } if ran => totals.code_runs += 1, |
| 2024 | ActionKind::Network { .. } if ran => totals.network += 1, |
| 2025 | ActionKind::Mcp { plugin, .. } if ran => { |
| 2026 | totals.mcp_calls += 1; |
| 2027 | if *plugin { |
| 2028 | totals.plugin_calls += 1; |
| 2029 | } |
| 2030 | } |
| 2031 | ActionKind::Subagent { .. } if ran => totals.subagents += 1, |
| 2032 | _ => {} |
| 2033 | } |
| 2034 | if let Some(fact) = action.approval { |
| 2035 | let approvals = &mut totals.approvals; |
| 2036 | approvals.total += 1; |
| 2037 | match fact.decision { |
| 2038 | ApprovalDecisionLabel::Approved | ApprovalDecisionLabel::ApprovedWithPolicy => { |
| 2039 | approvals.approved += 1 |
| 2040 | } |
| 2041 | ApprovalDecisionLabel::Denied => approvals.denied += 1, |
| 2042 | ApprovalDecisionLabel::TimedOut => approvals.timed_out += 1, |
| 2043 | ApprovalDecisionLabel::Cancelled | ApprovalDecisionLabel::Unavailable => { |
| 2044 | approvals.not_answered += 1 |
| 2045 | } |
| 2046 | ApprovalDecisionLabel::Pending => approvals.pending += 1, |
| 2047 | } |
| 2048 | let by = match fact.decision { |
| 2049 | ApprovalDecisionLabel::Approved | ApprovalDecisionLabel::ApprovedWithPolicy => { |
| 2050 | &mut approvals.approved_by |
| 2051 | } |
| 2052 | ApprovalDecisionLabel::Denied => &mut approvals.denied_by, |
| 2053 | _ => continue, |
| 2054 | }; |
| 2055 | match fact.decided_by { |
| 2056 | Some(ApprovalDecider::User) => by.you += 1, |
| 2057 | Some(ApprovalDecider::SessionRule) => by.session_rule += 1, |
| 2058 | Some(ApprovalDecider::Posture) => by.posture += 1, |
| 2059 | // Codewhale never approves, and its denials are read as not |
| 2060 | // answered (`approvals_from_replay`), so this is only a |
| 2061 | // record that says neither. |
| 2062 | Some(ApprovalDecider::Host) | None => by.not_recorded += 1, |
| 2063 | } |
| 2064 | } |
| 2065 | } |
| 2066 | totals.files_changed = changed.len(); |
| 2067 | totals.files_created = created.len(); |
| 2068 | totals.files_deleted = deleted.len(); |
| 2069 | totals.files_changed_outside_file_tools = outside.len(); |
| 2070 | } |
| 2071 | |
| 2072 | // --------------------------------------------------------------------------- |
| 2073 | // Rendering |
| 2074 | // --------------------------------------------------------------------------- |
| 2075 | |
| 2076 | #[cfg(test)] |
| 2077 | use crate::diagnostics_reports::receipts::code_span; |
| 2078 | #[cfg(test)] |
| 2079 | use crate::diagnostics_reports::receipts::{action_line, totals_line}; |
| 2080 | pub use crate::diagnostics_reports::receipts::{render_json, render_markdown}; |
| 2081 | |
| 2082 | #[cfg(test)] |
| 2083 | #[path = "receipts/tests.rs"] |
| 2084 | mod tests; |
| 2085 |