返回 CodeWhale
receipts.rs
根目录 / crates / tui / src / receipts.rs
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) = &current {
1194 counts.entry(path.clone()).or_default();
1195 }
1196 }
1197 DiffLine::FileStart => current = None,
1198 DiffLine::Added | DiffLine::Removed => {
1199 let Some(path) = &current 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
2085 lines RUST