Skip to main content

agentmux_srv/backend/storage/
cron.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Persistent cron job storage — CRUD methods on `db_cron_jobs` in the
5//! global shared store. See `docs/specs/SPEC_CRON_LOOP_ROBUSTNESS_2026_06_25.md`.
6
7use rusqlite::params;
8use serde::{Deserialize, Serialize};
9
10use super::error::StoreError;
11use super::store::Store;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CronJob {
15    pub id: String,
16    pub name: String,
17    /// Standard 5-field cron expression evaluated in UTC.
18    pub expression: String,
19    pub prompt: String,
20    /// Target agent id for injection.
21    pub target: String,
22    /// Identity M1b/M3 (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
23    /// §9.1): the UID (`db_agents.id`) of `target`, resolved once at the
24    /// authoring boundary and carried in — spec §5.4, a job firing at 03:00
25    /// must never resolve a name. Since M3 a job with a `target_uid` fires
26    /// by it; empty = none was carried, and the job fires by `target`.
27    #[serde(default)]
28    pub target_uid: String,
29    pub created_by: String,
30    /// Identity M4c-1 (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
31    /// §6.5.9): the creator's UID, taken from the create request's `Caller`
32    /// — its `X-Agent-Token` — never from a body field. Empty = the create
33    /// was Unattributed; never guessed. Dual-written beside `created_by`;
34    /// not read yet (M4c-3 carries it as the fire's attribution).
35    #[serde(default)]
36    pub created_by_uid: String,
37    pub enabled: bool,
38    pub last_fired: Option<i64>,
39    pub fire_count: i64,
40    /// `None` = unlimited.
41    pub max_fires: Option<i64>,
42    pub created_at: i64,
43    /// Hard expiry bound in seconds since `created_at`. `None` = no expiry
44    /// (the default — existing jobs created before this field are
45    /// unaffected). See
46    /// docs/specs/SPEC_AGENT_POLLING_AND_WAKEUP_HARDENING_2026_08_04.md Phase 0.
47    pub max_age_secs: Option<i64>,
48}
49
50impl Store {
51    pub fn cron_create(&self, job: &CronJob) -> Result<(), StoreError> {
52        let conn = self.conn.lock().unwrap();
53        conn.execute(
54            "INSERT INTO db_cron_jobs
55                (id, name, expression, prompt, target, created_by, enabled,
56                 last_fired, fire_count, max_fires, created_at, max_age_secs,
57                 target_uid, created_by_uid)
58             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
59            params![
60                job.id,
61                job.name,
62                job.expression,
63                job.prompt,
64                job.target,
65                job.created_by,
66                job.enabled as i64,
67                job.last_fired,
68                job.fire_count,
69                job.max_fires,
70                job.created_at,
71                job.max_age_secs,
72                job.target_uid,
73                job.created_by_uid,
74            ],
75        )?;
76        Ok(())
77    }
78
79    pub fn cron_list(&self) -> Result<Vec<CronJob>, StoreError> {
80        let conn = self.conn.lock().unwrap();
81        let mut stmt = conn.prepare(
82            "SELECT id, name, expression, prompt, target, created_by, enabled,
83                    last_fired, fire_count, max_fires, created_at, max_age_secs,
84                    target_uid, created_by_uid
85             FROM db_cron_jobs ORDER BY created_at ASC",
86        )?;
87        let iter = stmt.query_map([], map_row)?;
88        iter.collect::<Result<Vec<_>, _>>().map_err(Into::into)
89    }
90
91    pub fn cron_list_enabled(&self) -> Result<Vec<CronJob>, StoreError> {
92        let conn = self.conn.lock().unwrap();
93        let mut stmt = conn.prepare(
94            "SELECT id, name, expression, prompt, target, created_by, enabled,
95                    last_fired, fire_count, max_fires, created_at, max_age_secs,
96                    target_uid, created_by_uid
97             FROM db_cron_jobs WHERE enabled = 1 ORDER BY created_at ASC",
98        )?;
99        let iter = stmt.query_map([], map_row)?;
100        iter.collect::<Result<Vec<_>, _>>().map_err(Into::into)
101    }
102
103    pub fn cron_get(&self, id: &str) -> Result<Option<CronJob>, StoreError> {
104        let conn = self.conn.lock().unwrap();
105        let mut stmt = conn.prepare(
106            "SELECT id, name, expression, prompt, target, created_by, enabled,
107                    last_fired, fire_count, max_fires, created_at, max_age_secs,
108                    target_uid, created_by_uid
109             FROM db_cron_jobs WHERE id = ?1",
110        )?;
111        let mut rows = stmt.query_map(params![id], map_row)?;
112        match rows.next() {
113            Some(r) => Ok(Some(r?)),
114            None => Ok(None),
115        }
116    }
117
118    pub fn cron_set_enabled(&self, id: &str, enabled: bool) -> Result<bool, StoreError> {
119        let conn = self.conn.lock().unwrap();
120        let rows = conn.execute(
121            "UPDATE db_cron_jobs SET enabled = ?1 WHERE id = ?2",
122            params![enabled as i64, id],
123        )?;
124        Ok(rows > 0)
125    }
126
127    pub fn cron_record_fire(&self, id: &str, now: i64) -> Result<(), StoreError> {
128        let conn = self.conn.lock().unwrap();
129        conn.execute(
130            "UPDATE db_cron_jobs
131             SET last_fired = ?1,
132                 fire_count = fire_count + 1,
133                 enabled    = CASE
134                     WHEN max_fires IS NOT NULL AND fire_count + 1 >= max_fires THEN 0
135                     ELSE enabled
136                 END
137             WHERE id = ?2",
138            params![now, id],
139        )?;
140        Ok(())
141    }
142
143    pub fn cron_delete(&self, id: &str) -> Result<bool, StoreError> {
144        let conn = self.conn.lock().unwrap();
145        let rows = conn.execute("DELETE FROM db_cron_jobs WHERE id = ?1", params![id])?;
146        Ok(rows > 0)
147    }
148}
149
150fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
151    Ok(CronJob {
152        id:            row.get(0)?,
153        name:          row.get(1)?,
154        expression:    row.get(2)?,
155        prompt:        row.get(3)?,
156        target:        row.get(4)?,
157        created_by:    row.get(5)?,
158        enabled:       row.get::<_, i64>(6)? != 0,
159        last_fired:    row.get(7)?,
160        fire_count:    row.get(8)?,
161        max_fires:     row.get(9)?,
162        created_at:    row.get(10)?,
163        max_age_secs:  row.get(11)?,
164        target_uid:    row.get(12)?,
165        created_by_uid: row.get(13)?,
166    })
167}