use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::Serialize;
use serde_json::Value;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct JobsRoot {
path: PathBuf,
}
impl JobsRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::Artifacts {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("jobs"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list(&self) -> Result<Vec<JobSummary>> {
let entries = match fs::read_dir(&self.path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !ft.is_dir() {
continue;
}
let short_id = entry.file_name().to_string_lossy().into_owned();
let state_path = entry.path().join("state.json");
if !state_path.exists() {
continue;
}
match parse_state(&state_path, &short_id) {
Ok(summary) => out.push(summary),
Err(e) => tracing::warn!(?state_path, "skipping job: {e}"),
}
}
out.sort_by(|a, b| a.short_id.cmp(&b.short_id));
Ok(out)
}
pub fn get(&self, short_id: &str) -> Result<Job> {
let dir = self.path.join(short_id);
let state_path = dir.join("state.json");
if !state_path.exists() {
return Err(Error::Artifacts {
message: format!("no job at {}", dir.display()),
});
}
let summary = parse_state(&state_path, short_id)?;
let timeline = parse_timeline(&dir.join("timeline.jsonl"));
let raw_state =
serde_json::from_str(&fs::read_to_string(&state_path)?).unwrap_or(Value::Null);
Ok(Job {
summary,
timeline,
raw_state,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct JobSummary {
pub short_id: String,
pub state: String,
pub daemon_short: Option<String>,
pub backend: Option<String>,
pub name: Option<String>,
pub detail: Option<String>,
pub intent: Option<String>,
pub session_id: Option<String>,
pub session_path: Option<PathBuf>,
pub cwd: Option<PathBuf>,
pub origin_cwd: Option<PathBuf>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub first_terminal_at: Option<String>,
pub cli_version: Option<String>,
pub state_mtime_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Job {
pub summary: JobSummary,
pub timeline: Vec<JobEvent>,
pub raw_state: Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct JobEvent {
pub at: Option<String>,
pub state: Option<String>,
pub detail: Option<String>,
pub text: Option<String>,
pub extra: Value,
}
fn parse_state(path: &Path, short_id: &str) -> Result<JobSummary> {
let raw = fs::read_to_string(path)?;
let v: Value = serde_json::from_str(&raw).map_err(|e| Error::Artifacts {
message: format!("parse {}: {e}", path.display()),
})?;
let state_mtime_secs = fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Ok(JobSummary {
short_id: short_id.to_string(),
state: v
.get("state")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
daemon_short: v
.get("daemonShort")
.and_then(Value::as_str)
.map(str::to_string),
backend: v.get("backend").and_then(Value::as_str).map(str::to_string),
name: v.get("name").and_then(Value::as_str).map(str::to_string),
detail: v.get("detail").and_then(Value::as_str).map(str::to_string),
intent: v.get("intent").and_then(Value::as_str).map(str::to_string),
session_id: v
.get("sessionId")
.and_then(Value::as_str)
.map(str::to_string),
session_path: v
.get("linkScanPath")
.and_then(Value::as_str)
.map(PathBuf::from),
cwd: v.get("cwd").and_then(Value::as_str).map(PathBuf::from),
origin_cwd: v
.get("originCwd")
.and_then(Value::as_str)
.map(PathBuf::from),
created_at: v
.get("createdAt")
.and_then(Value::as_str)
.map(str::to_string),
updated_at: v
.get("updatedAt")
.and_then(Value::as_str)
.map(str::to_string),
first_terminal_at: v
.get("firstTerminalAt")
.and_then(Value::as_str)
.map(str::to_string),
cli_version: v
.get("cliVersion")
.and_then(Value::as_str)
.map(str::to_string),
state_mtime_secs,
})
}
fn parse_timeline(path: &Path) -> Vec<JobEvent> {
let Ok(file) = fs::File::open(path) else {
return Vec::new();
};
let mut out = Vec::new();
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = match line {
Ok(s) => s,
Err(e) => {
tracing::warn!(?path, "timeline line {i}: read error: {e}");
continue;
}
};
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(v) => out.push(JobEvent {
at: v.get("at").and_then(Value::as_str).map(str::to_string),
state: v.get("state").and_then(Value::as_str).map(str::to_string),
detail: v.get("detail").and_then(Value::as_str).map(str::to_string),
text: v.get("text").and_then(Value::as_str).map(str::to_string),
extra: v,
}),
Err(e) => {
tracing::warn!(?path, "timeline line {i}: parse error: {e}");
}
}
}
out
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_job(root: &Path, short_id: &str, state_json: &str, timeline_lines: &[&str]) {
let dir = root.join(short_id);
fs::create_dir_all(&dir).expect("mkdir");
fs::write(dir.join("state.json"), state_json).expect("write state.json");
if !timeline_lines.is_empty() {
let mut f = fs::File::create(dir.join("timeline.jsonl")).expect("create timeline");
for line in timeline_lines {
writeln!(f, "{line}").unwrap();
}
}
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
write_job(
tmp.path(),
"aaaaaaaa",
r#"{"state":"done","detail":"42","intent":"meaning of life",
"sessionId":"sess-aaa","linkScanPath":"/p/sess-aaa.jsonl",
"cwd":"/work","createdAt":"2026-05-15T01:00:00Z",
"updatedAt":"2026-05-15T01:01:00Z","firstTerminalAt":"2026-05-15T01:00:55Z",
"name":"meaning of life","backend":"daemon","cliVersion":"2.1.143",
"daemonShort":"aaaaaaaa","originCwd":"/work"}"#,
&[
r#"{"at":"2026-05-15T01:00:30Z","state":"running","detail":"thinking"}"#,
r#"{"at":"2026-05-15T01:00:55Z","state":"done","detail":"42","text":"the answer is 42"}"#,
],
);
write_job(
tmp.path(),
"bbbbbbbb",
r#"{"state":"running","intent":"compute primes","sessionId":"sess-bbb"}"#,
&[r#"{"at":"2026-05-15T02:00:00Z","state":"running","detail":"started"}"#],
);
fs::create_dir_all(tmp.path().join("cccccccc")).unwrap();
fs::write(tmp.path().join("pins.json"), "[]").unwrap();
write_job(tmp.path(), "deadbeef", "not valid json {{", &[]);
tmp
}
#[test]
fn list_returns_only_well_formed_jobs_sorted_by_short_id() {
let tmp = fixture_root();
let root = JobsRoot::at(tmp.path());
let jobs = root.list().expect("list");
let ids: Vec<&str> = jobs.iter().map(|j| j.short_id.as_str()).collect();
assert_eq!(ids, ["aaaaaaaa", "bbbbbbbb"]);
}
#[test]
fn list_missing_root_returns_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = JobsRoot::at(tmp.path().join("does-not-exist"));
assert!(root.list().expect("list").is_empty());
}
#[test]
fn list_summary_carries_typed_fields() {
let tmp = fixture_root();
let root = JobsRoot::at(tmp.path());
let jobs = root.list().expect("list");
let s = jobs.iter().find(|j| j.short_id == "aaaaaaaa").unwrap();
assert_eq!(s.state, "done");
assert_eq!(s.intent.as_deref(), Some("meaning of life"));
assert_eq!(s.session_id.as_deref(), Some("sess-aaa"));
assert_eq!(s.session_path, Some(PathBuf::from("/p/sess-aaa.jsonl")));
assert_eq!(s.cwd, Some(PathBuf::from("/work")));
assert_eq!(s.name.as_deref(), Some("meaning of life"));
assert_eq!(s.backend.as_deref(), Some("daemon"));
assert_eq!(s.cli_version.as_deref(), Some("2.1.143"));
assert_eq!(s.daemon_short.as_deref(), Some("aaaaaaaa"));
assert_eq!(s.origin_cwd, Some(PathBuf::from("/work")));
assert_eq!(s.created_at.as_deref(), Some("2026-05-15T01:00:00Z"));
assert_eq!(s.updated_at.as_deref(), Some("2026-05-15T01:01:00Z"));
assert_eq!(s.first_terminal_at.as_deref(), Some("2026-05-15T01:00:55Z"));
assert!(s.state_mtime_secs.is_some());
}
#[test]
fn list_running_job_has_no_first_terminal_at() {
let tmp = fixture_root();
let root = JobsRoot::at(tmp.path());
let jobs = root.list().expect("list");
let s = jobs.iter().find(|j| j.short_id == "bbbbbbbb").unwrap();
assert_eq!(s.state, "running");
assert!(s.first_terminal_at.is_none());
}
#[test]
fn get_returns_full_record_with_timeline() {
let tmp = fixture_root();
let root = JobsRoot::at(tmp.path());
let job = root.get("aaaaaaaa").expect("get");
assert_eq!(job.summary.state, "done");
assert_eq!(job.timeline.len(), 2);
assert_eq!(job.timeline[0].state.as_deref(), Some("running"));
assert_eq!(job.timeline[1].state.as_deref(), Some("done"));
assert_eq!(job.timeline[1].text.as_deref(), Some("the answer is 42"));
assert!(!job.raw_state.is_null());
}
#[test]
fn get_no_timeline_returns_empty_vec() {
let tmp = tempfile::tempdir().expect("tempdir");
write_job(
tmp.path(),
"ffffffff",
r#"{"state":"queued","intent":"x","sessionId":"y"}"#,
&[],
);
let root = JobsRoot::at(tmp.path());
let job = root.get("ffffffff").expect("get");
assert!(job.timeline.is_empty());
}
#[test]
fn get_unknown_id_errors() {
let tmp = fixture_root();
let root = JobsRoot::at(tmp.path());
let err = root.get("nope").unwrap_err();
assert!(err.to_string().contains("no job"));
}
#[test]
fn timeline_skips_malformed_lines_without_failing() {
let tmp = tempfile::tempdir().expect("tempdir");
write_job(
tmp.path(),
"mixed",
r#"{"state":"done","intent":"x","sessionId":"y"}"#,
&[
r#"{"at":"t1","state":"running"}"#,
r#"NOT VALID JSON"#,
r#""#, r#"{"at":"t2","state":"done","text":"final"}"#,
],
);
let root = JobsRoot::at(tmp.path());
let job = root.get("mixed").expect("get");
assert_eq!(job.timeline.len(), 2);
assert_eq!(job.timeline[0].at.as_deref(), Some("t1"));
assert_eq!(job.timeline[1].at.as_deref(), Some("t2"));
assert_eq!(job.timeline[1].text.as_deref(), Some("final"));
}
#[test]
fn unknown_state_string_passes_through() {
let tmp = tempfile::tempdir().expect("tempdir");
write_job(
tmp.path(),
"weirdstate",
r#"{"state":"some-future-state","intent":"x","sessionId":"y"}"#,
&[],
);
let root = JobsRoot::at(tmp.path());
let job = root.get("weirdstate").expect("get");
assert_eq!(job.summary.state, "some-future-state");
}
#[test]
fn raw_state_preserves_unknown_fields() {
let tmp = tempfile::tempdir().expect("tempdir");
write_job(
tmp.path(),
"extras",
r#"{"state":"done","intent":"x","sessionId":"y",
"futureField":{"nested":42},"tempo":"idle"}"#,
&[],
);
let root = JobsRoot::at(tmp.path());
let job = root.get("extras").expect("get");
assert_eq!(job.raw_state["futureField"]["nested"], 42);
assert_eq!(job.raw_state["tempo"], "idle");
}
#[test]
fn missing_state_field_defaults_to_unknown() {
let tmp = tempfile::tempdir().expect("tempdir");
write_job(tmp.path(), "nostate", r#"{"intent":"x"}"#, &[]);
let root = JobsRoot::at(tmp.path());
let summary = &root.list().expect("list")[0];
assert_eq!(summary.state, "unknown");
}
#[test]
#[ignore = "reads the user's real ~/.claude/jobs; may be empty"]
fn live_list_real_jobs_dir() {
let root = JobsRoot::home().expect("home dir");
for s in root.list().expect("list") {
assert!(!s.short_id.is_empty(), "empty short_id: {s:?}");
assert!(!s.state.is_empty(), "empty state: {s:?}");
}
}
}