feat(parser): add session/agent discovery and JSONL reader [claudbg-g5uv,claudbg-jupi,claudbg-76fy]
Add src/parser/discovery.rs with SessionRef, AgentRef, discover_sessions(), discover_agents_for_session(), and discover_all_agents(). Add src/parser/reader.rs with async read_session_file() using tokio BufReader. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
620a3571c6
commit
fb18b64621
@ -0,0 +1,451 @@
|
||||
//! Discovery of Claude Code session and sub-agent JSONL files on disk.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::models::session::RawEntry;
|
||||
|
||||
/// Reference to a discovered session file on disk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRef {
|
||||
/// The session UUID (filename stem, without `.jsonl`).
|
||||
pub session_id: String,
|
||||
/// The project path recovered from the JSONL `cwd` field.
|
||||
/// `None` if the file could not be parsed to find a `cwd`.
|
||||
pub project_path: Option<String>,
|
||||
/// Absolute path to the `.jsonl` file.
|
||||
pub file_path: PathBuf,
|
||||
/// Last-modified time of the file.
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Reference to a discovered sub-agent JSONL file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentRef {
|
||||
/// The agent's UUID (from filename: `agent-{uuid}.jsonl`).
|
||||
pub agent_id: String,
|
||||
/// The parent session UUID (read from first line of the agent JSONL).
|
||||
pub session_id: String,
|
||||
/// Agent type, from `agent-{id}.meta.json` if present.
|
||||
pub agent_type: Option<String>,
|
||||
/// Absolute path to the agent's `.jsonl` file.
|
||||
pub file_path: PathBuf,
|
||||
/// Last-modified time of the file.
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Resolve the `~/.claude/projects/` directory path.
|
||||
///
|
||||
/// Uses `HOME` environment variable to expand `~`.
|
||||
fn claude_projects_dir() -> Option<PathBuf> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
Some(PathBuf::from(home).join(".claude").join("projects"))
|
||||
}
|
||||
|
||||
/// Read the first non-empty line of a file and attempt to extract `cwd` from it.
|
||||
///
|
||||
/// Returns `None` if the file cannot be read or the first line cannot be parsed.
|
||||
fn read_cwd_from_first_line(path: &Path) -> Option<String> {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines() {
|
||||
let line = line.ok()?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entry) = serde_json::from_str::<RawEntry>(trimmed)
|
||||
&& entry.cwd.is_some()
|
||||
{
|
||||
return entry.cwd;
|
||||
}
|
||||
// Return after first non-empty line regardless.
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Discover all session JSONL files under `~/.claude/projects/`.
|
||||
///
|
||||
/// Walks one level deep: for each project subdirectory, collects `*.jsonl` files
|
||||
/// while skipping `subagents/` subdirectories. For each file the session ID is
|
||||
/// extracted from the filename stem and the project path from the first parseable
|
||||
/// JSONL line's `cwd` field.
|
||||
///
|
||||
/// Files that cannot be read or stat'd are skipped with a warning to `stderr`.
|
||||
pub fn discover_sessions() -> crate::error::Result<Vec<SessionRef>> {
|
||||
let projects_dir = match claude_projects_dir() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!("claudbg: could not determine HOME directory");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
if !projects_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
let project_entries = match std::fs::read_dir(&projects_dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"claudbg: could not read {}: {}",
|
||||
projects_dir.display(),
|
||||
err
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
for proj_entry in project_entries {
|
||||
let proj_entry = match proj_entry {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: error reading project dir entry: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let proj_path = proj_entry.path();
|
||||
if !proj_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_entries = match std::fs::read_dir(&proj_path) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: could not read {}: {}", proj_path.display(), err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for file_entry in file_entries {
|
||||
let file_entry = match file_entry {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: error reading file entry: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let file_path = file_entry.path();
|
||||
|
||||
// Skip subdirectories (e.g. subagents/).
|
||||
if file_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process .jsonl files.
|
||||
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract session_id from filename stem.
|
||||
let session_id = match file_path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
eprintln!(
|
||||
"claudbg: could not extract session ID from {}",
|
||||
file_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Get last-modified time.
|
||||
let modified_at = match file_entry.metadata() {
|
||||
Ok(meta) => match meta.modified() {
|
||||
Ok(t) => DateTime::<Utc>::from(t),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"claudbg: could not read mtime for {}: {}",
|
||||
file_path.display(),
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: could not stat {}: {}", file_path.display(), err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Try to extract cwd from first JSONL line.
|
||||
let project_path = read_cwd_from_first_line(&file_path);
|
||||
|
||||
sessions.push(SessionRef {
|
||||
session_id,
|
||||
project_path,
|
||||
file_path,
|
||||
modified_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Discover all sub-agent runs for a session given the session's JSONL file path.
|
||||
///
|
||||
/// Looks for `subagents/agent-*.jsonl` files in the same directory as
|
||||
/// `session_file`.
|
||||
pub fn discover_agents_for_session(session_file: &Path) -> crate::error::Result<Vec<AgentRef>> {
|
||||
let parent = match session_file.parent() {
|
||||
Some(p) => p,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
let subagents_dir = parent.join("subagents");
|
||||
collect_agents_in_dir(&subagents_dir, None)
|
||||
}
|
||||
|
||||
/// Discover all sub-agent runs across all sessions.
|
||||
///
|
||||
/// Walks `~/.claude/projects/` and collects `agent-*.jsonl` files from every
|
||||
/// `subagents/` subdirectory found.
|
||||
pub fn discover_all_agents() -> crate::error::Result<Vec<AgentRef>> {
|
||||
let projects_dir = match claude_projects_dir() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!("claudbg: could not determine HOME directory");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
if !projects_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut agents = Vec::new();
|
||||
|
||||
let project_entries = match std::fs::read_dir(&projects_dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"claudbg: could not read {}: {}",
|
||||
projects_dir.display(),
|
||||
err
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
for proj_entry in project_entries {
|
||||
let proj_entry = match proj_entry {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: error reading project dir entry: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let proj_path = proj_entry.path();
|
||||
if !proj_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subagents_dir = proj_path.join("subagents");
|
||||
match collect_agents_in_dir(&subagents_dir, None) {
|
||||
Ok(mut found) => agents.append(&mut found),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"claudbg: error collecting agents in {}: {}",
|
||||
subagents_dir.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
/// Collect all `agent-*.jsonl` files from a single `subagents/` directory.
|
||||
///
|
||||
/// `filter_session_id` optionally restricts results to agents whose first JSONL
|
||||
/// line's `session_id` field matches the given value.
|
||||
fn collect_agents_in_dir(
|
||||
subagents_dir: &Path,
|
||||
filter_session_id: Option<&str>,
|
||||
) -> crate::error::Result<Vec<AgentRef>> {
|
||||
if !subagents_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut agents = Vec::new();
|
||||
|
||||
let entries = match std::fs::read_dir(subagents_dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"claudbg: could not read {}: {}",
|
||||
subagents_dir.display(),
|
||||
err
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: error reading subagent entry: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let file_path = entry.path();
|
||||
if file_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process agent-*.jsonl files.
|
||||
let file_name = match file_path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !file_name.starts_with("agent-") || !file_name.ends_with(".jsonl") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract agent_id: strip "agent-" prefix and ".jsonl" suffix.
|
||||
let agent_id = file_name
|
||||
.strip_prefix("agent-")
|
||||
.and_then(|s| s.strip_suffix(".jsonl"))
|
||||
.unwrap_or(&file_name)
|
||||
.to_string();
|
||||
|
||||
// Get last-modified time.
|
||||
let modified_at = match entry.metadata() {
|
||||
Ok(meta) => match meta.modified() {
|
||||
Ok(t) => DateTime::<Utc>::from(t),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"claudbg: could not read mtime for {}: {}",
|
||||
file_path.display(),
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: could not stat {}: {}", file_path.display(), err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Read session_id from first line of the agent file.
|
||||
let session_id = read_session_id_from_first_line(&file_path).unwrap_or_default();
|
||||
|
||||
// Filter by session_id if requested.
|
||||
if let Some(filter) = filter_session_id
|
||||
&& session_id != filter
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to read agent_type from meta.json.
|
||||
let meta_path = subagents_dir.join(format!("agent-{agent_id}.meta.json"));
|
||||
let agent_type = read_agent_type_from_meta(&meta_path);
|
||||
|
||||
agents.push(AgentRef {
|
||||
agent_id,
|
||||
session_id,
|
||||
agent_type,
|
||||
file_path,
|
||||
modified_at,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
/// Read the `session_id` from the first non-empty line of an agent JSONL file.
|
||||
fn read_session_id_from_first_line(path: &Path) -> Option<String> {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines() {
|
||||
let line = line.ok()?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entry) = serde_json::from_str::<RawEntry>(trimmed)
|
||||
&& entry.session_id.is_some()
|
||||
{
|
||||
return entry.session_id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to read `agent_type` from an `agent-{id}.meta.json` file.
|
||||
///
|
||||
/// Expects a JSON object with an `"agent_type"` string field.
|
||||
fn read_agent_type_from_meta(meta_path: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(meta_path).ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
value
|
||||
.get("agent_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `SessionRef` implements `Debug` and `Clone`.
|
||||
#[test]
|
||||
fn session_ref_debug_clone() {
|
||||
let sr = SessionRef {
|
||||
session_id: "abc123".to_string(),
|
||||
project_path: Some("/home/user/project".to_string()),
|
||||
file_path: PathBuf::from("/tmp/abc123.jsonl"),
|
||||
modified_at: DateTime::<Utc>::from(std::time::SystemTime::UNIX_EPOCH),
|
||||
};
|
||||
let cloned = sr.clone();
|
||||
assert_eq!(cloned.session_id, sr.session_id);
|
||||
// Ensure Debug works without panic.
|
||||
let _ = format!("{sr:?}");
|
||||
}
|
||||
|
||||
/// `AgentRef` implements `Debug` and `Clone`.
|
||||
#[test]
|
||||
fn agent_ref_debug_clone() {
|
||||
let ar = AgentRef {
|
||||
agent_id: "def456".to_string(),
|
||||
session_id: "abc123".to_string(),
|
||||
agent_type: Some("TaskAgent".to_string()),
|
||||
file_path: PathBuf::from("/tmp/agent-def456.jsonl"),
|
||||
modified_at: DateTime::<Utc>::from(std::time::SystemTime::UNIX_EPOCH),
|
||||
};
|
||||
let cloned = ar.clone();
|
||||
assert_eq!(cloned.agent_id, ar.agent_id);
|
||||
let _ = format!("{ar:?}");
|
||||
}
|
||||
|
||||
/// `discover_sessions()` does not panic and returns `Ok` even if
|
||||
/// `~/.claude/projects/` does not exist.
|
||||
#[test]
|
||||
fn discover_sessions_does_not_panic() {
|
||||
let result = discover_sessions();
|
||||
// Should always succeed (empty or non-empty).
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// `discover_all_agents()` does not panic and returns `Ok`.
|
||||
#[test]
|
||||
fn discover_all_agents_does_not_panic() {
|
||||
let result = discover_all_agents();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
//! JSONL session file discovery and reading.
|
||||
pub mod discovery;
|
||||
pub mod reader;
|
||||
@ -0,0 +1,115 @@
|
||||
//! Async JSONL session file reader.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::session::RawEntry;
|
||||
|
||||
/// Reads a JSONL session file and returns all successfully-parsed entries.
|
||||
///
|
||||
/// - Skips empty lines.
|
||||
/// - Skips lines that fail to parse (logs a warning with `eprintln!`).
|
||||
/// - Uses tokio async I/O with `BufReader` for efficiency.
|
||||
/// - If the file path does not exist, returns `Err(AppError::Io(...))`.
|
||||
pub async fn read_session_file(path: &Path) -> Result<Vec<RawEntry>> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Io)?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
loop {
|
||||
let line = lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(crate::error::AppError::Io)?;
|
||||
match line {
|
||||
None => break,
|
||||
Some(line) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<RawEntry>(trimmed) {
|
||||
Ok(entry) => entries.push(entry),
|
||||
Err(err) => {
|
||||
eprintln!("claudbg: skipping unparseable JSONL line: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// Helper: create a temp file with the given content, run the async reader.
|
||||
async fn read_temp(content: &str) -> Result<Vec<RawEntry>> {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join(format!(
|
||||
"claudbg_test_{}.jsonl",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos()
|
||||
));
|
||||
{
|
||||
let mut f = std::fs::File::create(&path).expect("create temp file");
|
||||
f.write_all(content.as_bytes()).expect("write temp file");
|
||||
}
|
||||
let result = read_session_file(&path).await;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
result
|
||||
}
|
||||
|
||||
/// Two valid JSON lines → two entries returned.
|
||||
#[tokio::test]
|
||||
async fn two_valid_lines() {
|
||||
let content = concat!(
|
||||
r#"{"type":"user","session_id":"abc"}"#,
|
||||
"\n",
|
||||
r#"{"type":"assistant","session_id":"abc"}"#,
|
||||
"\n"
|
||||
);
|
||||
let entries = read_temp(content).await.expect("should succeed");
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
/// One valid + one invalid JSON line → one entry returned (invalid skipped).
|
||||
#[tokio::test]
|
||||
async fn one_valid_one_invalid() {
|
||||
let content = concat!(
|
||||
r#"{"type":"user","session_id":"abc"}"#,
|
||||
"\n",
|
||||
"THIS IS NOT JSON\n"
|
||||
);
|
||||
let entries = read_temp(content).await.expect("should succeed");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].entry_type.as_deref(), Some("user"));
|
||||
}
|
||||
|
||||
/// Empty file → empty vec returned.
|
||||
#[tokio::test]
|
||||
async fn empty_file_returns_empty_vec() {
|
||||
let entries = read_temp("").await.expect("should succeed");
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
/// Nonexistent path → returns `Err`.
|
||||
#[tokio::test]
|
||||
async fn nonexistent_path_returns_err() {
|
||||
let path = std::path::PathBuf::from("/tmp/claudbg_nonexistent_8675309.jsonl");
|
||||
let result = read_session_file(&path).await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result, Err(crate::error::AppError::Io(_))));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue