use std::{collections::BTreeSet, error::Error, fmt, io::Cursor, path::Path, sync::OnceLock};
use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset};
use jsonschema::{validator_for, Validator};
use murmur3::murmur3_32;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::{NoContext, Timestamp, Uuid};
use super::patch::{
intersect_patches, parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk,
TouchedLineKind,
};
use super::version::PACKAGE_VERSION;
pub const AGENT_TRACE_VERSION: &str = "0.1.0";
pub(crate) const SCE_WEB_BASE_URL: &str = "https://sce.crocoder.dev";
const RANGE_CONTENT_HASH_PREFIX: &str = "murmur3:";
const RANGE_CONTENT_HASH_INPUT_VERSION: &[u8] = b"sce-agent-trace-range-content-hash-v1\0";
const TOUCHED_LINE_ADDED_TAG: &[u8] = b"added\0";
const TOUCHED_LINE_REMOVED_TAG: &[u8] = b"removed\0";
pub(crate) fn agent_trace_conversation_url(agent_trace_id: &str) -> String {
format!("{SCE_WEB_BASE_URL}/conversations/{agent_trace_id}")
}
pub(crate) fn agent_trace_persisted_url(agent_trace_id: &str) -> String {
format!("{}/trace/{agent_trace_id}", sce_web_host())
}
pub(crate) fn agent_trace_session_url(session_id: &str) -> String {
format!("{SCE_WEB_BASE_URL}/sessions/{session_id}")
}
fn sce_web_host() -> &'static str {
SCE_WEB_BASE_URL
.strip_prefix("https://")
.unwrap_or(SCE_WEB_BASE_URL)
}
fn default_agent_trace_version() -> String {
AGENT_TRACE_VERSION.to_owned()
}
fn default_agent_trace_metadata() -> AgentTraceMetadata {
AgentTraceMetadata {
sce: AgentTraceSceMetadata {
version: PACKAGE_VERSION.to_owned(),
},
}
}
fn generate_agent_trace_id(commit_time: DateTime<FixedOffset>) -> Result<String> {
let seconds = u64::try_from(commit_time.timestamp()).with_context(|| {
format!(
"Invalid commit timestamp '{}': unix seconds must be non-negative.",
commit_time.to_rfc3339()
)
})?;
let timestamp = Timestamp::from_unix(NoContext, seconds, commit_time.timestamp_subsec_nanos());
Ok(Uuid::new_v7(timestamp).to_string())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AgentTraceMetadataInput<'a> {
pub commit_timestamp: &'a str,
pub commit_revision: &'a str,
pub vcs_type: Option<AgentTraceVcsType>,
pub tool_name: Option<&'a str>,
pub tool_version: Option<&'a str>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentTraceVcsType {
Git,
Jj,
Hg,
Svn,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentTraceVcs {
#[serde(rename = "type")]
pub r#type: AgentTraceVcsType,
pub revision: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentTraceTool {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentTraceMetadata {
pub sce: AgentTraceSceMetadata,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentTraceSceMetadata {
pub version: String,
}
fn parse_commit_timestamp(commit_timestamp: &str) -> Result<DateTime<FixedOffset>> {
DateTime::parse_from_rfc3339(commit_timestamp).with_context(|| {
format!("Invalid commit timestamp '{commit_timestamp}': expected RFC 3339 date-time.")
})
}
#[allow(dead_code)]
const AGENT_TRACE_SCHEMA_PATH: &str = "config/schema/agent-trace.schema.json";
#[allow(dead_code)]
pub(crate) const AGENT_TRACE_SCHEMA_JSON: &str =
include_str!("../../assets/generated/config/schema/agent-trace.schema.json");
#[allow(dead_code)]
static AGENT_TRACE_SCHEMA_VALIDATOR: OnceLock<Validator> = OnceLock::new();
#[derive(Debug, Eq, PartialEq)]
#[allow(dead_code)]
pub(crate) enum AgentTraceValidationError {
FileRead { path: String, message: String },
InvalidJson { message: String },
SchemaValidation { errors: Vec<String> },
}
impl fmt::Display for AgentTraceValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FileRead { path, message } => {
write!(f, "Agent Trace JSON file could not be read at '{path}': {message}")
}
Self::InvalidJson { message } => {
write!(f, "Agent Trace JSON must be valid JSON: {message}")
}
Self::SchemaValidation { errors } => write!(
f,
"Agent Trace JSON failed schema validation against embedded schema '{AGENT_TRACE_SCHEMA_PATH}': {}",
errors.join(" | ")
),
}
}
}
impl Error for AgentTraceValidationError {}
#[allow(dead_code)]
fn agent_trace_schema_validator() -> &'static Validator {
AGENT_TRACE_SCHEMA_VALIDATOR.get_or_init(|| {
let schema: Value = serde_json::from_str(AGENT_TRACE_SCHEMA_JSON)
.expect("agent trace schema JSON should parse");
validator_for(&schema).expect("agent trace schema JSON should compile")
})
}
#[allow(dead_code)]
pub(crate) fn validate_agent_trace_value(value: &Value) -> Result<(), AgentTraceValidationError> {
let mut errors = agent_trace_schema_validator()
.iter_errors(value)
.map(|error| error.to_string())
.collect::<Vec<_>>();
if errors.is_empty() {
return Ok(());
}
errors.sort();
Err(AgentTraceValidationError::SchemaValidation { errors })
}
#[allow(dead_code)]
pub(crate) fn validate_agent_trace_json(raw: &str) -> Result<(), AgentTraceValidationError> {
let value: Value =
serde_json::from_str(raw).map_err(|error| AgentTraceValidationError::InvalidJson {
message: error.to_string(),
})?;
validate_agent_trace_value(&value)
}
#[allow(dead_code)]
pub(crate) fn validate_agent_trace_file(path: &Path) -> Result<(), AgentTraceValidationError> {
let raw =
std::fs::read_to_string(path).map_err(|error| AgentTraceValidationError::FileRead {
path: path.display().to_string(),
message: error.to_string(),
})?;
validate_agent_trace_json(&raw)
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HunkContributor {
Ai,
Mixed,
Unknown,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Conversation {
pub url: String,
pub contributor: Contributor,
pub ranges: Vec<LineRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<Vec<ConversationRelated>>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub struct ConversationRelated {
#[serde(rename = "type")]
pub kind: String,
pub url: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Contributor {
#[serde(rename = "type")]
pub kind: HunkContributor,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct LineRange {
pub start_line: u64,
pub end_line: u64,
pub content_hash: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct TraceFile {
pub path: String,
pub conversations: Vec<Conversation>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AgentTrace {
#[serde(default = "default_agent_trace_version")]
pub version: String,
#[serde(default)]
pub id: String,
#[serde(default)]
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub vcs: Option<AgentTraceVcs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<AgentTraceTool>,
#[serde(default = "default_agent_trace_metadata")]
pub metadata: AgentTraceMetadata,
pub files: Vec<TraceFile>,
}
pub fn classify_hunk(
post_commit_hunk: &PatchHunk,
intersection_hunks: &[PatchHunk],
) -> HunkContributor {
let Some(intersection_hunk) = intersection_hunks
.iter()
.find(|h| h.old_start == post_commit_hunk.old_start)
else {
return HunkContributor::Unknown;
};
if hunks_match_exactly(post_commit_hunk, intersection_hunk) {
HunkContributor::Ai
} else {
HunkContributor::Mixed
}
}
#[allow(dead_code)]
pub(crate) fn patches_have_overlap(
candidate_patch: &ParsedPatch,
target_patch: &ParsedPatch,
) -> bool {
let intersection_patch = intersect_patches(candidate_patch, target_patch);
patch_has_touched_lines(&intersection_patch)
}
pub(crate) fn patch_has_touched_lines(patch: &ParsedPatch) -> bool {
patch
.files
.iter()
.any(|file| file.hunks.iter().any(|hunk| !hunk.lines.is_empty()))
}
fn hunks_match_exactly(left: &PatchHunk, right: &PatchHunk) -> bool {
if left.lines.len() != right.lines.len() {
return false;
}
left.lines.iter().zip(right.lines.iter()).all(|(ll, rl)| {
ll.kind == rl.kind && ll.line_number == rl.line_number && ll.content == rl.content
})
}
#[allow(dead_code)]
pub(crate) fn range_content_hash(hunk: &PatchHunk) -> String {
let mut input = Vec::new();
input.extend_from_slice(RANGE_CONTENT_HASH_INPUT_VERSION);
for line in &hunk.lines {
let kind_tag = match line.kind {
TouchedLineKind::Added => TOUCHED_LINE_ADDED_TAG,
TouchedLineKind::Removed => TOUCHED_LINE_REMOVED_TAG,
};
let content = line.content.as_bytes();
input.extend_from_slice(kind_tag);
input.extend_from_slice(&(content.len() as u64).to_be_bytes());
input.extend_from_slice(content);
input.push(0);
}
let hash = murmur3_32(&mut Cursor::new(input), 0)
.expect("murmur3 hashing from in-memory cursor should not fail");
format!("{RANGE_CONTENT_HASH_PREFIX}{hash:08x}")
}
fn line_range_from_hunk(file: &PatchFileChange, hunk: &PatchHunk) -> LineRange {
let (start_line, line_count) = match file.kind {
FileChangeKind::Deleted if hunk.new_count == 0 => (hunk.old_start, hunk.old_count),
_ => (hunk.new_start, hunk.new_count),
};
let end_line = start_line.saturating_add(line_count.saturating_sub(1));
LineRange {
start_line,
end_line,
content_hash: range_content_hash(hunk),
}
}
fn trace_path(file: &PatchFileChange) -> &str {
if file.new_path.is_empty() {
&file.old_path
} else {
&file.new_path
}
}
fn parse_embedded_deleted_patch(file: &PatchFileChange) -> Option<ParsedPatch> {
if file.kind != FileChangeKind::Deleted
|| !Path::new(&file.old_path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("patch"))
{
return None;
}
let embedded_patch = file
.hunks
.iter()
.flat_map(|hunk| hunk.lines.iter())
.filter(|line| line.kind == TouchedLineKind::Removed)
.map(|line| line.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let parsed_patch = parse_patch(&embedded_patch, None).ok()?;
(!parsed_patch.files.is_empty()).then_some(parsed_patch)
}
fn build_trace_file(
post_commit_file: &PatchFileChange,
intersection_patch: &ParsedPatch,
conversation_url: &str,
) -> Option<TraceFile> {
if post_commit_file.hunks.is_empty() {
return None;
}
let intersection_file = intersection_patch
.files
.iter()
.find(|ifile| ifile.new_path == post_commit_file.new_path);
let conversations = post_commit_file
.hunks
.iter()
.map(|post_commit_hunk| {
let (contributor_kind, contributor_model_id, matched_intersection_hunk) =
match intersection_file {
Some(ifile) => {
let contributor_kind = classify_hunk(post_commit_hunk, &ifile.hunks);
let matched_intersection_hunk = ifile
.hunks
.iter()
.find(|h| h.old_start == post_commit_hunk.old_start);
let contributor_model_id = match contributor_kind {
HunkContributor::Ai | HunkContributor::Mixed => {
matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone())
}
HunkContributor::Unknown => None,
};
(
contributor_kind,
contributor_model_id,
matched_intersection_hunk,
)
}
None => (HunkContributor::Unknown, None, None),
};
let related_session_ids = matched_intersection_hunk
.into_iter()
.flat_map(|hunk| hunk.lines.iter())
.filter_map(|line| line.session_id.as_deref())
.filter(|session_id| !session_id.is_empty())
.collect::<BTreeSet<_>>();
let related = (!related_session_ids.is_empty()).then(|| {
related_session_ids
.into_iter()
.map(|session_id| ConversationRelated {
kind: String::from("session"),
url: agent_trace_session_url(session_id),
})
.collect()
});
Conversation {
url: conversation_url.to_owned(),
contributor: Contributor {
kind: contributor_kind,
model_id: contributor_model_id,
},
ranges: vec![line_range_from_hunk(post_commit_file, post_commit_hunk)],
related,
}
})
.collect();
Some(TraceFile {
path: trace_path(post_commit_file).to_string(),
conversations,
})
}
#[allow(dead_code)]
pub fn build_agent_trace(
constructed_patch: &ParsedPatch,
post_commit_patch: &ParsedPatch,
metadata: AgentTraceMetadataInput<'_>,
) -> Result<AgentTrace> {
let commit_time = parse_commit_timestamp(metadata.commit_timestamp)?;
let id = generate_agent_trace_id(commit_time)?;
let conversation_url = agent_trace_conversation_url(&id);
let timestamp = metadata.commit_timestamp.to_owned();
let intersection_patch = intersect_patches(constructed_patch, post_commit_patch);
let mut files = Vec::new();
for post_commit_file in &post_commit_patch.files {
if let Some(embedded_patch) = parse_embedded_deleted_patch(post_commit_file) {
let embedded_intersection = intersect_patches(constructed_patch, &embedded_patch);
files.extend(embedded_patch.files.iter().filter_map(|embedded_file| {
build_trace_file(embedded_file, &embedded_intersection, &conversation_url)
}));
continue;
}
if let Some(trace_file) =
build_trace_file(post_commit_file, &intersection_patch, &conversation_url)
{
files.push(trace_file);
}
}
let tool = if !intersection_patch.files.is_empty()
&& (metadata.tool_name.is_some() || metadata.tool_version.is_some())
{
Some(AgentTraceTool {
name: metadata.tool_name.map(ToOwned::to_owned),
version: metadata.tool_version.map(ToOwned::to_owned),
})
} else {
None
};
Ok(AgentTrace {
version: default_agent_trace_version(),
id,
timestamp,
vcs: metadata.vcs_type.map(|vcs_type| AgentTraceVcs {
r#type: vcs_type,
revision: metadata.commit_revision.to_owned(),
}),
tool,
metadata: default_agent_trace_metadata(),
files,
})
}
#[cfg(test)]
mod tests;