1mod projection;
4mod render;
5#[cfg(test)]
6mod tests;
7
8use crate::{SessionEventLog, SessionStoreError};
9pub use render::{render_details, render_diagram, render_html, render_html_with_workspace, render_summary};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use vtcode_exec_events::{ThreadEvent, TokenBreakdown, Usage, VersionedThreadEvent};
13
14#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ExplanationScope {
18 #[default]
20 Task,
21 Session,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
27pub struct EvidenceRef {
28 pub session_id: String,
30 pub offset: u64,
32 pub length: u64,
34 pub digest: String,
36 pub item_id: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct ExplanationEntry {
43 pub label: String,
45 pub status: String,
47 pub evidence: EvidenceRef,
49 pub task_id: Option<String>,
51 pub actor_id: Option<String>,
53 pub parent_actor_id: Option<String>,
55 pub timestamp: Option<String>,
57 pub path: Option<String>,
59 pub line: Option<u64>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct VerificationEntry {
66 pub fact: ExplanationEntry,
68 pub exit_code: Option<i32>,
70 pub fresh: bool,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct TokenBreakdownEntry {
77 pub fact: ExplanationEntry,
79 pub breakdown: TokenBreakdown,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84pub struct DecisionEntry {
86 pub fact: ExplanationEntry,
88 pub rationale: String,
90 pub alternatives: Vec<String>,
92 pub evidence_ids: Vec<String>,
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
97#[serde(rename_all = "snake_case")]
98pub enum ReviewPriority {
100 High,
102 Medium,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct ReviewSignal {
109 pub priority: ReviewPriority,
111 pub reason: String,
113 pub fact: ExplanationEntry,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct GraphEdge {
120 pub from: String,
122 pub to: String,
124 pub relation: String,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
129pub struct EvidenceCompleteness {
131 pub malformed_records: usize,
133 pub unknown_records: usize,
135 pub legacy_records: usize,
137 pub evicted_turns: u64,
139 pub warnings: Vec<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct ExplanationModel {
146 pub session_id: String,
148 pub revision: String,
150 pub scope: ExplanationScope,
152 pub task_id: Option<String>,
154 pub status: String,
156 pub goals: Vec<ExplanationEntry>,
158 pub actions: Vec<ExplanationEntry>,
160 pub changes: Vec<ExplanationEntry>,
162 pub edit_operations: usize,
164 pub decisions: Vec<DecisionEntry>,
166 pub verification: Vec<VerificationEntry>,
168 pub token_breakdowns: Vec<TokenBreakdownEntry>,
170 pub failures: Vec<ExplanationEntry>,
172 pub review_priorities: Vec<ReviewSignal>,
174 pub plan_evolution: Vec<ExplanationEntry>,
176 pub graph: Vec<GraphEdge>,
178 pub usage: Option<Usage>,
180 pub cost_usd: Option<serde_json::Number>,
182 pub completeness: EvidenceCompleteness,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct EvidencePage {
189 pub reference: EvidenceRef,
191 pub text: String,
193 pub offset: usize,
195 pub next_offset: Option<usize>,
197 pub total_bytes: usize,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct ExplanationPage {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub workspace_diff: Option<WorkspaceDiffSnapshot>,
208 pub model: ExplanationModel,
210 pub offset: usize,
212 pub next_offset: Option<usize>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218pub struct WorkspaceDiffSnapshot {
219 pub captured_at: String,
221 pub text: Option<String>,
223 pub truncated: bool,
225 pub note: String,
227}
228
229pub fn page_explanation(model: &ExplanationModel, offset: usize) -> ExplanationPage {
231 fn page<T: Clone>(items: &[T], offset: usize, count: usize) -> Vec<T> {
232 items.iter().skip(offset).take(count).cloned().collect()
233 }
234 let maximum = [
235 model.goals.len(),
236 model.actions.len(),
237 model.changes.len(),
238 model.decisions.len(),
239 model.verification.len(),
240 model.token_breakdowns.len(),
241 model.failures.len(),
242 model.review_priorities.len(),
243 model.plan_evolution.len(),
244 model.graph.len(),
245 ]
246 .into_iter()
247 .max()
248 .unwrap_or(0);
249 let mut count = 32;
250 loop {
251 let result = ExplanationPage {
252 workspace_diff: None,
253 model: ExplanationModel {
254 session_id: model.session_id.clone(),
255 revision: model.revision.clone(),
256 scope: model.scope,
257 task_id: model.task_id.clone(),
258 status: model.status.clone(),
259 goals: page(&model.goals, offset, count),
260 actions: page(&model.actions, offset, count),
261 changes: page(&model.changes, offset, count),
262 edit_operations: model.edit_operations,
263 decisions: page(&model.decisions, offset, count),
264 verification: page(&model.verification, offset, count),
265 token_breakdowns: page(&model.token_breakdowns, offset, count),
266 failures: page(&model.failures, offset, count),
267 review_priorities: page(&model.review_priorities, offset, count),
268 plan_evolution: page(&model.plan_evolution, offset, count),
269 graph: page(&model.graph, offset, count),
270 usage: model.usage.clone(),
271 cost_usd: model.cost_usd.clone(),
272 completeness: model.completeness.clone(),
273 },
274 offset,
275 next_offset: (offset.saturating_add(count) < maximum).then_some(offset.saturating_add(count)),
276 };
277 if count == 1 || serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 64 * 1024) {
278 return result;
279 }
280 count /= 2;
281 }
282}
283
284impl ExplanationModel {
285 pub fn evidence_references(&self) -> Vec<EvidenceRef> {
287 let mut refs: Vec<_> = self
288 .goals
289 .iter()
290 .chain(&self.actions)
291 .chain(&self.changes)
292 .chain(&self.failures)
293 .chain(&self.plan_evolution)
294 .chain(self.decisions.iter().map(|d| &d.fact))
295 .chain(self.verification.iter().map(|v| &v.fact))
296 .chain(self.token_breakdowns.iter().map(|entry| &entry.fact))
297 .chain(self.review_priorities.iter().map(|r| &r.fact))
298 .map(|e| e.evidence.clone())
299 .collect();
300 refs.sort();
301 refs.dedup();
302 refs
303 }
304}
305
306pub fn query_explanation(
308 log: &SessionEventLog,
309 scope: ExplanationScope,
310) -> Result<ExplanationModel, SessionStoreError> {
311 let session_id = log.manifest().session_id;
312 let mut reducer = projection::Reducer::new(session_id.clone(), scope);
313 let mut revision = Sha256::new();
314 let manifest = log.visit_snapshot(|offset, bytes| {
315 revision.update(bytes);
316 let reference = EvidenceRef {
317 session_id: session_id.clone(),
318 offset,
319 length: bytes.len() as u64,
320 digest: hex(&Sha256::digest(bytes)),
321 item_id: None,
322 };
323 match serde_json::from_slice::<VersionedThreadEvent>(bytes) {
324 Ok(v) => reducer.push(v.into_event(), reference),
325 Err(_) => reducer.malformed(),
326 }
327 })?;
328 let evicted_turns = manifest.evicted_turn_count();
329 Ok(reducer.finish(hex(&revision.finalize()), evicted_turns))
330}
331
332pub fn query_evidence(
335 log: &SessionEventLog,
336 reference: &EvidenceRef,
337 offset: usize,
338 limit: usize,
339) -> Result<EvidencePage, SessionStoreError> {
340 if reference.session_id != log.manifest().session_id {
341 return Err(evidence_error("evidence belongs to another session"));
342 }
343 let mut found = None;
344 log.visit_snapshot(|position, bytes| {
345 if position == reference.offset
346 && bytes.len() as u64 == reference.length
347 && hex(&Sha256::digest(bytes)) == reference.digest
348 {
349 if let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) {
351 redact_value(&mut value);
352 found = serde_json::to_string_pretty(&value).ok();
353 }
354 }
355 })?;
356 let text = found.ok_or_else(|| evidence_error("evidence is unavailable, malformed, or expired"))?;
357 if offset > text.len() || !text.is_char_boundary(offset) {
358 return Err(evidence_error("invalid evidence page offset"));
359 }
360 let mut end = offset.saturating_add(limit.clamp(1, 32 * 1024)).min(text.len());
361 while !text.is_char_boundary(end) {
362 end = end.saturating_sub(1);
363 }
364 if end == offset && offset < text.len() {
366 end += text[offset..].chars().next().map_or(0, char::len_utf8);
367 }
368 Ok(EvidencePage {
369 reference: reference.clone(),
370 text: text[offset..end].to_owned(),
371 offset,
372 next_offset: (end < text.len()).then_some(end),
373 total_bytes: text.len(),
374 })
375}
376
377fn evidence_error(message: &str) -> SessionStoreError {
378 SessionStoreError::io("events.jsonl", std::io::Error::new(std::io::ErrorKind::InvalidData, message))
379}
380
381fn hex(bytes: &[u8]) -> String {
382 bytes.iter().map(|b| format!("{b:02x}")).collect()
383}
384
385fn public_text(text: &str, limit: usize) -> String {
386 let clean: String = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
387 let redacted = vtcode_commons::sanitizer::redact_secrets(clean);
388 let mut end = redacted.len().min(limit);
389 while !redacted.is_char_boundary(end) {
390 end -= 1;
391 }
392 let mut result = redacted[..end].to_owned();
393 if redacted.len() > limit {
394 result.push_str(" [truncated; inspect evidence]");
395 }
396 result
397}
398
399fn public_identity(text: &str) -> String {
400 if text
401 .strip_prefix("task-")
402 .or_else(|| text.strip_prefix("turn-"))
403 .is_some_and(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok())
404 {
405 return text.to_owned();
406 }
407 let redacted = public_text(text, 256);
408 if redacted == text {
409 redacted
410 } else {
411 format!("redacted-id-{}", hex(&Sha256::digest(text.as_bytes())))
412 }
413}
414
415fn public_path(text: &str) -> String {
416 let label = public_text(text, 900);
417 if label == text {
418 label
419 } else {
420 format!("{label} [path-{}]", hex(&Sha256::digest(text.as_bytes())))
421 }
422}
423
424fn redact_value(value: &mut serde_json::Value) {
425 match value {
426 serde_json::Value::String(s) => *s = public_text(s, usize::MAX),
427 serde_json::Value::Array(a) => {
428 for value in a {
429 redact_value(value);
430 }
431 }
432 serde_json::Value::Object(o) => {
433 for (key, v) in o {
434 if matches!(
435 key.to_ascii_lowercase().replace(['_', '-'], "").as_str(),
436 "token"
437 | "password"
438 | "passwd"
439 | "authorization"
440 | "apikey"
441 | "secret"
442 | "accesstoken"
443 | "refreshtoken"
444 | "clientsecret"
445 | "privatekey"
446 | "credential"
447 | "credentials"
448 ) {
449 *v = serde_json::Value::String("[REDACTED]".into());
450 } else {
451 redact_value(v);
452 }
453 }
454 }
455 _ => {}
456 }
457}