1use crate::collect::model_from_json;
5use crate::core::event::{Event, SessionRecord, SessionStatus};
6use anyhow::{Context, Result};
7use serde_json::Value;
8use std::path::{Path, PathBuf};
9
10#[derive(Default)]
11struct Meta {
12 id: Option<String>,
13 workspace: Option<String>,
14 model: Option<String>,
15 started_ms: Option<u64>,
16 ended_ms: Option<u64>,
17 agent_version: Option<String>,
18}
19
20pub fn scan_claude_project_dir(
21 project_dir: &Path,
22 workspace: &Path,
23) -> Result<Vec<(SessionRecord, Vec<Event>)>> {
24 if !project_dir.exists() {
25 return Ok(Vec::new());
26 }
27 let target = crate::core::paths::canonical(workspace);
28 let mut out = Vec::new();
29 for file in top_level_jsonl(project_dir)? {
30 push_if_target(
31 &mut out,
32 scan_claude_session_file(&file, None, Some(workspace))?,
33 &target,
34 );
35 }
36 for (file, parent) in subagent_jsonl(project_dir)? {
37 push_if_target(
38 &mut out,
39 scan_claude_session_file(&file, Some(parent), Some(workspace))?,
40 &target,
41 );
42 }
43 Ok(out)
44}
45
46pub fn scan_claude_session_file(
47 path: &Path,
48 parent_session_id: Option<String>,
49 workspace_fallback: Option<&Path>,
50) -> Result<(SessionRecord, Vec<Event>)> {
51 let content = std::fs::read_to_string(path)
52 .with_context(|| format!("read claude file: {}", path.display()))?;
53 let mut meta = content.lines().fold(Meta::default(), read_meta);
54 if meta.workspace.is_none() {
55 meta.workspace = workspace_fallback.map(|p| p.to_string_lossy().to_string());
56 }
57 let id = meta.id.clone().unwrap_or_else(|| file_stem(path));
58 let base = meta.started_ms.unwrap_or_else(|| file_mtime_ms(path));
59 let events = content
60 .lines()
61 .enumerate()
62 .filter_map(|(i, line)| {
63 crate::collect::tail::claude::parse_claude_line(&id, i as u64, base, line)
64 .ok()
65 .flatten()
66 })
67 .collect();
68 Ok((record(path, id, meta, base, parent_session_id), events))
69}
70
71fn top_level_jsonl(project_dir: &Path) -> Result<Vec<PathBuf>> {
72 let mut out = std::fs::read_dir(project_dir)?
73 .filter_map(|e| e.ok().map(|e| e.path()))
74 .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
75 .collect::<Vec<_>>();
76 out.sort();
77 Ok(out)
78}
79
80fn subagent_jsonl(project_dir: &Path) -> Result<Vec<(PathBuf, String)>> {
81 let mut out = Vec::new();
82 for entry in std::fs::read_dir(project_dir)? {
83 let path = entry?.path();
84 let Some(parent) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
85 continue;
86 };
87 collect_subagents(&path.join("subagents"), &parent, &mut out)?;
88 }
89 out.sort();
90 Ok(out)
91}
92
93fn collect_subagents(dir: &Path, parent: &str, out: &mut Vec<(PathBuf, String)>) -> Result<()> {
94 if !dir.is_dir() {
95 return Ok(());
96 }
97 for entry in std::fs::read_dir(dir)? {
98 let path = entry?.path();
99 if path.extension().and_then(|x| x.to_str()) == Some("jsonl") {
100 out.push((path, parent.to_string()));
101 }
102 }
103 Ok(())
104}
105
106fn read_meta(mut meta: Meta, line: &str) -> Meta {
107 let Ok(v) = serde_json::from_str::<Value>(line.trim()) else {
108 return meta;
109 };
110 let Some(obj) = v.as_object() else {
111 return meta;
112 };
113 if let Some(ts) = line_ts(obj) {
114 meta.started_ms = Some(meta.started_ms.map_or(ts, |v| v.min(ts)));
115 meta.ended_ms = Some(meta.ended_ms.map_or(ts, |v| v.max(ts)));
116 }
117 meta.id = text(&v, "sessionId").or(meta.id);
118 meta.workspace = text(&v, "cwd").or(meta.workspace);
119 meta.agent_version = text(&v, "version").or(meta.agent_version);
120 meta.model = model_from_json::from_value(&v).or(meta.model);
121 meta
122}
123
124fn record(
125 path: &Path,
126 id: String,
127 meta: Meta,
128 base: u64,
129 parent_session_id: Option<String>,
130) -> SessionRecord {
131 SessionRecord {
132 id,
133 agent: "claude".into(),
134 model: meta.model,
135 workspace: meta.workspace.unwrap_or_default(),
136 started_at_ms: base,
137 ended_at_ms: meta.ended_ms,
138 status: SessionStatus::Done,
139 trace_path: path.to_string_lossy().to_string(),
140 start_commit: None,
141 end_commit: None,
142 branch: None,
143 dirty_start: None,
144 dirty_end: None,
145 repo_binding_source: None,
146 prompt_fingerprint: None,
147 parent_session_id,
148 agent_version: meta.agent_version,
149 os: None,
150 arch: None,
151 repo_file_count: None,
152 repo_total_loc: None,
153 }
154}
155
156fn push_if_target(
157 out: &mut Vec<(SessionRecord, Vec<Event>)>,
158 row: (SessionRecord, Vec<Event>),
159 target: &Path,
160) {
161 if workspace_matches(&row.0.workspace, target) {
162 out.push(row);
163 }
164}
165
166fn workspace_matches(found: &str, target: &Path) -> bool {
167 !found.is_empty() && crate::core::paths::canonical(Path::new(found)) == target
168}
169
170fn line_ts(obj: &serde_json::Map<String, Value>) -> Option<u64> {
171 ["timestamp_ms", "ts_ms", "created_at_ms", "timestamp"]
172 .iter()
173 .find_map(|k| obj.get(*k).and_then(crate::collect::tail::value_ts_ms))
174}
175
176fn text(v: &Value, key: &str) -> Option<String> {
177 v.get(key).and_then(Value::as_str).map(ToOwned::to_owned)
178}
179
180fn file_stem(path: &Path) -> String {
181 path.file_stem()
182 .and_then(|s| s.to_str())
183 .unwrap_or("")
184 .to_string()
185}
186
187fn file_mtime_ms(path: &Path) -> u64 {
188 path.metadata()
189 .ok()
190 .and_then(|m| m.modified().ok())
191 .map(|t| {
192 t.duration_since(std::time::UNIX_EPOCH)
193 .unwrap_or_default()
194 .as_millis() as u64
195 })
196 .unwrap_or(0)
197}