use std::collections::HashMap;
use std::sync::Arc;
use serde::Serialize;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Queued,
Running,
Done,
Flagged,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct Job {
pub id: String,
pub status: JobStatus,
pub output: Option<String>,
pub error: Option<String>,
pub message: String,
pub provider: String,
pub project: Option<String>,
pub channel: Option<String>,
pub created_at: String,
pub finished_at: Option<String>,
}
#[derive(Debug)]
pub struct JobRequest {
pub job_id: String,
pub message: String,
pub provider: String,
pub model: Option<String>,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub project: Option<String>,
pub channel: Option<String>,
pub max_tokens: Option<u32>,
pub verbose: bool,
pub skills: Option<Vec<String>>,
pub mode: Option<String>,
pub workflow: Option<String>,
}
pub type JobStore = Arc<RwLock<HashMap<String, Job>>>;
pub const MAX_STORE_JOBS: usize = 1_000;
pub fn new_store() -> JobStore {
Arc::new(RwLock::new(HashMap::new()))
}
pub fn evict_oldest_finished(store: &mut HashMap<String, Job>) {
if store.len() <= MAX_STORE_JOBS {
return;
}
let mut finished: Vec<(String, String)> = store
.iter()
.filter(|(_, j)| {
matches!(
j.status,
JobStatus::Done | JobStatus::Flagged | JobStatus::Failed
)
})
.map(|(id, j)| (id.clone(), j.created_at.clone()))
.collect();
finished.sort_by(|a, b| a.1.cmp(&b.1));
let to_remove = store.len().saturating_sub(MAX_STORE_JOBS);
for (id, _) in finished.into_iter().take(to_remove) {
store.remove(&id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_job(id: &str, status: JobStatus, created_at: &str) -> Job {
Job {
id: id.to_string(),
status,
output: None,
error: None,
message: "test".to_string(),
provider: "claude-code".to_string(),
project: None,
channel: None,
created_at: created_at.to_string(),
finished_at: None,
}
}
#[test]
fn evict_below_cap_is_noop() {
let mut store = HashMap::new();
store.insert(
"a".to_string(),
make_job("a", JobStatus::Done, "2026-01-01T00:00:00Z"),
);
evict_oldest_finished(&mut store);
assert_eq!(store.len(), 1);
}
#[test]
fn evict_removes_oldest_finished_when_over_cap() {
let mut store = HashMap::new();
for i in 0..=MAX_STORE_JOBS {
let id = format!("job-{i:04}");
let ts = format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1);
store.insert(id.clone(), make_job(&id, JobStatus::Done, &ts));
}
assert_eq!(store.len(), MAX_STORE_JOBS + 1);
evict_oldest_finished(&mut store);
assert_eq!(store.len(), MAX_STORE_JOBS);
}
#[test]
fn evict_does_not_remove_active_jobs() {
let mut store = HashMap::new();
for i in 0..MAX_STORE_JOBS {
let id = format!("done-{i:04}");
store.insert(
id.clone(),
make_job(&id, JobStatus::Done, "2026-01-01T00:00:00Z"),
);
}
store.insert(
"active".to_string(),
make_job("active", JobStatus::Running, "2026-01-01T00:00:00Z"),
);
evict_oldest_finished(&mut store);
assert_eq!(store.len(), MAX_STORE_JOBS);
assert!(
store.contains_key("active"),
"running job must not be evicted"
);
}
#[test]
fn new_store_is_empty() {
let store = new_store();
let guard = store.try_read().unwrap();
assert!(guard.is_empty());
}
#[test]
fn job_status_serializes_lowercase() {
let s = serde_json::to_string(&JobStatus::Queued).unwrap();
assert_eq!(s, "\"queued\"");
let s = serde_json::to_string(&JobStatus::Running).unwrap();
assert_eq!(s, "\"running\"");
let s = serde_json::to_string(&JobStatus::Done).unwrap();
assert_eq!(s, "\"done\"");
let s = serde_json::to_string(&JobStatus::Flagged).unwrap();
assert_eq!(s, "\"flagged\"");
let s = serde_json::to_string(&JobStatus::Failed).unwrap();
assert_eq!(s, "\"failed\"");
}
#[test]
fn job_serializes_fields() {
let job = Job {
id: "abc-123".to_string(),
status: JobStatus::Queued,
output: None,
error: None,
message: "hello".to_string(),
provider: "claude-code".to_string(),
project: Some("my-app".to_string()),
channel: None,
created_at: "2026-04-03T09:00:00Z".to_string(),
finished_at: None,
};
let json = serde_json::to_string(&job).unwrap();
assert!(json.contains("\"id\":\"abc-123\""));
assert!(json.contains("\"status\":\"queued\""));
assert!(json.contains("\"message\":\"hello\""));
}
}