Skip to main content

forge_primitives/
cron.rs

1//! Durable forge-cron wire types.
2//!
3//! A forge cron is a scheduled prompt that fires into a project's
4//! session and survives forge restarts. It lives in the
5//! `mcp__forge__cron__*` tool family alongside the agents family. The
6//! list persists to the machine-local redb store for restart
7//! durability; the single-instance boot guard makes one forge process
8//! per config dir the sole writer, so the in-process mutex (not the
9//! store) is the serialization point.
10//!
11//! These are pure data shapes - the parsing, due-check, and catch-up
12//! math live in `forge-workspace::mcp::cron`, and the persistence in
13//! `forge-workspace::store::cron`.
14
15use std::time::SystemTime;
16
17use serde::{Deserialize, Serialize};
18
19/// Identifier for a durable forge cron entry. Minted at create time
20/// (a UUIDv4 in the `cron__create` handler) and used by `cron__delete`
21/// to address an entry.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
23#[serde(transparent)]
24pub struct CronId(pub String);
25
26impl CronId {
27    pub fn new(s: impl Into<String>) -> Self {
28        Self(s.into())
29    }
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35impl std::fmt::Display for CronId {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.write_str(&self.0)
38    }
39}
40
41impl From<String> for CronId {
42    fn from(s: String) -> Self {
43        Self(s)
44    }
45}
46
47impl From<&str> for CronId {
48    fn from(s: &str) -> Self {
49        Self(s.to_owned())
50    }
51}
52
53/// What schedule drives a cron.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub enum CronKind {
56    /// A 5-field cron expression evaluated at minute granularity.
57    /// After each fire `next_fire` advances to the next matching slot.
58    Recurring(String),
59    /// A one-shot fire at the given instant. After firing the entry
60    /// self-deletes. `next_fire` mirrors this instant.
61    Once(SystemTime),
62}
63
64/// One persisted forge cron. The scheduler's due-check reads
65/// `next_fire` uniformly for both kinds.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CronEntry {
68    pub id: CronId,
69    /// forge.toml project NAME this cron belongs to and fires into.
70    /// Delivery resolves it against the running project leads (the
71    /// live path) or `Command::SpawnProject { project_name }` (the
72    /// spawn-to-fire path), so it is the human-facing name, not the
73    /// directory-derived `ProjectKey` hash.
74    pub project_name: String,
75    pub kind: CronKind,
76    pub prompt: String,
77    /// Short human "what/why" for the job, supplied at `cron__create`.
78    /// `None` for legacy entries stored before this field existed; the
79    /// renderer falls back to the prompt's first line.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82    pub created_at: SystemTime,
83    /// Last instant this cron actually fired. `None` until the first
84    /// fire.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub last_fire: Option<SystemTime>,
87    /// Absolute instant of the next scheduled fire. For `Once` this
88    /// equals the kind's instant.
89    pub next_fire: SystemTime,
90    /// The label half of the slot that owns this cron: `None` for a
91    /// lead's (also every seeded / legacy entry), `Some(label)` for a
92    /// worker's. The project name carries the other half, so this routes
93    /// the fire to its slot and scopes `cron__list` / `cron__delete` to
94    /// it. The name is the store's, kept because the field is serialized
95    /// and a rename would silently re-key every persisted worker cron to
96    /// its lead.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub team_role: Option<String>,
99    /// Whether a fire may wake a sleeping owner. False is for a job that
100    /// must not wake a sleeping owner: a tick that finds the owner
101    /// asleep is skipped and the entry stays due, so the next session start
102    /// (or a tick while it is awake) delivers it. `default` reads a legacy
103    /// entry as waking, which is what it did before the field existed.
104    #[serde(default = "wake_default")]
105    pub wake: bool,
106}
107
108/// A cron written before `wake` existed woke its owner, its only behaviour.
109fn wake_default() -> bool {
110    true
111}
112
113/// The schedule a fired prompt came from, keyed by the prompt's own id.
114///
115/// **The delivery frame carries this pairing once**, and a row drawn after a
116/// reload has nothing else to title itself from: the prose is the prompt and
117/// never the schedule. So the fire is remembered under the prompt's id, and a
118/// conversation read attaches the pair to the rows it serves.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct CronName {
121    /// The entry that fired.
122    pub cron_id: String,
123    /// The entry's description, `None` for one registered without.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub description: Option<String>,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::time::Duration;
132
133    fn epoch(secs: u64) -> SystemTime {
134        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
135    }
136
137    #[test]
138    fn cron_id_round_trips_through_string() {
139        let id = CronId::from("abc-123");
140        assert_eq!(id.as_str(), "abc-123");
141        assert_eq!(id.to_string(), "abc-123");
142        assert_eq!(CronId::new("x").0, "x");
143    }
144
145    #[test]
146    fn recurring_entry_serde_round_trips() {
147        let entry = CronEntry {
148            id: CronId::from("id-1"),
149            project_name: "forge".to_owned(),
150            kind: CronKind::Recurring("0 9 * * *".to_owned()),
151            prompt: "stand-up summary".to_owned(),
152            description: Some("Morning summary".to_owned()),
153            created_at: epoch(1_700_000_000),
154            last_fire: None,
155            next_fire: epoch(1_700_032_400),
156            team_role: None,
157            wake: true,
158        };
159        let json = serde_json::to_string(&entry).expect("serialize");
160        assert!(json.contains("Morning summary"), "a set description serializes");
161        let back: CronEntry = serde_json::from_str(&json).expect("deserialize");
162        assert_eq!(entry, back);
163    }
164
165    #[test]
166    fn once_entry_serde_round_trips() {
167        let entry = CronEntry {
168            id: CronId::from("id-2"),
169            project_name: "airmail".to_owned(),
170            kind: CronKind::Once(epoch(1_700_100_000)),
171            prompt: "deploy".to_owned(),
172            description: None,
173            created_at: epoch(1_700_000_000),
174            last_fire: Some(epoch(1_700_050_000)),
175            next_fire: epoch(1_700_100_000),
176            team_role: None,
177            wake: true,
178        };
179        let json = serde_json::to_string(&entry).expect("serialize");
180        assert!(!json.contains("description"), "an absent description is skipped");
181        let back: CronEntry = serde_json::from_str(&json).expect("deserialize");
182        assert_eq!(entry, back);
183    }
184
185    #[test]
186    fn legacy_entry_without_description_deserializes_to_none() {
187        // A cron persisted before `description` existed must still load.
188        let json = r#"{
189            "id": "legacy-1",
190            "project_name": "forge",
191            "kind": {"Recurring": "0 9 * * *"},
192            "prompt": "stand-up",
193            "created_at": {"secs_since_epoch": 1700000000, "nanos_since_epoch": 0},
194            "next_fire": {"secs_since_epoch": 1700032400, "nanos_since_epoch": 0}
195        }"#;
196        let back: CronEntry = serde_json::from_str(json).expect("legacy deserialize");
197        assert_eq!(back.description, None);
198        assert!(back.wake, "an entry written before `wake` woke its owner");
199    }
200}