1use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::sync::Mutex;
9
10fn default_true() -> bool {
11 true
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct Job {
18 pub id: String,
19 pub name: String,
20 pub schedule: String,
22 pub prompt: String,
24 pub channel: String,
26 #[serde(default)]
28 pub target: Option<String>,
29 #[serde(default)]
34 pub owner: Option<String>,
35 #[serde(default = "default_true")]
36 pub enabled: bool,
37 #[serde(default)]
38 pub last_run_ms: Option<i64>,
39 #[serde(default)]
40 pub next_run_ms: Option<i64>,
41 pub created_ms: i64,
42}
43
44impl Job {
45 pub fn new(
46 name: impl Into<String>,
47 schedule: impl Into<String>,
48 prompt: impl Into<String>,
49 channel: impl Into<String>,
50 created_ms: i64,
51 ) -> Self {
52 Self {
53 id: gen_id(created_ms),
54 name: name.into(),
55 schedule: schedule.into(),
56 prompt: prompt.into(),
57 channel: channel.into(),
58 target: None,
59 owner: None,
60 enabled: true,
61 last_run_ms: None,
62 next_run_ms: None,
63 created_ms,
64 }
65 }
66 pub fn with_target(mut self, t: Option<String>) -> Self {
67 self.target = t;
68 self
69 }
70 pub fn with_next_run(mut self, ms: Option<i64>) -> Self {
71 self.next_run_ms = ms;
72 self
73 }
74 pub fn with_owner(mut self, owner: Option<String>) -> Self {
75 self.owner = owner;
76 self
77 }
78 pub fn owned_by(&self, owner: Option<&str>) -> bool {
81 self.owner.as_deref() == owner
82 }
83}
84
85fn gen_id(created_ms: i64) -> String {
87 use std::sync::atomic::{AtomicU64, Ordering};
88 static SEQ: AtomicU64 = AtomicU64::new(0);
89 format!("job-{created_ms}-{}", SEQ.fetch_add(1, Ordering::SeqCst))
90}
91
92#[derive(Debug, thiserror::Error)]
93#[non_exhaustive]
94pub enum JobError {
95 #[error("job io: {0}")]
96 Io(String),
97 #[error("job serde: {0}")]
98 Serde(String),
99}
100
101#[async_trait]
102pub trait JobStore: Send + Sync + 'static {
103 async fn add(&self, job: &Job) -> Result<(), JobError>;
104 async fn list(&self) -> Result<Vec<Job>, JobError>;
107
108 async fn list_for_owner(&self, owner: Option<&str>) -> Result<Vec<Job>, JobError> {
112 Ok(self
113 .list()
114 .await?
115 .into_iter()
116 .filter(|j| j.owned_by(owner))
117 .collect())
118 }
119 async fn get(&self, id: &str) -> Result<Option<Job>, JobError>;
120 async fn remove(&self, id: &str) -> Result<bool, JobError>;
121 async fn set_enabled(&self, id: &str, enabled: bool) -> Result<bool, JobError>;
122 async fn record_run(
123 &self,
124 id: &str,
125 last_run_ms: i64,
126 next_run_ms: Option<i64>,
127 ) -> Result<(), JobError>;
128}
129
130pub struct FileJobStore {
133 path: PathBuf,
134 write_lock: Mutex<()>,
135}
136
137impl FileJobStore {
138 pub fn open(path: impl Into<PathBuf>) -> Result<Self, JobError> {
139 let path = path.into();
140 if let Some(parent) = path.parent()
141 && !parent.as_os_str().is_empty()
142 {
143 std::fs::create_dir_all(parent).map_err(|e| JobError::Io(e.to_string()))?;
144 }
145 Ok(Self {
146 path,
147 write_lock: Mutex::new(()),
148 })
149 }
150
151 fn read_all(&self) -> Result<Vec<Job>, JobError> {
152 match std::fs::read_to_string(&self.path) {
153 Ok(s) if !s.trim().is_empty() => {
154 serde_json::from_str(&s).map_err(|e| JobError::Serde(e.to_string()))
155 }
156 _ => Ok(Vec::new()),
157 }
158 }
159
160 fn write_all(&self, jobs: &[Job]) -> Result<(), JobError> {
161 let _g = self
162 .write_lock
163 .lock()
164 .map_err(|e| JobError::Io(e.to_string()))?;
165 let buf = serde_json::to_string_pretty(jobs).map_err(|e| JobError::Serde(e.to_string()))?;
166 let tmp = self.path.with_extension("json.tmp");
167 std::fs::write(&tmp, buf).map_err(|e| JobError::Io(e.to_string()))?;
168 std::fs::rename(&tmp, &self.path).map_err(|e| JobError::Io(e.to_string()))?;
169 Ok(())
170 }
171}
172
173#[async_trait]
174impl JobStore for FileJobStore {
175 async fn add(&self, job: &Job) -> Result<(), JobError> {
176 let mut jobs = self.read_all()?;
177 jobs.push(job.clone());
178 self.write_all(&jobs)
179 }
180 async fn list(&self) -> Result<Vec<Job>, JobError> {
181 self.read_all()
182 }
183 async fn get(&self, id: &str) -> Result<Option<Job>, JobError> {
184 Ok(self.read_all()?.into_iter().find(|j| j.id == id))
185 }
186 async fn remove(&self, id: &str) -> Result<bool, JobError> {
187 let mut jobs = self.read_all()?;
188 let before = jobs.len();
189 jobs.retain(|j| j.id != id);
190 let removed = jobs.len() != before;
191 if removed {
192 self.write_all(&jobs)?;
193 }
194 Ok(removed)
195 }
196 async fn set_enabled(&self, id: &str, enabled: bool) -> Result<bool, JobError> {
197 let mut jobs = self.read_all()?;
198 let mut found = false;
199 for j in &mut jobs {
200 if j.id == id {
201 j.enabled = enabled;
202 found = true;
203 }
204 }
205 if found {
206 self.write_all(&jobs)?;
207 }
208 Ok(found)
209 }
210 async fn record_run(
211 &self,
212 id: &str,
213 last_run_ms: i64,
214 next_run_ms: Option<i64>,
215 ) -> Result<(), JobError> {
216 let mut jobs = self.read_all()?;
217 for j in &mut jobs {
218 if j.id == id {
219 j.last_run_ms = Some(last_run_ms);
220 j.next_run_ms = next_run_ms;
221 }
222 }
223 self.write_all(&jobs)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn tmp() -> PathBuf {
232 let n = std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .unwrap()
235 .as_nanos();
236 std::env::temp_dir().join(format!("harness-jobstore-{}-{n}.json", std::process::id()))
237 }
238
239 #[tokio::test]
240 async fn crud_roundtrip() {
241 let p = tmp();
242 let store = FileJobStore::open(&p).unwrap();
243 let job = Job::new("digest", "daily 08:00", "summarize", "stdout", 1000)
244 .with_next_run(Some(2000));
245 let id = job.id.clone();
246 store.add(&job).await.unwrap();
247
248 assert_eq!(store.list().await.unwrap().len(), 1);
249 assert_eq!(store.get(&id).await.unwrap().unwrap().name, "digest");
250
251 assert!(store.set_enabled(&id, false).await.unwrap());
252 assert!(!store.get(&id).await.unwrap().unwrap().enabled);
253
254 store.record_run(&id, 5000, Some(9000)).await.unwrap();
255 let j = store.get(&id).await.unwrap().unwrap();
256 assert_eq!(j.last_run_ms, Some(5000));
257 assert_eq!(j.next_run_ms, Some(9000));
258
259 assert!(store.remove(&id).await.unwrap());
260 assert!(store.list().await.unwrap().is_empty());
261 assert!(!store.remove(&id).await.unwrap());
262
263 let _ = std::fs::remove_file(&p);
264 }
265}