返回 CodeWhale
debug_receipts.rs
根目录 / crates / command-contract / src / facets / debug_receipts.rs
1 //! Receipt output data, moved from the shared receipt builder without schema changes.
2 //! The builder stays host-owned; CLI, API and slash commands share these shapes.
3
4 use chrono::{DateTime, Utc};
5 use serde::{Deserialize, Serialize};
6
7 /// Maximum number of listed actions; totals include omitted actions.
8 pub const MAX_RECEIPT_ACTIONS: usize = 2_000;
9 pub const RECEIPT_SCHEMA_ID: &str = "codewhale.receipt/v1";
10
11 /// Who resolved an approval request. Recorded on the decision half so a
12 /// receipt says "approved by you" only when a person answered. Records
13 /// written before this field existed carry no decider; readers report it as
14 /// not recorded rather than guessing.
15 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16 #[serde(rename_all = "snake_case")]
17 pub enum ApprovalDecider {
18 /// A person answered the prompt: the terminal card, the app, the web
19 /// mirror, or a Runtime API client acting for them.
20 User,
21 /// A remembered "allow/deny for this session" rule answered it.
22 SessionRule,
23 /// The active mode or permission posture answered it without a prompt.
24 Posture,
25 /// The host resolved it without a person: the turn had ended, was
26 /// cancelled, or the decision channel closed.
27 Host,
28 }
29
30 #[derive(Debug, Clone, Serialize, PartialEq)]
31 pub struct Receipt {
32 pub schema_id: &'static str,
33 pub source: ReceiptSource,
34 /// Set when the receipt covers one turn instead of the whole session.
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub turn: Option<String>,
37 /// Permission postures the covered turns ran under, in first-seen order
38 /// (`Ask`, `Auto-Review`, `Full Access`, `Never`). Read from each turn's
39 /// own record; empty when no turn recorded one.
40 pub postures: Vec<&'static str>,
41 pub totals: ReceiptTotals,
42 pub actions: Vec<ReceiptAction>,
43 /// Actions left off the list because it hit [`MAX_RECEIPT_ACTIONS`].
44 pub omitted_actions: usize,
45 /// Facts this record does not hold, stated instead of guessed.
46 pub not_recorded: Vec<String>,
47 pub claim_ceiling: [&'static str; 3],
48 }
49
50 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
51 #[serde(rename_all = "snake_case")]
52 pub enum SourceKind {
53 /// A terminal session (saved transcript + approval log).
54 Session,
55 /// A Runtime thread (turn/item records + event log).
56 Thread,
57 }
58
59 #[derive(Debug, Clone, Serialize, PartialEq)]
60 pub struct ReceiptSource {
61 pub kind: SourceKind,
62 pub id: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub title: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub workspace: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub model: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub started_at: Option<DateTime<Utc>>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub updated_at: Option<DateTime<Utc>>,
73 }
74
75 #[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
76 pub struct ReceiptTotals {
77 /// Distinct paths changed, by file tools or (from the turn's workspace
78 /// snapshots) by anything else during the turn.
79 pub files_changed: usize,
80 /// Of `files_changed`, paths no file tool changed: a command, a build, or
81 /// another process wrote them during the turn.
82 pub files_changed_outside_file_tools: usize,
83 pub files_created: usize,
84 pub files_deleted: usize,
85 /// Sum over changes whose line counts are recorded.
86 pub lines_added: u64,
87 pub lines_removed: u64,
88 /// False when at least one file change has no recorded line counts, so
89 /// the sums above are a floor.
90 pub line_counts_complete: bool,
91 pub commands: usize,
92 pub commands_failed: usize,
93 pub code_runs: usize,
94 pub network: usize,
95 pub mcp_calls: usize,
96 pub plugin_calls: usize,
97 pub subagents: usize,
98 pub approvals: ApprovalTotals,
99 /// File changes, commands, code runs, web and MCP calls, and agents that
100 /// ran with no approval on record: the posture, an allow rule, or a
101 /// remembered grant let them run without a prompt. A call Codewhale
102 /// refused before it started, or one the record does not show starting,
103 /// is not counted. Zero, with a `not_recorded` note, for a session older
104 /// than its approval log.
105 pub ran_without_asking: usize,
106 /// Actions that ran and failed, plus failed turns.
107 pub failures: usize,
108 /// Calls Codewhale refused before they started: an Auto-Review or
109 /// guardian block, a tool-policy or allow-list denial, a sandbox
110 /// escalation the posture cannot grant, invalid input, or a tool that is
111 /// not available. Not counted as run, as failed, or as ran without asking.
112 pub blocked: usize,
113 /// Reads, searches, and other calls that are counted but not listed
114 /// unless they failed.
115 pub other_tool_calls: usize,
116 }
117
118 #[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
119 pub struct ApprovalTotals {
120 pub total: usize,
121 pub approved: usize,
122 pub denied: usize,
123 pub timed_out: usize,
124 /// Cancelled, or resolved by Codewhale because nobody could be asked
125 /// (the turn had ended or stopped). Never a person's no.
126 pub not_answered: usize,
127 pub pending: usize,
128 /// Who gave each approval counted in `approved`.
129 pub approved_by: DeciderCounts,
130 /// Who gave each denial counted in `denied`.
131 pub denied_by: DeciderCounts,
132 }
133
134 #[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
135 pub struct DeciderCounts {
136 pub you: usize,
137 pub session_rule: usize,
138 pub posture: usize,
139 /// The record predates Codewhale keeping who decided, or came from a
140 /// sub-agent's request, which does not carry it yet.
141 pub not_recorded: usize,
142 }
143
144 #[derive(Debug, Clone, Serialize, PartialEq)]
145 pub struct ReceiptAction {
146 /// 1-based position in the session's action order.
147 pub seq: usize,
148 /// Runtime turn id, or the 1-based turn number in a terminal session.
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub turn: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub at: Option<DateTime<Utc>>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub call_id: Option<String>,
155 /// The tool name exactly as called.
156 pub tool: String,
157 #[serde(flatten)]
158 pub what: ActionKind,
159 pub status: ActionStatus,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub duration_ms: Option<u64>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub approval: Option<ApprovalFact>,
164 /// First line of the failure, bounded and redacted.
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub error: Option<String>,
167 }
168
169 #[derive(Debug, Clone, Serialize, PartialEq)]
170 #[serde(tag = "kind", rename_all = "snake_case")]
171 pub enum ActionKind {
172 FileChange {
173 files: Vec<FileTouch>,
174 },
175 /// Files that changed in the workspace during a turn with no file tool
176 /// naming them, read from the turn's before/after snapshots. A command
177 /// changed them, or something else writing to the workspace did.
178 WorkspaceChange {
179 files: Vec<FileTouch>,
180 /// More paths changed than are listed.
181 #[serde(skip_serializing_if = "std::ops::Not::not")]
182 truncated: bool,
183 },
184 Command {
185 command: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 cwd: Option<String>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 exit_code: Option<i64>,
190 },
191 Code {
192 #[serde(skip_serializing_if = "Option::is_none")]
193 exit_code: Option<i64>,
194 /// Tool calls the program made (`execute_tools`), when recorded.
195 #[serde(skip_serializing_if = "Vec::is_empty")]
196 nested: Vec<NestedCall>,
197 },
198 Network {
199 action: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 host: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 query: Option<String>,
204 },
205 Mcp {
206 #[serde(skip_serializing_if = "Option::is_none")]
207 server: Option<String>,
208 plugin: bool,
209 },
210 Subagent {
211 #[serde(skip_serializing_if = "Option::is_none")]
212 name: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
214 agent_id: Option<String>,
215 /// Last status the record holds for this agent.
216 #[serde(skip_serializing_if = "Option::is_none")]
217 outcome: Option<String>,
218 },
219 /// An approval with no matching call in the record (for example a
220 /// sub-agent's request).
221 Approval,
222 /// Any other tool. Listed only when it failed.
223 Tool,
224 /// A Runtime turn that ended in failure.
225 TurnFailed,
226 }
227
228 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
229 #[serde(rename_all = "snake_case")]
230 pub enum FileChangeKind {
231 Edited,
232 Created,
233 Deleted,
234 /// Written whole; the record does not say whether the file existed.
235 Written,
236 }
237
238 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
239 pub struct FileTouch {
240 pub path: String,
241 pub change: FileChangeKind,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub lines_added: Option<u64>,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub lines_removed: Option<u64>,
246 }
247
248 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
249 pub struct NestedCall {
250 pub tool: String,
251 pub ok: bool,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub elapsed_ms: Option<u64>,
254 }
255
256 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
257 #[serde(rename_all = "snake_case")]
258 pub enum ActionStatus {
259 Ok,
260 /// Ran and failed.
261 Failed,
262 /// Did not run: held at approval (denied, timed out, never answered).
263 NotRun,
264 /// Did not run: Codewhale refused it before it started (an Auto-Review
265 /// or guardian block, a policy or allow-list denial, a sandbox escalation
266 /// the posture cannot grant, invalid input, a tool that is not
267 /// available). `error` carries the reason.
268 Blocked,
269 Interrupted,
270 Running,
271 /// The record does not show whether it ran: there is no result, or a
272 /// command returned an error with no exit code or shell status.
273 Unknown,
274 }
275
276 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
277 #[serde(rename_all = "snake_case")]
278 pub enum ApprovalDecisionLabel {
279 Approved,
280 /// Approved with a wider sandbox after a sandbox denial.
281 ApprovedWithPolicy,
282 Denied,
283 TimedOut,
284 Cancelled,
285 /// The host answered because nobody could be asked.
286 Unavailable,
287 Pending,
288 }
289
290 impl ApprovalDecisionLabel {
291 pub fn ran(self) -> bool {
292 matches!(self, Self::Approved | Self::ApprovedWithPolicy)
293 }
294 }
295
296 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
297 pub struct ApprovalFact {
298 pub decision: ApprovalDecisionLabel,
299 /// `None` when the decision names its own cause (timeout, pending) or the
300 /// record predates deciders.
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub decided_by: Option<ApprovalDecider>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub at: Option<DateTime<Utc>>,
305 }
306
307 impl ReceiptAction {
308 /// A change, command, code run, web or MCP call, or agent that ran with
309 /// no approval on record.
310 pub fn ran_without_asking(&self) -> bool {
311 // `Unknown`, `NotRun`, and `Blocked` are left out: the call did not
312 // start, or the record does not show that it did. A workspace change
313 // is not a call.
314 self.approval.is_none()
315 && matches!(
316 self.status,
317 ActionStatus::Ok
318 | ActionStatus::Failed
319 | ActionStatus::Interrupted
320 | ActionStatus::Running
321 )
322 && matches!(
323 self.what,
324 ActionKind::FileChange { .. }
325 | ActionKind::Command { .. }
326 | ActionKind::Code { .. }
327 | ActionKind::Network { .. }
328 | ActionKind::Mcp { .. }
329 | ActionKind::Subagent { .. }
330 )
331 }
332 }
333
333 lines RUST