Skip to main content

harness_loop/scheduler/
store.rs

1//! Job persistence. `Job` is the unit of scheduled work; `JobStore` is the
2//! pluggable backend (default `FileJobStore`, a single JSON-array file written
3//! atomically — same posture as `harness_context::FileMemory`).
4
5use 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/// One scheduled agent job.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct Job {
18    pub id: String,
19    pub name: String,
20    /// "daily 08:00" | "weekly mon 09:30" | "every 15m" (parsed by crate::daemon::Schedule).
21    pub schedule: String,
22    /// The agent task to run at fire time.
23    pub prompt: String,
24    /// Channel key (matched against a registered Channel), e.g. "stdout" | "email".
25    pub channel: String,
26    /// Channel-specific recipient (email address, chat id, …).
27    #[serde(default)]
28    pub target: Option<String>,
29    /// Who this job belongs to, in a host that has more than one user. `None`
30    /// means unowned — the single-user default. A multi-tenant host stamps its
31    /// user id here and scopes every read through `list_for_owner`, so one
32    /// user's agent can neither see nor cancel another's jobs.
33    #[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    /// Whether this job is visible to `owner`. An unowned job belongs to the
79    /// single-user world and is visible to an unowned caller only.
80    pub fn owned_by(&self, owner: Option<&str>) -> bool {
81        self.owner.as_deref() == owner
82    }
83}
84
85/// Short-ish unique id: created-ms + a process-global counter.
86fn 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    /// Every job in the store, across owners. The scheduler wants this; a
105    /// user-facing caller almost always wants `list_for_owner` instead.
106    async fn list(&self) -> Result<Vec<Job>, JobError>;
107
108    /// Only the jobs belonging to `owner`. The default filters `list()`, which
109    /// is correct for any backend; a database-backed store should override it
110    /// with a WHERE clause.
111    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
130/// JSON-array file backend. All jobs in one file; mutations read-all → modify →
131/// atomic rewrite. `Mutex<()>` serializes writers.
132pub 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}