1use std::fs;
45use std::io::{BufRead, BufReader};
46use std::path::{Path, PathBuf};
47use std::time::SystemTime;
48
49use serde::Serialize;
50use serde_json::Value;
51
52use crate::error::{Error, Result};
53
54#[derive(Debug, Clone)]
58pub struct JobsRoot {
59 path: PathBuf,
60}
61
62impl JobsRoot {
63 pub fn home() -> Result<Self> {
66 let home = home_dir().ok_or_else(|| Error::Artifacts {
67 message: "could not determine user home directory".to_string(),
68 })?;
69 Ok(Self {
70 path: home.join(".claude").join("jobs"),
71 })
72 }
73
74 pub fn at(path: impl Into<PathBuf>) -> Self {
77 Self { path: path.into() }
78 }
79
80 pub fn path(&self) -> &Path {
82 &self.path
83 }
84
85 pub fn list(&self) -> Result<Vec<JobSummary>> {
93 let entries = match fs::read_dir(&self.path) {
94 Ok(it) => it,
95 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
96 Err(e) => return Err(e.into()),
97 };
98
99 let mut out = Vec::new();
100 for entry in entries.flatten() {
101 let ft = match entry.file_type() {
102 Ok(ft) => ft,
103 Err(_) => continue,
104 };
105 if !ft.is_dir() {
106 continue;
108 }
109 let short_id = entry.file_name().to_string_lossy().into_owned();
110 let state_path = entry.path().join("state.json");
111 if !state_path.exists() {
112 continue;
114 }
115 match parse_state(&state_path, &short_id) {
116 Ok(summary) => out.push(summary),
117 Err(e) => tracing::warn!(?state_path, "skipping job: {e}"),
118 }
119 }
120 out.sort_by(|a, b| a.short_id.cmp(&b.short_id));
121 Ok(out)
122 }
123
124 pub fn get(&self, short_id: &str) -> Result<Job> {
129 let dir = self.path.join(short_id);
130 let state_path = dir.join("state.json");
131 if !state_path.exists() {
132 return Err(Error::Artifacts {
133 message: format!("no job at {}", dir.display()),
134 });
135 }
136 let summary = parse_state(&state_path, short_id)?;
137 let timeline = parse_timeline(&dir.join("timeline.jsonl"));
138 let raw_state =
139 serde_json::from_str(&fs::read_to_string(&state_path)?).unwrap_or(Value::Null);
140 Ok(Job {
141 summary,
142 timeline,
143 raw_state,
144 })
145 }
146}
147
148#[derive(Debug, Clone, Serialize)]
151pub struct JobSummary {
152 pub short_id: String,
155 pub state: String,
158 pub daemon_short: Option<String>,
162 pub backend: Option<String>,
165 pub name: Option<String>,
169 pub detail: Option<String>,
172 pub intent: Option<String>,
176 pub session_id: Option<String>,
179 pub session_path: Option<PathBuf>,
182 pub cwd: Option<PathBuf>,
184 pub origin_cwd: Option<PathBuf>,
187 pub created_at: Option<String>,
189 pub updated_at: Option<String>,
191 pub first_terminal_at: Option<String>,
194 pub cli_version: Option<String>,
197 pub state_mtime_secs: Option<u64>,
201}
202
203#[derive(Debug, Clone, Serialize)]
208pub struct Job {
209 pub summary: JobSummary,
211 pub timeline: Vec<JobEvent>,
213 pub raw_state: Value,
217}
218
219#[derive(Debug, Clone, Serialize)]
223pub struct JobEvent {
224 pub at: Option<String>,
226 pub state: Option<String>,
228 pub detail: Option<String>,
230 pub text: Option<String>,
234 pub extra: Value,
237}
238
239fn parse_state(path: &Path, short_id: &str) -> Result<JobSummary> {
240 let raw = fs::read_to_string(path)?;
241 let v: Value = serde_json::from_str(&raw).map_err(|e| Error::Artifacts {
242 message: format!("parse {}: {e}", path.display()),
243 })?;
244 let state_mtime_secs = fs::metadata(path)
245 .and_then(|m| m.modified())
246 .ok()
247 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
248 .map(|d| d.as_secs());
249 Ok(JobSummary {
250 short_id: short_id.to_string(),
251 state: v
252 .get("state")
253 .and_then(Value::as_str)
254 .unwrap_or("unknown")
255 .to_string(),
256 daemon_short: v
257 .get("daemonShort")
258 .and_then(Value::as_str)
259 .map(str::to_string),
260 backend: v.get("backend").and_then(Value::as_str).map(str::to_string),
261 name: v.get("name").and_then(Value::as_str).map(str::to_string),
262 detail: v.get("detail").and_then(Value::as_str).map(str::to_string),
263 intent: v.get("intent").and_then(Value::as_str).map(str::to_string),
264 session_id: v
265 .get("sessionId")
266 .and_then(Value::as_str)
267 .map(str::to_string),
268 session_path: v
269 .get("linkScanPath")
270 .and_then(Value::as_str)
271 .map(PathBuf::from),
272 cwd: v.get("cwd").and_then(Value::as_str).map(PathBuf::from),
273 origin_cwd: v
274 .get("originCwd")
275 .and_then(Value::as_str)
276 .map(PathBuf::from),
277 created_at: v
278 .get("createdAt")
279 .and_then(Value::as_str)
280 .map(str::to_string),
281 updated_at: v
282 .get("updatedAt")
283 .and_then(Value::as_str)
284 .map(str::to_string),
285 first_terminal_at: v
286 .get("firstTerminalAt")
287 .and_then(Value::as_str)
288 .map(str::to_string),
289 cli_version: v
290 .get("cliVersion")
291 .and_then(Value::as_str)
292 .map(str::to_string),
293 state_mtime_secs,
294 })
295}
296
297fn parse_timeline(path: &Path) -> Vec<JobEvent> {
298 let Ok(file) = fs::File::open(path) else {
299 return Vec::new();
300 };
301 let mut out = Vec::new();
302 for (i, line) in BufReader::new(file).lines().enumerate() {
303 let line = match line {
304 Ok(s) => s,
305 Err(e) => {
306 tracing::warn!(?path, "timeline line {i}: read error: {e}");
307 continue;
308 }
309 };
310 if line.trim().is_empty() {
311 continue;
312 }
313 match serde_json::from_str::<Value>(&line) {
314 Ok(v) => out.push(JobEvent {
315 at: v.get("at").and_then(Value::as_str).map(str::to_string),
316 state: v.get("state").and_then(Value::as_str).map(str::to_string),
317 detail: v.get("detail").and_then(Value::as_str).map(str::to_string),
318 text: v.get("text").and_then(Value::as_str).map(str::to_string),
319 extra: v,
320 }),
321 Err(e) => {
322 tracing::warn!(?path, "timeline line {i}: parse error: {e}");
323 }
324 }
325 }
326 out
327}
328
329fn home_dir() -> Option<PathBuf> {
330 if let Ok(h) = std::env::var("HOME")
331 && !h.is_empty()
332 {
333 return Some(PathBuf::from(h));
334 }
335 if let Ok(h) = std::env::var("USERPROFILE")
336 && !h.is_empty()
337 {
338 return Some(PathBuf::from(h));
339 }
340 None
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use std::io::Write;
347
348 fn write_job(root: &Path, short_id: &str, state_json: &str, timeline_lines: &[&str]) {
351 let dir = root.join(short_id);
352 fs::create_dir_all(&dir).expect("mkdir");
353 fs::write(dir.join("state.json"), state_json).expect("write state.json");
354 if !timeline_lines.is_empty() {
355 let mut f = fs::File::create(dir.join("timeline.jsonl")).expect("create timeline");
356 for line in timeline_lines {
357 writeln!(f, "{line}").unwrap();
358 }
359 }
360 }
361
362 fn fixture_root() -> tempfile::TempDir {
363 let tmp = tempfile::tempdir().expect("tempdir");
364 write_job(
366 tmp.path(),
367 "aaaaaaaa",
368 r#"{"state":"done","detail":"42","intent":"meaning of life",
369 "sessionId":"sess-aaa","linkScanPath":"/p/sess-aaa.jsonl",
370 "cwd":"/work","createdAt":"2026-05-15T01:00:00Z",
371 "updatedAt":"2026-05-15T01:01:00Z","firstTerminalAt":"2026-05-15T01:00:55Z",
372 "name":"meaning of life","backend":"daemon","cliVersion":"2.1.143",
373 "daemonShort":"aaaaaaaa","originCwd":"/work"}"#,
374 &[
375 r#"{"at":"2026-05-15T01:00:30Z","state":"running","detail":"thinking"}"#,
376 r#"{"at":"2026-05-15T01:00:55Z","state":"done","detail":"42","text":"the answer is 42"}"#,
377 ],
378 );
379 write_job(
381 tmp.path(),
382 "bbbbbbbb",
383 r#"{"state":"running","intent":"compute primes","sessionId":"sess-bbb"}"#,
384 &[r#"{"at":"2026-05-15T02:00:00Z","state":"running","detail":"started"}"#],
385 );
386 fs::create_dir_all(tmp.path().join("cccccccc")).unwrap();
388 fs::write(tmp.path().join("pins.json"), "[]").unwrap();
390 write_job(tmp.path(), "deadbeef", "not valid json {{", &[]);
392 tmp
393 }
394
395 #[test]
396 fn list_returns_only_well_formed_jobs_sorted_by_short_id() {
397 let tmp = fixture_root();
398 let root = JobsRoot::at(tmp.path());
399 let jobs = root.list().expect("list");
400 let ids: Vec<&str> = jobs.iter().map(|j| j.short_id.as_str()).collect();
401 assert_eq!(ids, ["aaaaaaaa", "bbbbbbbb"]);
402 }
403
404 #[test]
405 fn list_missing_root_returns_empty() {
406 let tmp = tempfile::tempdir().expect("tempdir");
407 let root = JobsRoot::at(tmp.path().join("does-not-exist"));
408 assert!(root.list().expect("list").is_empty());
409 }
410
411 #[test]
412 fn list_summary_carries_typed_fields() {
413 let tmp = fixture_root();
414 let root = JobsRoot::at(tmp.path());
415 let jobs = root.list().expect("list");
416 let s = jobs.iter().find(|j| j.short_id == "aaaaaaaa").unwrap();
417 assert_eq!(s.state, "done");
418 assert_eq!(s.intent.as_deref(), Some("meaning of life"));
419 assert_eq!(s.session_id.as_deref(), Some("sess-aaa"));
420 assert_eq!(s.session_path, Some(PathBuf::from("/p/sess-aaa.jsonl")));
421 assert_eq!(s.cwd, Some(PathBuf::from("/work")));
422 assert_eq!(s.name.as_deref(), Some("meaning of life"));
423 assert_eq!(s.backend.as_deref(), Some("daemon"));
424 assert_eq!(s.cli_version.as_deref(), Some("2.1.143"));
425 assert_eq!(s.daemon_short.as_deref(), Some("aaaaaaaa"));
426 assert_eq!(s.origin_cwd, Some(PathBuf::from("/work")));
427 assert_eq!(s.created_at.as_deref(), Some("2026-05-15T01:00:00Z"));
428 assert_eq!(s.updated_at.as_deref(), Some("2026-05-15T01:01:00Z"));
429 assert_eq!(s.first_terminal_at.as_deref(), Some("2026-05-15T01:00:55Z"));
430 assert!(s.state_mtime_secs.is_some());
431 }
432
433 #[test]
434 fn list_running_job_has_no_first_terminal_at() {
435 let tmp = fixture_root();
436 let root = JobsRoot::at(tmp.path());
437 let jobs = root.list().expect("list");
438 let s = jobs.iter().find(|j| j.short_id == "bbbbbbbb").unwrap();
439 assert_eq!(s.state, "running");
440 assert!(s.first_terminal_at.is_none());
441 }
442
443 #[test]
444 fn get_returns_full_record_with_timeline() {
445 let tmp = fixture_root();
446 let root = JobsRoot::at(tmp.path());
447 let job = root.get("aaaaaaaa").expect("get");
448 assert_eq!(job.summary.state, "done");
449 assert_eq!(job.timeline.len(), 2);
450 assert_eq!(job.timeline[0].state.as_deref(), Some("running"));
451 assert_eq!(job.timeline[1].state.as_deref(), Some("done"));
452 assert_eq!(job.timeline[1].text.as_deref(), Some("the answer is 42"));
453 assert!(!job.raw_state.is_null());
454 }
455
456 #[test]
457 fn get_no_timeline_returns_empty_vec() {
458 let tmp = tempfile::tempdir().expect("tempdir");
461 write_job(
462 tmp.path(),
463 "ffffffff",
464 r#"{"state":"queued","intent":"x","sessionId":"y"}"#,
465 &[],
466 );
467 let root = JobsRoot::at(tmp.path());
468 let job = root.get("ffffffff").expect("get");
469 assert!(job.timeline.is_empty());
470 }
471
472 #[test]
473 fn get_unknown_id_errors() {
474 let tmp = fixture_root();
475 let root = JobsRoot::at(tmp.path());
476 let err = root.get("nope").unwrap_err();
477 assert!(err.to_string().contains("no job"));
478 }
479
480 #[test]
481 fn timeline_skips_malformed_lines_without_failing() {
482 let tmp = tempfile::tempdir().expect("tempdir");
483 write_job(
484 tmp.path(),
485 "mixed",
486 r#"{"state":"done","intent":"x","sessionId":"y"}"#,
487 &[
488 r#"{"at":"t1","state":"running"}"#,
489 r#"NOT VALID JSON"#,
490 r#""#, r#"{"at":"t2","state":"done","text":"final"}"#,
492 ],
493 );
494 let root = JobsRoot::at(tmp.path());
495 let job = root.get("mixed").expect("get");
496 assert_eq!(job.timeline.len(), 2);
497 assert_eq!(job.timeline[0].at.as_deref(), Some("t1"));
498 assert_eq!(job.timeline[1].at.as_deref(), Some("t2"));
499 assert_eq!(job.timeline[1].text.as_deref(), Some("final"));
500 }
501
502 #[test]
503 fn unknown_state_string_passes_through() {
504 let tmp = tempfile::tempdir().expect("tempdir");
506 write_job(
507 tmp.path(),
508 "weirdstate",
509 r#"{"state":"some-future-state","intent":"x","sessionId":"y"}"#,
510 &[],
511 );
512 let root = JobsRoot::at(tmp.path());
513 let job = root.get("weirdstate").expect("get");
514 assert_eq!(job.summary.state, "some-future-state");
515 }
516
517 #[test]
518 fn raw_state_preserves_unknown_fields() {
519 let tmp = tempfile::tempdir().expect("tempdir");
520 write_job(
521 tmp.path(),
522 "extras",
523 r#"{"state":"done","intent":"x","sessionId":"y",
524 "futureField":{"nested":42},"tempo":"idle"}"#,
525 &[],
526 );
527 let root = JobsRoot::at(tmp.path());
528 let job = root.get("extras").expect("get");
529 assert_eq!(job.raw_state["futureField"]["nested"], 42);
530 assert_eq!(job.raw_state["tempo"], "idle");
531 }
532
533 #[test]
534 fn missing_state_field_defaults_to_unknown() {
535 let tmp = tempfile::tempdir().expect("tempdir");
536 write_job(tmp.path(), "nostate", r#"{"intent":"x"}"#, &[]);
537 let root = JobsRoot::at(tmp.path());
538 let summary = &root.list().expect("list")[0];
539 assert_eq!(summary.state, "unknown");
540 }
541
542 #[test]
545 #[ignore = "reads the user's real ~/.claude/jobs; may be empty"]
546 fn live_list_real_jobs_dir() {
547 let root = JobsRoot::home().expect("home dir");
548 for s in root.list().expect("list") {
551 assert!(!s.short_id.is_empty(), "empty short_id: {s:?}");
552 assert!(!s.state.is_empty(), "empty state: {s:?}");
553 }
554 }
555}