Skip to main content

kaizen/collect/tail/
claude_code.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Claude Code project logs stored as top-level project `*.jsonl` files.
3
4use 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}