1use chrono::{DateTime, SecondsFormat, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeSet, HashMap};
9use std::fmt;
10use std::fs;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use crate::{
16 AgentSession, EditSummary, discover_session_files, parse_session_file, parse_session_path,
17 short_hash,
18};
19
20const RETRIEVAL_BEFORE_MS: i64 = 15 * 60 * 1_000;
21const RETRIEVAL_AFTER_MS: i64 = 24 * 60 * 60 * 1_000;
22const AUDIT_BEFORE_MS: i64 = 24 * 60 * 60 * 1_000;
23const AUDIT_AFTER_MS: i64 = 7 * 24 * 60 * 60 * 1_000;
24
25#[derive(Debug)]
26pub struct ExportError(String);
27
28impl ExportError {
29 fn new(message: impl Into<String>) -> Self {
30 Self(message.into())
31 }
32}
33
34impl fmt::Display for ExportError {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.write_str(&self.0)
37 }
38}
39
40impl std::error::Error for ExportError {}
41
42#[derive(Debug, Clone)]
43pub struct LongitudinalOptions {
44 pub repo: PathBuf,
45 pub head: Option<String>,
46 pub since_ms: i64,
47 pub until_ms: i64,
48 pub session_paths: Vec<PathBuf>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LongitudinalArtifact {
53 pub schema: String,
54 pub repository: RepositorySummary,
55 pub window: ExportWindow,
56 pub summary: ArtifactSummary,
57 pub sessions: Vec<SessionSummary>,
58 pub events: Vec<NormalizedEvent>,
59 pub commits: Vec<GitCommit>,
60 pub changes: Vec<GitChange>,
61 pub file_lifetimes: Vec<FileLifetime>,
62 pub associations: Vec<EventAssociation>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct RepositorySummary {
67 pub name: String,
68 pub head: String,
69 pub root_id: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ExportWindow {
74 pub since_ms: i64,
75 pub until_ms: i64,
76 pub since: String,
77 pub until: String,
78 pub audit_since_ms: i64,
79 pub audit_until_ms: i64,
80 pub retrieval_before_ms: i64,
81 pub retrieval_after_ms: i64,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct ArtifactSummary {
86 pub session_count: usize,
87 pub event_count: usize,
88 pub path_resolvable_event_count: usize,
89 pub write_event_path_count: usize,
90 pub commit_count: usize,
91 pub change_count: usize,
92 pub lifetime_count: usize,
93 pub surviving_lifetime_count: usize,
94 pub association_none_count: usize,
95 pub association_unique_count: usize,
96 pub association_ambiguous_count: usize,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SessionSummary {
101 pub id: String,
102 pub vendor: String,
103 pub model: Option<String>,
104 pub started_at_ms: Option<i64>,
105 pub ended_at_ms: Option<i64>,
106 pub tool_events: usize,
107 pub total_tokens: i64,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct NormalizedEvent {
112 pub id: String,
113 pub session_id: String,
114 pub vendor: String,
115 pub model: Option<String>,
116 pub ts_ms: i64,
117 pub kind: String,
118 pub action: String,
119 pub category: String,
120 pub effect: String,
121 pub status: String,
122 pub prompt_index: usize,
123 pub paths: Vec<String>,
124 #[serde(default)]
125 pub write_paths: Vec<String>,
126 pub path_groups: Vec<String>,
127 pub edit_summary: Option<EditSummary>,
128 pub input_tokens: u64,
129 pub output_tokens: u64,
130 pub cache_tokens: u64,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct GitCommit {
135 pub id: String,
136 pub parents: Vec<String>,
137 pub committed_at_ms: i64,
138 pub author_label: String,
139 pub is_merge: bool,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct HunkFingerprint {
144 pub before_hash: Option<String>,
145 pub after_hash: Option<String>,
146 pub removed_lines: u64,
147 pub added_lines: u64,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct GitChange {
152 pub id: String,
153 pub commit_id: String,
154 pub committed_at_ms: i64,
155 pub status: String,
156 pub old_path: Option<String>,
157 pub path: String,
158 pub additions: u64,
159 pub deletions: u64,
160 pub lifetime_id: String,
161 pub is_merge: bool,
162 pub hunks: Vec<HunkFingerprint>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct FileLifetime {
167 pub id: String,
168 pub paths: Vec<String>,
169 pub birth_commit: String,
170 pub birth_ms: i64,
171 pub death_commit: Option<String>,
172 pub death_ms: Option<i64>,
173 pub current_path: Option<String>,
174 pub current_bytes: Option<u64>,
175 pub survives_to_head: bool,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CandidateAssociation {
180 pub change_id: String,
181 pub commit_id: String,
182 pub lifetime_id: String,
183 pub rank: usize,
184 pub delta_ms: i64,
185 pub match_kind: String,
186 pub exact_hunk_match: bool,
187 pub evidence_bin: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct EventAssociation {
192 pub id: String,
193 pub event_id: String,
194 pub path: String,
195 pub state: String,
196 pub candidates: Vec<CandidateAssociation>,
197}
198
199#[derive(Debug)]
200struct CommitRecord {
201 commit: GitCommit,
202 all_changes: Vec<GitChange>,
203}
204
205#[derive(Debug)]
206struct LifetimeBuilder {
207 lifetime: FileLifetime,
208}
209
210#[derive(Debug)]
211struct StatusChange {
212 status: String,
213 old_path: Option<String>,
214 path: String,
215}
216
217pub fn build_longitudinal_artifact(
218 options: &LongitudinalOptions,
219) -> Result<LongitudinalArtifact, ExportError> {
220 if options.until_ms <= options.since_ms {
221 return Err(ExportError::new("--until must be after --since"));
222 }
223 let repo = options
224 .repo
225 .canonicalize()
226 .map_err(|error| ExportError::new(format!("cannot resolve repository: {error}")))?;
227 ensure_git_repo(&repo)?;
228 let revision = options.head.as_deref().unwrap_or("HEAD");
229 let head = git_text(&repo, &["rev-parse", "--verify", revision])?
230 .trim()
231 .to_string();
232 let audit_since_ms = options.since_ms.saturating_sub(AUDIT_BEFORE_MS);
233 let audit_until_ms = options.until_ms.saturating_add(AUDIT_AFTER_MS);
234
235 let (commit_records, mut lifetimes) =
236 collect_git_history(&repo, &head, audit_since_ms, audit_until_ms)?;
237 let mut commits = Vec::new();
238 let mut changes = Vec::new();
239 for record in commit_records {
240 if (audit_since_ms..audit_until_ms).contains(&record.commit.committed_at_ms) {
241 commits.push(record.commit);
242 changes.extend(record.all_changes);
243 }
244 }
245 let current_sizes = current_blob_sizes(&repo, &head)?;
246 for lifetime in &mut lifetimes {
247 if let Some(path) = &lifetime.current_path {
248 lifetime.current_bytes = current_sizes.get(path).copied();
249 lifetime.survives_to_head = current_sizes.contains_key(path);
250 }
251 }
252
253 let sessions = load_sessions(options, &repo)?;
254 let (session_summaries, mut events) = normalize_sessions(&repo, sessions, options);
255 events.sort_by(|left, right| {
256 left.ts_ms
257 .cmp(&right.ts_ms)
258 .then_with(|| left.id.cmp(&right.id))
259 });
260 let associations = associate_events(&events, &changes, &lifetimes);
261
262 let mut summary = ArtifactSummary {
263 session_count: session_summaries.len(),
264 event_count: events.len(),
265 path_resolvable_event_count: events
266 .iter()
267 .filter(|event| !event.paths.is_empty())
268 .count(),
269 write_event_path_count: associations.len(),
270 commit_count: commits.len(),
271 change_count: changes.len(),
272 lifetime_count: lifetimes.len(),
273 surviving_lifetime_count: lifetimes
274 .iter()
275 .filter(|lifetime| lifetime.survives_to_head)
276 .count(),
277 ..ArtifactSummary::default()
278 };
279 for association in &associations {
280 match association.state.as_str() {
281 "no_candidate" => summary.association_none_count += 1,
282 "unique_candidate" => summary.association_unique_count += 1,
283 "ambiguous_candidates" => summary.association_ambiguous_count += 1,
284 _ => {}
285 }
286 }
287
288 Ok(LongitudinalArtifact {
289 schema: "agentsight.longitudinal.v1".to_string(),
290 repository: RepositorySummary {
291 name: repo
292 .file_name()
293 .and_then(|name| name.to_str())
294 .unwrap_or("repository")
295 .to_string(),
296 head: head.clone(),
297 root_id: short_hash(repo.to_string_lossy().as_ref(), 16),
298 },
299 window: ExportWindow {
300 since_ms: options.since_ms,
301 until_ms: options.until_ms,
302 since: format_timestamp(options.since_ms),
303 until: format_timestamp(options.until_ms),
304 audit_since_ms,
305 audit_until_ms,
306 retrieval_before_ms: RETRIEVAL_BEFORE_MS,
307 retrieval_after_ms: RETRIEVAL_AFTER_MS,
308 },
309 summary,
310 sessions: session_summaries,
311 events,
312 commits,
313 changes,
314 file_lifetimes: lifetimes,
315 associations,
316 })
317}
318
319pub fn write_longitudinal_artifact(
320 artifact: &LongitudinalArtifact,
321 output: &Path,
322) -> Result<(), ExportError> {
323 if let Some(parent) = output.parent() {
324 fs::create_dir_all(parent).map_err(|error| {
325 ExportError::new(format!("cannot create {}: {error}", parent.display()))
326 })?;
327 }
328 let file_name = output
329 .file_name()
330 .and_then(|name| name.to_str())
331 .unwrap_or("artifact.json");
332 let temporary = output.with_file_name(format!(".{file_name}.tmp-{}", std::process::id()));
333 let mut file = fs::File::create(&temporary).map_err(|error| {
334 ExportError::new(format!("cannot create {}: {error}", temporary.display()))
335 })?;
336 serde_json::to_writer_pretty(&mut file, artifact)
337 .map_err(|error| ExportError::new(format!("cannot serialize artifact: {error}")))?;
338 file.write_all(b"\n")
339 .and_then(|_| file.sync_all())
340 .map_err(|error| ExportError::new(format!("cannot flush artifact: {error}")))?;
341 fs::rename(&temporary, output).map_err(|error| {
342 ExportError::new(format!(
343 "cannot move {} to {}: {error}",
344 temporary.display(),
345 output.display()
346 ))
347 })
348}
349
350fn ensure_git_repo(repo: &Path) -> Result<(), ExportError> {
351 let inside = git_text(repo, &["rev-parse", "--is-inside-work-tree"])?;
352 if inside.trim() != "true" {
353 return Err(ExportError::new(format!(
354 "{} is not a Git worktree",
355 repo.display()
356 )));
357 }
358 Ok(())
359}
360
361fn collect_git_history(
362 repo: &Path,
363 head: &str,
364 audit_since_ms: i64,
365 audit_until_ms: i64,
366) -> Result<(Vec<CommitRecord>, Vec<FileLifetime>), ExportError> {
367 let output = git_bytes(
368 repo,
369 &[
370 "log",
371 "--first-parent",
372 "--reverse",
373 "--format=%H%x1f%P%x1f%ct%x1f%an%x1e",
374 head,
375 ],
376 )?;
377 let mut records = Vec::new();
378 let mut active = HashMap::<String, usize>::new();
379 let mut lifetimes = Vec::<LifetimeBuilder>::new();
380 let mut lifetime_counter = 0usize;
381
382 for raw_record in output.split(|byte| *byte == 0x1e) {
383 let raw_record = trim_ascii(raw_record);
384 if raw_record.is_empty() {
385 continue;
386 }
387 let fields = raw_record.split(|byte| *byte == 0x1f).collect::<Vec<_>>();
388 if fields.len() < 4 {
389 return Err(ExportError::new("unexpected git log record"));
390 }
391 let id = utf8(fields[0], "commit id")?.to_string();
392 let parents = utf8(fields[1], "commit parents")?
393 .split_whitespace()
394 .map(str::to_string)
395 .collect::<Vec<_>>();
396 let committed_at_ms = utf8(fields[2], "commit timestamp")?
397 .parse::<i64>()
398 .map_err(|error| ExportError::new(format!("invalid commit timestamp: {error}")))?
399 .saturating_mul(1_000);
400 let author_label = utf8(fields[3], "commit author")?.trim().to_string();
401 let is_merge = parents.len() > 1;
402 let statuses = commit_statuses(repo, &id, parents.first().map(String::as_str))?;
403 let numstats = commit_numstats(repo, &id, parents.first().map(String::as_str))?;
404 let mut all_changes = Vec::new();
405
406 for (index, status) in statuses.into_iter().enumerate() {
407 let lifetime_index = apply_lifetime_change(
408 &mut active,
409 &mut lifetimes,
410 &mut lifetime_counter,
411 &status,
412 &id,
413 committed_at_ms,
414 );
415 let (additions, deletions) = numstats
416 .get(&status.path)
417 .or_else(|| status.old_path.as_ref().and_then(|path| numstats.get(path)))
418 .copied()
419 .unwrap_or((0, 0));
420 let change_id = format!("{}:{index}", short_hash(&id, 12));
421 let hunks = if (audit_since_ms..audit_until_ms).contains(&committed_at_ms) {
422 commit_hunks(
423 repo,
424 &id,
425 parents.first().map(String::as_str),
426 status.old_path.as_deref(),
427 &status.path,
428 )?
429 } else {
430 Vec::new()
431 };
432 all_changes.push(GitChange {
433 id: change_id,
434 commit_id: id.clone(),
435 committed_at_ms,
436 status: status.status,
437 old_path: status.old_path,
438 path: status.path,
439 additions,
440 deletions,
441 lifetime_id: lifetimes[lifetime_index].lifetime.id.clone(),
442 is_merge,
443 hunks,
444 });
445 }
446
447 records.push(CommitRecord {
448 commit: GitCommit {
449 id,
450 parents,
451 committed_at_ms,
452 author_label,
453 is_merge,
454 },
455 all_changes,
456 });
457 }
458
459 Ok((
460 records,
461 lifetimes
462 .into_iter()
463 .map(|builder| builder.lifetime)
464 .collect(),
465 ))
466}
467
468fn apply_lifetime_change(
469 active: &mut HashMap<String, usize>,
470 lifetimes: &mut Vec<LifetimeBuilder>,
471 lifetime_counter: &mut usize,
472 change: &StatusChange,
473 commit_id: &str,
474 committed_at_ms: i64,
475) -> usize {
476 let status = change.status.chars().next().unwrap_or('M');
477 if (status == 'R' || status == 'C')
478 && let Some(old_path) = &change.old_path
479 && let Some(index) = active.remove(old_path)
480 {
481 let lifetime = &mut lifetimes[index].lifetime;
482 if !lifetime.paths.contains(&change.path) {
483 lifetime.paths.push(change.path.clone());
484 }
485 lifetime.current_path = Some(change.path.clone());
486 active.insert(change.path.clone(), index);
487 return index;
488 }
489 if status == 'D'
490 && let Some(index) = active.remove(&change.path)
491 {
492 let lifetime = &mut lifetimes[index].lifetime;
493 lifetime.death_commit = Some(commit_id.to_string());
494 lifetime.death_ms = Some(committed_at_ms);
495 lifetime.current_path = None;
496 return index;
497 }
498 if status != 'A'
499 && let Some(index) = active.get(&change.path).copied()
500 {
501 return index;
502 }
503
504 *lifetime_counter += 1;
505 let index = lifetimes.len();
506 lifetimes.push(LifetimeBuilder {
507 lifetime: FileLifetime {
508 id: format!("file-{:06}", *lifetime_counter),
509 paths: vec![change.path.clone()],
510 birth_commit: commit_id.to_string(),
511 birth_ms: committed_at_ms,
512 death_commit: None,
513 death_ms: None,
514 current_path: Some(change.path.clone()),
515 current_bytes: None,
516 survives_to_head: false,
517 },
518 });
519 active.insert(change.path.clone(), index);
520 index
521}
522
523fn commit_statuses(
524 repo: &Path,
525 commit: &str,
526 first_parent: Option<&str>,
527) -> Result<Vec<StatusChange>, ExportError> {
528 let output = if let Some(parent) = first_parent {
529 git_bytes(
530 repo,
531 &[
532 "diff",
533 "--name-status",
534 "-z",
535 "-M50%",
536 "--no-ext-diff",
537 parent,
538 commit,
539 "--",
540 ],
541 )?
542 } else {
543 git_bytes(
544 repo,
545 &[
546 "diff-tree",
547 "--root",
548 "--no-commit-id",
549 "-r",
550 "--name-status",
551 "-z",
552 "-M50%",
553 commit,
554 ],
555 )?
556 };
557 let tokens = nul_tokens(&output);
558 let mut changes = Vec::new();
559 let mut index = 0usize;
560 while index < tokens.len() {
561 let status = tokens[index].clone();
562 index += 1;
563 let kind = status.chars().next().unwrap_or('M');
564 if kind == 'R' || kind == 'C' {
565 if index + 1 >= tokens.len() {
566 return Err(ExportError::new("truncated rename status"));
567 }
568 changes.push(StatusChange {
569 status,
570 old_path: Some(tokens[index].clone()),
571 path: tokens[index + 1].clone(),
572 });
573 index += 2;
574 } else {
575 let Some(path) = tokens.get(index) else {
576 return Err(ExportError::new("truncated path status"));
577 };
578 changes.push(StatusChange {
579 status,
580 old_path: None,
581 path: path.clone(),
582 });
583 index += 1;
584 }
585 }
586 Ok(changes)
587}
588
589fn commit_numstats(
590 repo: &Path,
591 commit: &str,
592 first_parent: Option<&str>,
593) -> Result<HashMap<String, (u64, u64)>, ExportError> {
594 let output = if let Some(parent) = first_parent {
595 git_bytes(
596 repo,
597 &[
598 "diff",
599 "--numstat",
600 "-z",
601 "-M50%",
602 "--no-ext-diff",
603 parent,
604 commit,
605 "--",
606 ],
607 )?
608 } else {
609 git_bytes(
610 repo,
611 &[
612 "diff-tree",
613 "--root",
614 "--no-commit-id",
615 "-r",
616 "--numstat",
617 "-z",
618 "-M50%",
619 commit,
620 ],
621 )?
622 };
623 let tokens = nul_tokens(&output);
624 let mut stats = HashMap::new();
625 let mut index = 0usize;
626 while index < tokens.len() {
627 let header = &tokens[index];
628 index += 1;
629 let mut fields = header.splitn(3, '\t');
630 let additions = parse_numstat(fields.next().unwrap_or("0"));
631 let deletions = parse_numstat(fields.next().unwrap_or("0"));
632 let path = fields.next().unwrap_or("");
633 if path.is_empty() && index + 1 < tokens.len() {
634 let old_path = tokens[index].clone();
635 let new_path = tokens[index + 1].clone();
636 index += 2;
637 stats.insert(old_path, (additions, deletions));
638 stats.insert(new_path, (additions, deletions));
639 } else if !path.is_empty() {
640 stats.insert(path.to_string(), (additions, deletions));
641 }
642 }
643 Ok(stats)
644}
645
646fn commit_hunks(
647 repo: &Path,
648 commit: &str,
649 first_parent: Option<&str>,
650 old_path: Option<&str>,
651 path: &str,
652) -> Result<Vec<HunkFingerprint>, ExportError> {
653 let mut command = Command::new("git");
654 command.arg("-C").arg(repo);
655 if let Some(parent) = first_parent {
656 command.args(["diff", "--unified=0", "--no-ext-diff", parent, commit, "--"]);
657 } else {
658 command.args([
659 "show",
660 "--format=",
661 "--unified=0",
662 "--no-ext-diff",
663 commit,
664 "--",
665 ]);
666 }
667 if let Some(old_path) = old_path {
668 command.arg(old_path);
669 }
670 command.arg(path);
671 let output = command
672 .output()
673 .map_err(|error| ExportError::new(format!("cannot run git diff: {error}")))?;
674 if !output.status.success() {
675 return Err(command_error("git diff hunks", &output));
676 }
677 let text = String::from_utf8_lossy(&output.stdout);
678 Ok(parse_hunk_fingerprints(&text))
679}
680
681fn parse_hunk_fingerprints(patch: &str) -> Vec<HunkFingerprint> {
682 let mut hunks = Vec::new();
683 let mut before = Vec::new();
684 let mut after = Vec::new();
685 let mut in_hunk = false;
686 let flush =
687 |before: &mut Vec<String>, after: &mut Vec<String>, hunks: &mut Vec<HunkFingerprint>| {
688 if before.is_empty() && after.is_empty() {
689 return;
690 }
691 let before_text = before.join("\n");
692 let after_text = after.join("\n");
693 hunks.push(HunkFingerprint {
694 before_hash: (!before.is_empty()).then(|| content_fingerprint(&before_text)),
695 after_hash: (!after.is_empty()).then(|| content_fingerprint(&after_text)),
696 removed_lines: before.len() as u64,
697 added_lines: after.len() as u64,
698 });
699 before.clear();
700 after.clear();
701 };
702 for line in patch.lines() {
703 if line.starts_with("@@") {
704 flush(&mut before, &mut after, &mut hunks);
705 in_hunk = true;
706 } else if in_hunk && line.starts_with('-') && !line.starts_with("---") {
707 before.push(line[1..].to_string());
708 } else if in_hunk && line.starts_with('+') && !line.starts_with("+++") {
709 after.push(line[1..].to_string());
710 }
711 }
712 flush(&mut before, &mut after, &mut hunks);
713 hunks
714}
715
716fn load_sessions(
717 options: &LongitudinalOptions,
718 repo: &Path,
719) -> Result<Vec<AgentSession>, ExportError> {
720 let mut sessions = Vec::new();
721 if options.session_paths.is_empty() {
722 for candidate in discover_session_files() {
723 let updated_ms = candidate
724 .updated
725 .duration_since(std::time::UNIX_EPOCH)
726 .unwrap_or_default()
727 .as_millis() as i64;
728 if !candidate_mtime_may_contain_window(updated_ms, options.since_ms) {
732 continue;
733 }
734 if !candidate_may_match_repo(candidate.agent, &candidate.path, repo) {
735 continue;
736 }
737 if let Some(session) = parse_session_file(&candidate)
738 && session_matches_repo(&session, repo)
739 && session_overlaps(&session, options.since_ms, options.until_ms)
740 {
741 sessions.push(session);
742 }
743 }
744 } else {
745 for path in &options.session_paths {
746 let session = parse_session_path(path).ok_or_else(|| {
747 ExportError::new(format!("cannot parse session {}", path.display()))
748 })?;
749 if session_matches_repo(&session, repo)
750 && session_overlaps(&session, options.since_ms, options.until_ms)
751 {
752 sessions.push(session);
753 }
754 }
755 }
756 sessions.sort_by(|left, right| {
757 left.start_timestamp_ms
758 .cmp(&right.start_timestamp_ms)
759 .then_with(|| left.session_id.cmp(&right.session_id))
760 });
761 Ok(sessions)
762}
763
764fn candidate_mtime_may_contain_window(updated_ms: i64, since_ms: i64) -> bool {
765 updated_ms >= since_ms.saturating_sub(AUDIT_BEFORE_MS)
766}
767
768fn candidate_may_match_repo(agent: &str, path: &Path, repo: &Path) -> bool {
769 let repo_text = repo.to_string_lossy();
770 match agent {
771 crate::AGENT_CLAUDE => {
772 let encoded = repo_text.replace('/', "-");
773 path.to_string_lossy().contains(&encoded)
774 }
775 crate::AGENT_GEMINI => {
776 let project_hash = short_hash(repo_text.as_ref(), 64);
777 path.ancestors().any(|ancestor| {
778 ancestor
779 .file_name()
780 .and_then(|name| name.to_str())
781 .is_some_and(|name| name == project_hash)
782 })
783 }
784 crate::AGENT_CODEX => {
785 let Ok(mut file) = fs::File::open(path) else {
786 return false;
787 };
788 let mut prefix = vec![0u8; 256 * 1024];
789 let Ok(count) = file.read(&mut prefix) else {
790 return false;
791 };
792 prefix.truncate(count);
793 prefix
794 .windows(repo_text.len())
795 .any(|window| window == repo_text.as_bytes())
796 }
797 _ => false,
798 }
799}
800
801fn session_matches_repo(session: &AgentSession, repo: &Path) -> bool {
802 if let Some(cwd) = session.cwd.as_deref() {
803 let cwd = Path::new(cwd);
804 let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
805 return cwd.starts_with(repo);
806 }
807 session.project_hash.as_deref()
808 == Some(short_hash(repo.to_string_lossy().as_ref(), 64).as_str())
809}
810
811fn session_overlaps(session: &AgentSession, since_ms: i64, until_ms: i64) -> bool {
812 let start = session.start_timestamp_ms.unwrap_or_default() as i64;
813 let end = session.end_timestamp_ms.unwrap_or_default() as i64;
814 start < until_ms && end >= since_ms
815}
816
817fn normalize_sessions(
818 repo: &Path,
819 sessions: Vec<AgentSession>,
820 options: &LongitudinalOptions,
821) -> (Vec<SessionSummary>, Vec<NormalizedEvent>) {
822 let mut summaries = Vec::new();
823 let mut events = Vec::new();
824 for session in sessions {
825 let stable_session_id = format!(
826 "{}-{}",
827 session.agent_type,
828 short_hash(&session.session_id, 16)
829 );
830 let project_hash_matches = session.project_hash.as_deref()
831 == Some(short_hash(repo.to_string_lossy().as_ref(), 64).as_str());
832 let cwd = if project_hash_matches {
833 Some(repo)
834 } else {
835 session.cwd.as_deref().map(Path::new)
836 };
837 summaries.push(SessionSummary {
838 id: stable_session_id.clone(),
839 vendor: session.agent_type.clone(),
840 model: session.model.clone(),
841 started_at_ms: session.start_timestamp_ms.map(|value| value as i64),
842 ended_at_ms: session.end_timestamp_ms.map(|value| value as i64),
843 tool_events: session.events.tools.len(),
844 total_tokens: session.usage.total_tokens,
845 });
846 for (index, tool) in session.events.tools.iter().enumerate() {
847 let Some(ts_ms) = tool.ts_ms else {
848 continue;
849 };
850 if !(options.since_ms..options.until_ms).contains(&ts_ms) {
851 continue;
852 }
853 let normalized_refs = tool
854 .path_refs
855 .iter()
856 .filter_map(|reference| {
857 cwd.and_then(|cwd| {
858 repo_relative_session_path(repo, cwd, &reference.path)
859 .map(|path| (path, reference.access.as_str()))
860 })
861 })
862 .collect::<BTreeSet<_>>();
863 let paths = normalized_refs
864 .iter()
865 .map(|(path, _)| path.clone())
866 .collect::<BTreeSet<_>>()
867 .into_iter()
868 .collect::<Vec<_>>();
869 let write_paths = normalized_refs
870 .iter()
871 .filter(|(_, access)| *access == "write")
872 .map(|(path, _)| path.clone())
873 .collect::<BTreeSet<_>>()
874 .into_iter()
875 .collect::<Vec<_>>();
876 let id_source = format!(
877 "{}:{}:{}:{}:{}",
878 stable_session_id,
879 ts_ms,
880 tool.call_id.as_deref().unwrap_or("none"),
881 tool.prompt_index,
882 index
883 );
884 events.push(NormalizedEvent {
885 id: format!("event-{}", short_hash(&id_source, 20)),
886 session_id: stable_session_id.clone(),
887 vendor: session.agent_type.clone(),
888 model: session.model.clone(),
889 ts_ms,
890 kind: "tool".to_string(),
891 action: tool.tool_name.clone(),
892 category: tool.category.clone(),
893 effect: tool.effect.clone(),
894 status: tool.status.clone(),
895 prompt_index: tool.prompt_index,
896 paths,
897 write_paths,
898 path_groups: tool.path_groups.clone(),
899 edit_summary: tool.edit_summary.clone(),
900 input_tokens: 0,
901 output_tokens: 0,
902 cache_tokens: 0,
903 });
904 }
905 for (index, response) in session.events.llm_responses.iter().enumerate() {
906 let Some(ts_ms) = response.ts_ms else {
907 continue;
908 };
909 if !(options.since_ms..options.until_ms).contains(&ts_ms) {
910 continue;
911 }
912 let id_source = format!("{}:{ts_ms}:llm:{index}", stable_session_id);
913 events.push(NormalizedEvent {
914 id: format!("event-{}", short_hash(&id_source, 20)),
915 session_id: stable_session_id.clone(),
916 vendor: session.agent_type.clone(),
917 model: Some(response.model.clone()),
918 ts_ms,
919 kind: "llm_response".to_string(),
920 action: "model_response".to_string(),
921 category: "model".to_string(),
922 effect: "compute".to_string(),
923 status: "observed".to_string(),
924 prompt_index: response.prompt_index,
925 paths: Vec::new(),
926 write_paths: Vec::new(),
927 path_groups: Vec::new(),
928 edit_summary: None,
929 input_tokens: response.input_tokens,
930 output_tokens: response.output_tokens,
931 cache_tokens: response.cache_tokens,
932 });
933 }
934 }
935 (summaries, events)
936}
937
938fn repo_relative_session_path(repo: &Path, cwd: &Path, event_path: &str) -> Option<String> {
939 let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
940 let prefix = cwd.strip_prefix(repo).ok()?;
941 let joined = prefix.join(event_path);
942 let mut parts = Vec::new();
943 for component in joined.components() {
944 match component {
945 std::path::Component::CurDir => {}
946 std::path::Component::Normal(part) => parts.push(part.to_str()?.to_string()),
947 std::path::Component::ParentDir => {
948 parts.pop()?;
949 }
950 std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
951 }
952 }
953 (!parts.is_empty()).then(|| parts.join("/"))
954}
955
956fn associate_events(
957 events: &[NormalizedEvent],
958 changes: &[GitChange],
959 lifetimes: &[FileLifetime],
960) -> Vec<EventAssociation> {
961 let lifetime_paths = lifetimes
962 .iter()
963 .map(|lifetime| {
964 (
965 lifetime.id.as_str(),
966 lifetime
967 .paths
968 .iter()
969 .map(String::as_str)
970 .collect::<BTreeSet<_>>(),
971 )
972 })
973 .collect::<HashMap<_, _>>();
974 let lifetime_intervals = lifetimes
975 .iter()
976 .map(|lifetime| (lifetime.id.as_str(), (lifetime.birth_ms, lifetime.death_ms)))
977 .collect::<HashMap<_, _>>();
978 let mut associations = Vec::new();
979 for event in events.iter().filter(|event| !event.write_paths.is_empty()) {
984 for path in &event.write_paths {
985 let mut candidates = changes
986 .iter()
987 .filter(|change| !change.is_merge)
988 .filter(|change| {
989 change.committed_at_ms >= event.ts_ms.saturating_sub(RETRIEVAL_BEFORE_MS)
990 && change.committed_at_ms <= event.ts_ms.saturating_add(RETRIEVAL_AFTER_MS)
991 })
992 .filter_map(|change| {
993 let direct = change.path == *path
994 || change.old_path.as_deref().is_some_and(|old| old == path);
995 let lifetime_match = lifetime_paths
996 .get(change.lifetime_id.as_str())
997 .is_some_and(|paths| paths.contains(path.as_str()));
998 let event_within_lifetime = lifetime_intervals
999 .get(change.lifetime_id.as_str())
1000 .is_some_and(|(birth, death)| {
1001 event.ts_ms >= *birth && death.is_none_or(|death| event.ts_ms < death)
1002 });
1003 let prebirth = change.status.starts_with('A')
1004 && change.path == *path
1005 && change.committed_at_ms >= event.ts_ms;
1006 if !prebirth && (!event_within_lifetime || (!direct && !lifetime_match)) {
1007 return None;
1008 }
1009 let exact_hunk_match = event.edit_summary.as_ref().is_some_and(|summary| {
1010 change
1011 .hunks
1012 .iter()
1013 .any(|hunk| edit_matches_hunk(summary, hunk))
1014 });
1015 let match_kind = if prebirth {
1016 "prebirth"
1017 } else if direct {
1018 "direct_path"
1019 } else {
1020 "rename_lifetime"
1021 };
1022 Some((change, exact_hunk_match, match_kind))
1023 })
1024 .collect::<Vec<_>>();
1025 candidates.sort_by(|left, right| {
1026 let left_key = candidate_sort_key(event, left.0, left.1, left.2);
1027 let right_key = candidate_sort_key(event, right.0, right.1, right.2);
1028 left_key.cmp(&right_key)
1029 });
1030 let candidate_count = candidates.len();
1031 let top_distance_ms = candidates
1032 .first()
1033 .map(|candidate| (candidate.0.committed_at_ms - event.ts_ms).abs());
1034 let second_distance_ms = candidates
1035 .get(1)
1036 .map(|candidate| (candidate.0.committed_at_ms - event.ts_ms).abs());
1037 let mapped = candidates
1038 .into_iter()
1039 .enumerate()
1040 .map(
1041 |(index, (change, exact_hunk_match, match_kind))| CandidateAssociation {
1042 change_id: change.id.clone(),
1043 commit_id: change.commit_id.clone(),
1044 lifetime_id: change.lifetime_id.clone(),
1045 rank: index + 1,
1046 delta_ms: change.committed_at_ms - event.ts_ms,
1047 match_kind: match_kind.to_string(),
1048 exact_hunk_match,
1049 evidence_bin: candidate_evidence_bin(
1050 candidate_count,
1051 index,
1052 top_distance_ms,
1053 second_distance_ms,
1054 exact_hunk_match,
1055 match_kind,
1056 ),
1057 },
1058 )
1059 .collect::<Vec<_>>();
1060 let state = match mapped.len() {
1061 0 => "no_candidate",
1062 1 => "unique_candidate",
1063 _ => "ambiguous_candidates",
1064 };
1065 let association_source = format!("{}:{path}", event.id);
1066 associations.push(EventAssociation {
1067 id: format!("assoc-{}", short_hash(&association_source, 20)),
1068 event_id: event.id.clone(),
1069 path: path.clone(),
1070 state: state.to_string(),
1071 candidates: mapped,
1072 });
1073 }
1074 }
1075 associations
1076}
1077
1078fn candidate_sort_key(
1079 event: &NormalizedEvent,
1080 change: &GitChange,
1081 exact_hunk_match: bool,
1082 match_kind: &str,
1083) -> (u8, u8, u8, i64, String) {
1084 let direct_rank = match match_kind {
1085 "direct_path" => 0,
1086 "prebirth" => 1,
1087 "rename_lifetime" => 1,
1088 _ => 2,
1089 };
1090 let direction_rank = if change.committed_at_ms >= event.ts_ms {
1091 0
1092 } else {
1093 1
1094 };
1095 (
1096 u8::from(!exact_hunk_match),
1097 direct_rank,
1098 direction_rank,
1099 (change.committed_at_ms - event.ts_ms).abs(),
1100 change.commit_id.clone(),
1101 )
1102}
1103
1104fn candidate_evidence_bin(
1105 candidate_count: usize,
1106 index: usize,
1107 top_distance_ms: Option<i64>,
1108 second_distance_ms: Option<i64>,
1109 exact_hunk_match: bool,
1110 match_kind: &str,
1111) -> String {
1112 if index == 0 && exact_hunk_match && candidate_count == 1 {
1113 "exact_hunk_unique"
1114 } else if candidate_count == 1 && match_kind == "direct_path" {
1115 "unique_direct_lifetime"
1116 } else if candidate_count == 1 {
1117 "unique_rename_or_prebirth"
1118 } else if index == 0
1119 && top_distance_ms
1120 .zip(second_distance_ms)
1121 .is_some_and(|(top, second)| top.saturating_mul(4) <= second)
1122 {
1123 "multi_close_top"
1124 } else {
1125 "multi_other"
1126 }
1127 .to_string()
1128}
1129
1130fn edit_matches_hunk(edit: &EditSummary, hunk: &HunkFingerprint) -> bool {
1131 let before_matches = edit
1132 .before_hash
1133 .as_ref()
1134 .is_none_or(|hash| hunk.before_hash.as_ref() == Some(hash));
1135 let after_matches = edit
1136 .after_hash
1137 .as_ref()
1138 .is_none_or(|hash| hunk.after_hash.as_ref() == Some(hash));
1139 (edit.before_hash.is_some() || edit.after_hash.is_some()) && before_matches && after_matches
1140}
1141
1142fn current_blob_sizes(repo: &Path, head: &str) -> Result<HashMap<String, u64>, ExportError> {
1143 let output = git_bytes(repo, &["ls-tree", "-r", "-l", "-z", head])?;
1144 let mut sizes = HashMap::new();
1145 for record in output.split(|byte| *byte == 0) {
1146 if record.is_empty() {
1147 continue;
1148 }
1149 let text = utf8(record, "ls-tree record")?;
1150 let Some((metadata, path)) = text.split_once('\t') else {
1151 continue;
1152 };
1153 let size = metadata
1154 .split_whitespace()
1155 .last()
1156 .and_then(|value| value.parse::<u64>().ok());
1157 if let Some(size) = size {
1158 sizes.insert(path.to_string(), size);
1159 }
1160 }
1161 Ok(sizes)
1162}
1163
1164fn git_text(repo: &Path, args: &[&str]) -> Result<String, ExportError> {
1165 let output = git_bytes(repo, args)?;
1166 String::from_utf8(output)
1167 .map_err(|error| ExportError::new(format!("git returned invalid UTF-8: {error}")))
1168}
1169
1170fn git_bytes(repo: &Path, args: &[&str]) -> Result<Vec<u8>, ExportError> {
1171 let output = Command::new("git")
1172 .arg("-C")
1173 .arg(repo)
1174 .args(args)
1175 .output()
1176 .map_err(|error| ExportError::new(format!("cannot run git: {error}")))?;
1177 if !output.status.success() {
1178 return Err(command_error(&format!("git {}", args.join(" ")), &output));
1179 }
1180 Ok(output.stdout)
1181}
1182
1183fn command_error(label: &str, output: &std::process::Output) -> ExportError {
1184 ExportError::new(format!(
1185 "{label} failed with {}: {}",
1186 output.status,
1187 String::from_utf8_lossy(&output.stderr).trim()
1188 ))
1189}
1190
1191fn nul_tokens(output: &[u8]) -> Vec<String> {
1192 output
1193 .split(|byte| *byte == 0)
1194 .filter(|token| !token.is_empty())
1195 .map(|token| String::from_utf8_lossy(token).into_owned())
1196 .collect()
1197}
1198
1199fn parse_numstat(value: &str) -> u64 {
1200 value.parse().unwrap_or(0)
1201}
1202
1203fn trim_ascii(mut value: &[u8]) -> &[u8] {
1204 while value.first().is_some_and(u8::is_ascii_whitespace) {
1205 value = &value[1..];
1206 }
1207 while value.last().is_some_and(u8::is_ascii_whitespace) {
1208 value = &value[..value.len() - 1];
1209 }
1210 value
1211}
1212
1213fn utf8<'a>(value: &'a [u8], label: &str) -> Result<&'a str, ExportError> {
1214 std::str::from_utf8(value)
1215 .map_err(|error| ExportError::new(format!("invalid {label}: {error}")))
1216}
1217
1218fn content_fingerprint(value: &str) -> String {
1219 let normalized = value.replace("\r\n", "\n");
1220 short_hash(normalized.trim_end_matches('\n'), 24)
1221}
1222
1223fn format_timestamp(timestamp_ms: i64) -> String {
1224 DateTime::<Utc>::from_timestamp_millis(timestamp_ms)
1225 .map(|timestamp| timestamp.to_rfc3339_opts(SecondsFormat::Millis, true))
1226 .unwrap_or_else(|| timestamp_ms.to_string())
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232 use std::process::Stdio;
1233
1234 fn git(repo: &Path, args: &[&str]) {
1235 let status = Command::new("git")
1236 .arg("-C")
1237 .arg(repo)
1238 .args(args)
1239 .stdout(Stdio::null())
1240 .status()
1241 .expect("run git");
1242 assert!(status.success(), "git {args:?}");
1243 }
1244
1245 #[test]
1246 fn hunk_fingerprints_match_normalized_edit_blocks() {
1247 let hunks = parse_hunk_fingerprints("@@ -1 +1 @@\n-old\n+new\n");
1248 assert_eq!(hunks.len(), 1);
1249 assert_eq!(hunks[0].before_hash, Some(content_fingerprint("old\n")));
1250 assert_eq!(hunks[0].after_hash, Some(content_fingerprint("new\n")));
1251 }
1252
1253 #[test]
1254 fn resumed_long_running_sessions_are_not_cut_off_by_future_mtime() {
1255 let since_ms = 1_000_000;
1256 assert!(candidate_mtime_may_contain_window(
1257 since_ms + 90 * 24 * 60 * 60 * 1_000,
1258 since_ms,
1259 ));
1260 assert!(!candidate_mtime_may_contain_window(
1261 since_ms - AUDIT_BEFORE_MS - 1,
1262 since_ms,
1263 ));
1264 }
1265
1266 #[test]
1267 fn delete_then_recreate_starts_a_new_lifetime() {
1268 let root = std::env::temp_dir().join(format!(
1269 "agent-session-longitudinal-{}-{}",
1270 std::process::id(),
1271 short_hash("delete-recreate", 8)
1272 ));
1273 let _ = fs::remove_dir_all(&root);
1274 fs::create_dir_all(&root).expect("create fixture repo");
1275 git(&root, &["init", "-q"]);
1276 git(&root, &["config", "user.name", "Fixture"]);
1277 git(&root, &["config", "user.email", "fixture@example.test"]);
1278 fs::write(root.join("file.txt"), "one\n").expect("write file");
1279 git(&root, &["add", "file.txt"]);
1280 git(&root, &["commit", "-q", "-m", "add"]);
1281 fs::remove_file(root.join("file.txt")).expect("remove file");
1282 git(&root, &["add", "-u"]);
1283 git(&root, &["commit", "-q", "-m", "delete"]);
1284 fs::write(root.join("file.txt"), "two\n").expect("recreate file");
1285 git(&root, &["add", "file.txt"]);
1286 git(&root, &["commit", "-q", "-m", "recreate"]);
1287
1288 let (_, lifetimes) = collect_git_history(&root, "HEAD", i64::MIN, i64::MAX)
1289 .expect("collect fixture history");
1290 let matching = lifetimes
1291 .iter()
1292 .filter(|lifetime| lifetime.paths == ["file.txt"])
1293 .collect::<Vec<_>>();
1294 assert_eq!(matching.len(), 2);
1295 assert!(matching[0].death_commit.is_some());
1296 assert!(matching[1].death_commit.is_none());
1297 fs::remove_dir_all(&root).expect("remove fixture repo");
1298 }
1299
1300 #[test]
1301 fn write_in_deleted_path_gap_only_targets_next_add() {
1302 let event = NormalizedEvent {
1303 id: "event".to_string(),
1304 session_id: "session".to_string(),
1305 vendor: "fixture".to_string(),
1306 model: None,
1307 ts_ms: 150,
1308 kind: "tool".to_string(),
1309 action: "write".to_string(),
1310 category: "file".to_string(),
1311 effect: "write".to_string(),
1312 status: "ok".to_string(),
1313 prompt_index: 0,
1314 paths: vec!["file.txt".to_string()],
1315 write_paths: vec!["file.txt".to_string()],
1316 path_groups: Vec::new(),
1317 edit_summary: None,
1318 input_tokens: 0,
1319 output_tokens: 0,
1320 cache_tokens: 0,
1321 };
1322 let changes = vec![
1323 GitChange {
1324 id: "delete".to_string(),
1325 commit_id: "old-commit".to_string(),
1326 committed_at_ms: 100,
1327 status: "D".to_string(),
1328 old_path: None,
1329 path: "file.txt".to_string(),
1330 additions: 0,
1331 deletions: 1,
1332 lifetime_id: "old".to_string(),
1333 is_merge: false,
1334 hunks: Vec::new(),
1335 },
1336 GitChange {
1337 id: "add".to_string(),
1338 commit_id: "new-commit".to_string(),
1339 committed_at_ms: 200,
1340 status: "A".to_string(),
1341 old_path: None,
1342 path: "file.txt".to_string(),
1343 additions: 1,
1344 deletions: 0,
1345 lifetime_id: "new".to_string(),
1346 is_merge: false,
1347 hunks: Vec::new(),
1348 },
1349 ];
1350 let lifetimes = vec![
1351 FileLifetime {
1352 id: "old".to_string(),
1353 paths: vec!["file.txt".to_string()],
1354 birth_commit: "birth-old".to_string(),
1355 birth_ms: 0,
1356 death_commit: Some("old-commit".to_string()),
1357 death_ms: Some(100),
1358 current_path: None,
1359 current_bytes: None,
1360 survives_to_head: false,
1361 },
1362 FileLifetime {
1363 id: "new".to_string(),
1364 paths: vec!["file.txt".to_string()],
1365 birth_commit: "new-commit".to_string(),
1366 birth_ms: 200,
1367 death_commit: None,
1368 death_ms: None,
1369 current_path: Some("file.txt".to_string()),
1370 current_bytes: Some(4),
1371 survives_to_head: true,
1372 },
1373 ];
1374
1375 let associations = associate_events(&[event], &changes, &lifetimes);
1376 assert_eq!(associations.len(), 1);
1377 assert_eq!(associations[0].state, "unique_candidate");
1378 assert_eq!(associations[0].candidates[0].commit_id, "new-commit");
1379 assert_eq!(associations[0].candidates[0].match_kind, "prebirth");
1380 }
1381
1382 #[test]
1383 fn segment_local_write_is_eligible_even_when_primary_effect_is_process() {
1384 let event = NormalizedEvent {
1385 id: "event".to_string(),
1386 session_id: "session".to_string(),
1387 vendor: "fixture".to_string(),
1388 model: None,
1389 ts_ms: 100,
1390 kind: "tool".to_string(),
1391 action: "exec_command".to_string(),
1392 category: "shell".to_string(),
1393 effect: "process".to_string(),
1394 status: "ok".to_string(),
1395 prompt_index: 0,
1396 paths: vec!["file.txt".to_string()],
1397 write_paths: vec!["file.txt".to_string()],
1398 path_groups: Vec::new(),
1399 edit_summary: None,
1400 input_tokens: 0,
1401 output_tokens: 0,
1402 cache_tokens: 0,
1403 };
1404 let change = GitChange {
1405 id: "change".to_string(),
1406 commit_id: "commit".to_string(),
1407 committed_at_ms: 110,
1408 status: "M".to_string(),
1409 old_path: None,
1410 path: "file.txt".to_string(),
1411 additions: 1,
1412 deletions: 0,
1413 lifetime_id: "lifetime".to_string(),
1414 is_merge: false,
1415 hunks: Vec::new(),
1416 };
1417 let lifetime = FileLifetime {
1418 id: "lifetime".to_string(),
1419 paths: vec!["file.txt".to_string()],
1420 birth_commit: "birth".to_string(),
1421 birth_ms: 0,
1422 death_commit: None,
1423 death_ms: None,
1424 current_path: Some("file.txt".to_string()),
1425 current_bytes: Some(4),
1426 survives_to_head: true,
1427 };
1428
1429 let associations = associate_events(&[event], &[change], &[lifetime]);
1430 assert_eq!(associations.len(), 1);
1431 assert_eq!(associations[0].path, "file.txt");
1432 }
1433
1434 #[test]
1435 fn session_paths_can_resolve_from_a_repository_subdirectory() {
1436 let repo = Path::new("/repo");
1437 let cwd = Path::new("/repo/nested");
1438 assert_eq!(
1439 repo_relative_session_path(repo, cwd, "../src/lib.rs").as_deref(),
1440 Some("src/lib.rs")
1441 );
1442 assert!(repo_relative_session_path(repo, cwd, "../../outside.txt").is_none());
1443 }
1444}