use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::profile::clauth_dir;
const DONE_TTL_MS: u64 = 60 * 60 * 1000; const RUNNING_TTL_MS: u64 = (3600 + 600) * 1000;
const MAX_RETAINED: usize = 256;
static JOB_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum JobState {
Running,
Done,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct JobRecord {
pub(crate) job_id: String,
pub(crate) profile: String,
pub(crate) state: JobState,
pub(crate) started_at: u64,
#[serde(default)]
pub(crate) monitor: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) envelope: Option<serde_json::Value>,
}
pub(crate) fn jobs_dir() -> Result<PathBuf> {
Ok(clauth_dir()?.join("jobs"))
}
pub(crate) fn new_job_id(started_at: u64) -> String {
let n = JOB_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("d-{started_at}-{n}")
}
pub(crate) fn is_safe_job_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn job_path(job_id: &str) -> Result<PathBuf> {
Ok(jobs_dir()?.join(format!("{job_id}.json")))
}
fn write_atomic(record: &JobRecord) -> Result<()> {
let dir = jobs_dir()?;
std::fs::create_dir_all(&dir)?;
let bytes = serde_json::to_vec(record)?;
let tmp_path = dir.join(format!("{}.json.tmp", record.job_id));
std::fs::write(&tmp_path, &bytes)?;
std::fs::rename(&tmp_path, dir.join(format!("{}.json", record.job_id)))?;
Ok(())
}
pub(crate) fn write_running(
job_id: &str,
profile: &str,
started_at: u64,
monitor: bool,
) -> Result<()> {
write_atomic(&JobRecord {
job_id: job_id.to_string(),
profile: profile.to_string(),
state: JobState::Running,
started_at,
monitor,
envelope: None,
})
}
pub(crate) fn write_done(
job_id: &str,
profile: &str,
started_at: u64,
envelope: serde_json::Value,
) -> Result<()> {
write_atomic(&JobRecord {
job_id: job_id.to_string(),
profile: profile.to_string(),
state: JobState::Done,
started_at,
monitor: false,
envelope: Some(envelope),
})
}
pub(crate) fn read(job_id: &str) -> Option<JobRecord> {
let bytes = std::fs::read(job_path(job_id).ok()?).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub(crate) fn remove(job_id: &str) {
if let Ok(path) = job_path(job_id) {
let _ = std::fs::remove_file(path);
}
}
pub(crate) fn gc(now: u64) {
let Ok(dir) = jobs_dir() else {
return;
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let mut kept: Vec<(u64, PathBuf)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
let _ = std::fs::remove_file(&path); continue;
}
let record = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<JobRecord>(&b).ok());
let Some(record) = record else {
let _ = std::fs::remove_file(&path);
continue;
};
let age = now.saturating_sub(record.started_at);
let expired = match record.state {
JobState::Done => age > DONE_TTL_MS,
JobState::Running => age > RUNNING_TTL_MS,
};
if expired {
let _ = std::fs::remove_file(&path);
} else {
kept.push((record.started_at, path));
}
}
if kept.len() > MAX_RETAINED {
kept.sort_by_key(|k| std::cmp::Reverse(k.0)); for (_, path) in kept.into_iter().skip(MAX_RETAINED) {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg(test)]
#[path = "../../tests/inline/mcp_jobs.rs"]
mod tests;