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}