mod projection;
mod render;
#[cfg(test)]
mod tests;
use crate::{SessionEventLog, SessionStoreError};
pub use render::{render_details, render_diagram, render_html, render_html_with_workspace, render_summary};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use vtcode_exec_events::{ThreadEvent, TokenBreakdown, Usage, VersionedThreadEvent};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExplanationScope {
#[default]
Task,
Session,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct EvidenceRef {
pub session_id: String,
pub offset: u64,
pub length: u64,
pub digest: String,
pub item_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExplanationEntry {
pub label: String,
pub status: String,
pub evidence: EvidenceRef,
pub task_id: Option<String>,
pub actor_id: Option<String>,
pub parent_actor_id: Option<String>,
pub timestamp: Option<String>,
pub path: Option<String>,
pub line: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VerificationEntry {
pub fact: ExplanationEntry,
pub exit_code: Option<i32>,
pub fresh: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TokenBreakdownEntry {
pub fact: ExplanationEntry,
pub breakdown: TokenBreakdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DecisionEntry {
pub fact: ExplanationEntry,
pub rationale: String,
pub alternatives: Vec<String>,
pub evidence_ids: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ReviewPriority {
High,
Medium,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReviewSignal {
pub priority: ReviewPriority,
pub reason: String,
pub fact: ExplanationEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GraphEdge {
pub from: String,
pub to: String,
pub relation: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct EvidenceCompleteness {
pub malformed_records: usize,
pub unknown_records: usize,
pub legacy_records: usize,
pub evicted_turns: u64,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExplanationModel {
pub session_id: String,
pub revision: String,
pub scope: ExplanationScope,
pub task_id: Option<String>,
pub status: String,
pub goals: Vec<ExplanationEntry>,
pub actions: Vec<ExplanationEntry>,
pub changes: Vec<ExplanationEntry>,
pub edit_operations: usize,
pub decisions: Vec<DecisionEntry>,
pub verification: Vec<VerificationEntry>,
pub token_breakdowns: Vec<TokenBreakdownEntry>,
pub failures: Vec<ExplanationEntry>,
pub review_priorities: Vec<ReviewSignal>,
pub plan_evolution: Vec<ExplanationEntry>,
pub graph: Vec<GraphEdge>,
pub usage: Option<Usage>,
pub cost_usd: Option<serde_json::Number>,
pub completeness: EvidenceCompleteness,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EvidencePage {
pub reference: EvidenceRef,
pub text: String,
pub offset: usize,
pub next_offset: Option<usize>,
pub total_bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExplanationPage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_diff: Option<WorkspaceDiffSnapshot>,
pub model: ExplanationModel,
pub offset: usize,
pub next_offset: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceDiffSnapshot {
pub captured_at: String,
pub text: Option<String>,
pub truncated: bool,
pub note: String,
}
pub fn page_explanation(model: &ExplanationModel, offset: usize) -> ExplanationPage {
fn page<T: Clone>(items: &[T], offset: usize, count: usize) -> Vec<T> {
items.iter().skip(offset).take(count).cloned().collect()
}
let maximum = [
model.goals.len(),
model.actions.len(),
model.changes.len(),
model.decisions.len(),
model.verification.len(),
model.token_breakdowns.len(),
model.failures.len(),
model.review_priorities.len(),
model.plan_evolution.len(),
model.graph.len(),
]
.into_iter()
.max()
.unwrap_or(0);
let mut count = 32;
loop {
let result = ExplanationPage {
workspace_diff: None,
model: ExplanationModel {
session_id: model.session_id.clone(),
revision: model.revision.clone(),
scope: model.scope,
task_id: model.task_id.clone(),
status: model.status.clone(),
goals: page(&model.goals, offset, count),
actions: page(&model.actions, offset, count),
changes: page(&model.changes, offset, count),
edit_operations: model.edit_operations,
decisions: page(&model.decisions, offset, count),
verification: page(&model.verification, offset, count),
token_breakdowns: page(&model.token_breakdowns, offset, count),
failures: page(&model.failures, offset, count),
review_priorities: page(&model.review_priorities, offset, count),
plan_evolution: page(&model.plan_evolution, offset, count),
graph: page(&model.graph, offset, count),
usage: model.usage.clone(),
cost_usd: model.cost_usd.clone(),
completeness: model.completeness.clone(),
},
offset,
next_offset: (offset.saturating_add(count) < maximum).then_some(offset.saturating_add(count)),
};
if count == 1 || serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 64 * 1024) {
return result;
}
count /= 2;
}
}
impl ExplanationModel {
pub fn evidence_references(&self) -> Vec<EvidenceRef> {
let mut refs: Vec<_> = self
.goals
.iter()
.chain(&self.actions)
.chain(&self.changes)
.chain(&self.failures)
.chain(&self.plan_evolution)
.chain(self.decisions.iter().map(|d| &d.fact))
.chain(self.verification.iter().map(|v| &v.fact))
.chain(self.token_breakdowns.iter().map(|entry| &entry.fact))
.chain(self.review_priorities.iter().map(|r| &r.fact))
.map(|e| e.evidence.clone())
.collect();
refs.sort();
refs.dedup();
refs
}
}
pub fn query_explanation(
log: &SessionEventLog,
scope: ExplanationScope,
) -> Result<ExplanationModel, SessionStoreError> {
let session_id = log.manifest().session_id;
let mut reducer = projection::Reducer::new(session_id.clone(), scope);
let mut revision = Sha256::new();
let manifest = log.visit_snapshot(|offset, bytes| {
revision.update(bytes);
let reference = EvidenceRef {
session_id: session_id.clone(),
offset,
length: bytes.len() as u64,
digest: hex(&Sha256::digest(bytes)),
item_id: None,
};
match serde_json::from_slice::<VersionedThreadEvent>(bytes) {
Ok(v) => reducer.push(v.into_event(), reference),
Err(_) => reducer.malformed(),
}
})?;
let evicted_turns = manifest.evicted_turn_count();
Ok(reducer.finish(hex(&revision.finalize()), evicted_turns))
}
pub fn query_evidence(
log: &SessionEventLog,
reference: &EvidenceRef,
offset: usize,
limit: usize,
) -> Result<EvidencePage, SessionStoreError> {
if reference.session_id != log.manifest().session_id {
return Err(evidence_error("evidence belongs to another session"));
}
let mut found = None;
log.visit_snapshot(|position, bytes| {
if position == reference.offset
&& bytes.len() as u64 == reference.length
&& hex(&Sha256::digest(bytes)) == reference.digest
{
if let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) {
redact_value(&mut value);
found = serde_json::to_string_pretty(&value).ok();
}
}
})?;
let text = found.ok_or_else(|| evidence_error("evidence is unavailable, malformed, or expired"))?;
if offset > text.len() || !text.is_char_boundary(offset) {
return Err(evidence_error("invalid evidence page offset"));
}
let mut end = offset.saturating_add(limit.clamp(1, 32 * 1024)).min(text.len());
while !text.is_char_boundary(end) {
end = end.saturating_sub(1);
}
if end == offset && offset < text.len() {
end += text[offset..].chars().next().map_or(0, char::len_utf8);
}
Ok(EvidencePage {
reference: reference.clone(),
text: text[offset..end].to_owned(),
offset,
next_offset: (end < text.len()).then_some(end),
total_bytes: text.len(),
})
}
fn evidence_error(message: &str) -> SessionStoreError {
SessionStoreError::io("events.jsonl", std::io::Error::new(std::io::ErrorKind::InvalidData, message))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn public_text(text: &str, limit: usize) -> String {
let clean: String = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
let redacted = vtcode_commons::sanitizer::redact_secrets(clean);
let mut end = redacted.len().min(limit);
while !redacted.is_char_boundary(end) {
end -= 1;
}
let mut result = redacted[..end].to_owned();
if redacted.len() > limit {
result.push_str(" [truncated; inspect evidence]");
}
result
}
fn public_identity(text: &str) -> String {
if text
.strip_prefix("task-")
.or_else(|| text.strip_prefix("turn-"))
.is_some_and(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok())
{
return text.to_owned();
}
let redacted = public_text(text, 256);
if redacted == text {
redacted
} else {
format!("redacted-id-{}", hex(&Sha256::digest(text.as_bytes())))
}
}
fn public_path(text: &str) -> String {
let label = public_text(text, 900);
if label == text {
label
} else {
format!("{label} [path-{}]", hex(&Sha256::digest(text.as_bytes())))
}
}
fn redact_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => *s = public_text(s, usize::MAX),
serde_json::Value::Array(a) => {
for value in a {
redact_value(value);
}
}
serde_json::Value::Object(o) => {
for (key, v) in o {
if matches!(
key.to_ascii_lowercase().replace(['_', '-'], "").as_str(),
"token"
| "password"
| "passwd"
| "authorization"
| "apikey"
| "secret"
| "accesstoken"
| "refreshtoken"
| "clientsecret"
| "privatekey"
| "credential"
| "credentials"
) {
*v = serde_json::Value::String("[REDACTED]".into());
} else {
redact_value(v);
}
}
}
_ => {}
}
}