Skip to main content

forge_workspace/mcp/cron/
facade.rs

1//! `CronFacade` - the seam between the cron MCP tools and workspace
2//! state. The production impl ([`ProdCronFacade`]) resolves the caller's
3//! project and drives the direct `Workspace` cron methods; the mock
4//! records calls for tool tests.
5//!
6//! Cron-list mutations are direct `Workspace` methods (lock the `crons`
7//! mutex + persist), NOT Command-bus dispatches - a cron entry is
8//! workspace state, like the account-usage cache, not a session action.
9
10use std::sync::{Arc, Weak};
11use std::time::SystemTime;
12
13use forge_primitives::cron::{CronEntry, CronId, CronKind};
14
15use crate::SessionSlot;
16use crate::mcp::caller_context::caller_context;
17use crate::mcp::cron::schedule;
18use crate::workspace::Workspace;
19
20/// Why `cron__create` failed.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) enum CronCreateError {
23    /// The caller couldn't be mapped to a project (transient race, or the
24    /// session ended). Shouldn't happen for a live session.
25    UnknownCallerProject,
26    /// The 5-field cron expression didn't parse. Carries croner's message.
27    InvalidExpression(String),
28    /// The schedule has no upcoming occurrence - a run-once whose time has
29    /// already passed, or a recurring expression that never matches.
30    NoUpcomingOccurrence,
31}
32
33/// Why `cron__list` failed.
34///
35/// The same unresolvable caller is what `delete_cron` answers with its own
36/// [`CronDeleteError::UnknownCallerProject`]; the variants stay per-verb
37/// because a list cannot have "no such cron" or "not yours".
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub(crate) enum CronListError {
40    /// The caller couldn't be mapped to a project (transient race, or the
41    /// session ended). Shouldn't happen for a live session.
42    UnknownCallerProject,
43    /// This run's boot could not load the durable crons, so an empty list
44    /// would claim the caller has none.
45    Unavailable,
46}
47
48/// Why `cron__delete` failed. The last two are the two cases a refusal
49/// keeps apart: an id the project holds under another session reads
50/// differently from one the project never had.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub(crate) enum CronDeleteError {
53    /// The caller couldn't be mapped to a project (transient race, or the
54    /// session ended). Shouldn't happen for a live session.
55    UnknownCallerProject,
56    /// No cron with that id in the caller's project.
57    NoSuchCron,
58    /// The id is in the caller's project but belongs to another session.
59    NotOwnedByCaller,
60}
61
62/// The cron tools' view of the workspace. Sync - cron-list mutations are
63/// direct state writes with no async handler to await.
64pub(crate) trait CronFacade: Send + Sync {
65    /// Validate + register a cron owned by the caller: compute
66    /// `next_fire`, persist, and return the new entry. `description` is
67    /// the caller-supplied human summary (already trimmed to `None` when
68    /// blank).
69    fn create_cron(
70        &self,
71        caller: &SessionSlot,
72        kind: CronKind,
73        prompt: String,
74        description: Option<String>,
75        wake: bool,
76    ) -> Result<CronEntry, CronCreateError>;
77
78    /// The crons the caller registered, within its project. An empty answer
79    /// means the caller has none.
80    fn list_crons(&self, caller: &SessionSlot) -> Result<Vec<CronEntry>, CronListError>;
81
82    /// Delete the caller's own cron by id, within its project, and return
83    /// the entry as it stood. A refusal says which case it is:
84    /// [`CronDeleteError::NoSuchCron`] when the project has no such id,
85    /// [`CronDeleteError::NotOwnedByCaller`] when it has one under
86    /// another session.
87    fn delete_cron(&self, caller: &SessionSlot, id: &CronId) -> Result<CronEntry, CronDeleteError>;
88}
89
90/// Production facade over `Weak<Workspace>` (weak to avoid a cycle with
91/// the MCP server the workspace owns).
92pub(crate) struct ProdCronFacade {
93    workspace: Weak<Workspace>,
94}
95
96impl ProdCronFacade {
97    pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn CronFacade> {
98        Arc::new(Self { workspace: Arc::downgrade(workspace) })
99    }
100}
101
102impl CronFacade for ProdCronFacade {
103    fn create_cron(
104        &self,
105        caller: &SessionSlot,
106        kind: CronKind,
107        prompt: String,
108        description: Option<String>,
109        wake: bool,
110    ) -> Result<CronEntry, CronCreateError> {
111        let ws = self.workspace.upgrade().ok_or(CronCreateError::UnknownCallerProject)?;
112        let cx = caller_context(&ws, caller).ok_or(CronCreateError::UnknownCallerProject)?;
113
114        if let CronKind::Recurring(expr) = &kind {
115            schedule::validate_cron_expr(expr).map_err(CronCreateError::InvalidExpression)?;
116        }
117        let now = SystemTime::now();
118        let next_fire =
119            schedule::next_fire_after(&kind, now).ok_or(CronCreateError::NoUpcomingOccurrence)?;
120
121        let entry = CronEntry {
122            id: CronId::from(uuid::Uuid::new_v4().to_string()),
123            project_name: cx.project_name,
124            kind,
125            prompt,
126            description,
127            created_at: now,
128            last_fire: None,
129            next_fire,
130            team_role: cx.worker_label,
131            wake,
132        };
133        ws.push_cron(entry.clone());
134        Ok(entry)
135    }
136
137    fn list_crons(&self, caller: &SessionSlot) -> Result<Vec<CronEntry>, CronListError> {
138        let ws = self.workspace.upgrade().ok_or(CronListError::UnknownCallerProject)?;
139        let cx = caller_context(&ws, caller).ok_or(CronListError::UnknownCallerProject)?;
140        if ws.durable_crons_unavailable() {
141            return Err(CronListError::Unavailable);
142        }
143        // Symmetric: every caller sees only its own crons - a lead
144        // (`worker_label == None`) the lead crons, a worker its own.
145        Ok(ws
146            .crons_for_project(&cx.project_name)
147            .into_iter()
148            .filter(|c| c.team_role == cx.worker_label)
149            .collect())
150    }
151
152    fn delete_cron(&self, caller: &SessionSlot, id: &CronId) -> Result<CronEntry, CronDeleteError> {
153        let ws = self.workspace.upgrade().ok_or(CronDeleteError::UnknownCallerProject)?;
154        let cx = caller_context(&ws, caller).ok_or(CronDeleteError::UnknownCallerProject)?;
155        if let Some(entry) =
156            ws.remove_cron_owned_by(&cx.project_name, id, cx.worker_label.as_deref())
157        {
158            return Ok(entry);
159        }
160        // The owner-scoped removal matched nothing, which is either an id
161        // the project never had or one another session owns; the caller is
162        // told which.
163        if ws.crons_for_project(&cx.project_name).iter().any(|c| c.id == *id) {
164            Err(CronDeleteError::NotOwnedByCaller)
165        } else {
166            Err(CronDeleteError::NoSuchCron)
167        }
168    }
169}
170
171/// One recorded `create_cron` call: caller, kind, prompt, description, wake.
172#[cfg(test)]
173type CreateCall = (SessionSlot, CronKind, String, Option<String>, bool);
174
175/// Records calls + returns preloaded results so the tool tests can assert
176/// the tool correctly parses args, resolves the caller, and surfaces
177/// facade results/errors - without a real workspace.
178#[cfg(test)]
179#[derive(Default)]
180pub(crate) struct MockCronFacade {
181    pub crons: parking_lot::Mutex<Vec<CronEntry>>,
182    pub list_result: parking_lot::Mutex<Option<Result<Vec<CronEntry>, CronListError>>>,
183    pub create_calls: parking_lot::Mutex<Vec<CreateCall>>,
184    pub create_result: parking_lot::Mutex<Option<Result<CronEntry, CronCreateError>>>,
185    pub delete_calls: parking_lot::Mutex<Vec<(SessionSlot, CronId)>>,
186    pub delete_result: parking_lot::Mutex<Option<Result<CronEntry, CronDeleteError>>>,
187}
188
189#[cfg(test)]
190impl MockCronFacade {
191    pub(crate) fn new() -> Self {
192        Self::default()
193    }
194    pub(crate) fn into_arc(self) -> Arc<dyn CronFacade> {
195        Arc::new(self)
196    }
197}
198
199#[cfg(test)]
200impl CronFacade for MockCronFacade {
201    fn create_cron(
202        &self,
203        caller: &SessionSlot,
204        kind: CronKind,
205        prompt: String,
206        description: Option<String>,
207        wake: bool,
208    ) -> Result<CronEntry, CronCreateError> {
209        self.create_calls.lock().push((
210            caller.clone(),
211            kind.clone(),
212            prompt.clone(),
213            description.clone(),
214            wake,
215        ));
216        if let Some(preloaded) = self.create_result.lock().clone() {
217            return preloaded;
218        }
219        Ok(CronEntry {
220            id: CronId::from("mock-cron-id"),
221            project_name: "mock".to_owned(),
222            kind,
223            prompt,
224            description,
225            created_at: SystemTime::UNIX_EPOCH,
226            last_fire: None,
227            next_fire: SystemTime::UNIX_EPOCH,
228            team_role: None,
229            wake,
230        })
231    }
232
233    fn list_crons(&self, _caller: &SessionSlot) -> Result<Vec<CronEntry>, CronListError> {
234        self.list_result.lock().clone().unwrap_or_else(|| Ok(self.crons.lock().clone()))
235    }
236
237    fn delete_cron(&self, caller: &SessionSlot, id: &CronId) -> Result<CronEntry, CronDeleteError> {
238        self.delete_calls.lock().push((caller.clone(), id.clone()));
239        self.delete_result.lock().clone().unwrap_or_else(|| {
240            Ok(CronEntry {
241                id: id.clone(),
242                project_name: "mock".to_owned(),
243                kind: CronKind::Recurring("0 9 * * *".to_owned()),
244                prompt: "p".to_owned(),
245                description: None,
246                created_at: SystemTime::UNIX_EPOCH,
247                last_fire: None,
248                next_fire: SystemTime::UNIX_EPOCH,
249                wake: true,
250                team_role: None,
251            })
252        })
253    }
254}
255
256/// Owner-scoping tests over a real `Workspace` (the mock above can't
257/// exercise `caller_context`). A project with a lead session and a live
258/// worker drives create/list/delete through `ProdCronFacade`.
259#[cfg(test)]
260mod prod_facade_tests {
261    use super::*;
262    use crate::WorkerEntry;
263    use forge_primitives::WorkerLiveness;
264
265    fn worker_entry(project: &str, label: &str) -> WorkerEntry {
266        WorkerEntry {
267            label: label.to_owned(),
268            charter: "review".to_owned(),
269            slot: SessionSlot::worker("TestOrg", project, label),
270            session_id: None,
271            status: WorkerLiveness::Running,
272            spawned_at: SystemTime::UNIX_EPOCH,
273            spawned_by: SessionSlot::lead("TestOrg", project),
274            needs_tag: false,
275            is_git_repo_at_spawn: false,
276            cwd: None,
277            diagnostic: None,
278            kick: None,
279        }
280    }
281
282    fn fixture() -> (Arc<Workspace>, Arc<dyn CronFacade>, SessionSlot, SessionSlot) {
283        let (ws, _rx) = Workspace::testing_stub();
284        ws.seed_test_project("myproj", "/tmp/b2-myproj");
285        let key =
286            ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("seeded view").key;
287        ws.record_connected_session("/tmp/b2-myproj", "lead-uuid", None);
288        ws.insert_live_worker(&key, worker_entry("myproj", "reviewer"));
289        let facade = ProdCronFacade::from_arc(&ws);
290        (
291            ws,
292            facade,
293            SessionSlot::lead("TestOrg", "myproj"),
294            SessionSlot::worker("TestOrg", "myproj", "reviewer"),
295        )
296    }
297
298    fn daily(prompt: &str) -> (CronKind, String) {
299        (CronKind::Recurring("0 9 * * *".to_owned()), prompt.to_owned())
300    }
301
302    #[test]
303    fn create_stamps_owner_and_list_delete_are_owner_scoped() {
304        let (_ws, facade, lead, worker) = fixture();
305
306        let (lk, lp) = daily("lead-standup");
307        let lead_cron = facade
308            .create_cron(&lead, lk, lp, Some("Lead stand-up".to_owned()), true)
309            .expect("lead create");
310        assert_eq!(lead_cron.team_role, None, "a lead cron is owner-less");
311        assert_eq!(
312            lead_cron.description.as_deref(),
313            Some("Lead stand-up"),
314            "the supplied description persists on the entry",
315        );
316        let (wk, wp) = daily("worker-standup");
317        let worker_cron = facade.create_cron(&worker, wk, wp, None, true).expect("worker create");
318        assert_eq!(worker_cron.description, None, "no description stays None");
319        assert_eq!(
320            worker_cron.team_role.as_deref(),
321            Some("reviewer"),
322            "a worker cron is stamped with its label",
323        );
324
325        let worker_list = facade.list_crons(&worker).expect("list");
326        assert_eq!(worker_list.len(), 1, "the worker sees only its own cron");
327        assert_eq!(worker_list[0].id, worker_cron.id);
328        let lead_list = facade.list_crons(&lead).expect("list");
329        assert_eq!(lead_list.len(), 1, "the lead sees only lead crons");
330        assert_eq!(lead_list[0].id, lead_cron.id);
331
332        assert_eq!(
333            facade.delete_cron(&worker, &lead_cron.id),
334            Err(CronDeleteError::NotOwnedByCaller),
335            "a worker cannot delete a lead's cron, and the refusal says which case it is",
336        );
337        assert_eq!(
338            facade.delete_cron(&lead, &worker_cron.id),
339            Err(CronDeleteError::NotOwnedByCaller),
340            "a lead cannot delete a worker's cron",
341        );
342        facade.delete_cron(&worker, &worker_cron.id).expect("a worker deletes its own cron");
343    }
344
345    /// The delete hands back the entry it removed, so the tool can echo
346    /// the schedule and prompt that went rather than a bare id.
347    ///
348    /// **A neighbour is registered FIRST**, so the removed set has a
349    /// record before the named one: an implementation answering with "the
350    /// first entry" instead of "the entry the id named" echoes the
351    /// neighbour here and leaves the named entry in the store, and both
352    /// assertions catch it.
353    #[test]
354    fn delete_returns_the_entry_it_removed() {
355        let (_ws, facade, lead, _worker) = fixture();
356        let (kind, prompt) = daily("worker digest");
357        let neighbour = facade
358            .create_cron(&lead, kind, prompt, Some("Worker digest".to_owned()), true)
359            .expect("create the neighbour first");
360        let (kind, prompt) = daily("morning summary");
361        let cron = facade
362            .create_cron(&lead, kind, prompt, Some("Morning summary".to_owned()), true)
363            .expect("create");
364
365        let removed = facade.delete_cron(&lead, &cron.id).expect("the lead deletes its own cron");
366
367        assert_eq!(removed.id, cron.id, "the echoed entry is the cron that went");
368        assert_eq!(removed.prompt, "morning summary");
369        assert_eq!(removed.description.as_deref(), Some("Morning summary"));
370        assert!(
371            matches!(&removed.kind, CronKind::Recurring(expr) if expr == "0 9 * * *"),
372            "carrying the schedule it was registered under: {:?}",
373            removed.kind,
374        );
375        let left = facade.list_crons(&lead).expect("list");
376        assert_eq!(left.len(), 1, "only the neighbour survives");
377        assert_eq!(left[0].id, neighbour.id, "and it is the neighbour that survives");
378    }
379
380    const ONE_PROJECT_CONFIG: &str = r#"
381[[orgs]]
382name = "TestOrg"
383accounts = ["acct-a"]
384
385[[orgs.projects]]
386name = "forge"
387path = "/tmp/cron-store-fixture"
388
389[[accounts]]
390display_name = "acct-a"
391token = "t"
392models = ["claude-sonnet-5"]
393provider = "anthropic"
394"#;
395
396    fn booted_workspace(dir: &tempfile::TempDir) -> Arc<Workspace> {
397        let forge = dir.path().join("forge");
398        std::fs::create_dir_all(&forge).expect("forge/");
399        std::fs::write(forge.join("forge.toml"), ONE_PROJECT_CONFIG).expect("write forge.toml");
400        Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace boots"))
401    }
402
403    /// A boot whose store could not open leaves the in-memory set empty while
404    /// the rows may still be on disk. An empty answer then reads as "you have
405    /// no crons", and a cleanup that trusts it leaves the old ones firing.
406    #[test]
407    fn list_refuses_when_the_boot_could_not_load_the_store() {
408        // A file where the app-support base must be a directory, so the
409        // stores dir cannot be created and the boot opens no DB.
410        let dir = tempfile::tempdir().expect("tempdir");
411        let forge = dir.path().join("forge");
412        std::fs::create_dir_all(&forge).expect("forge/");
413        std::fs::write(forge.join("forge.toml"), ONE_PROJECT_CONFIG).expect("write forge.toml");
414        std::fs::write(dir.path().join("app-support"), b"not a directory")
415            .expect("block the app-support base");
416
417        let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace boots"));
418        let facade = ProdCronFacade::from_arc(&ws);
419
420        assert_eq!(
421            facade.list_crons(&SessionSlot::lead("TestOrg", "forge")),
422            Err(CronListError::Unavailable),
423            "a boot that could not load its durable crons must not answer as an empty registry",
424        );
425    }
426
427    /// The boot's other failing arm: the store opens, but its cron read
428    /// fails. A regression dropping the read arm's `true` would restore the
429    /// empty-list lie while the store-open test above stays green.
430    #[test]
431    fn list_refuses_when_the_open_store_could_not_read_its_crons() {
432        use redb::TableDefinition;
433
434        // The right table name under the wrong key/value types: the store
435        // opens, but opening it as the cron table fails.
436        const WRONG_CRONS: TableDefinition<u64, u64> = TableDefinition::new("crons");
437
438        let dir = tempfile::tempdir().expect("tempdir");
439        let forge = dir.path().join("forge");
440        std::fs::create_dir_all(&forge).expect("forge/");
441        std::fs::write(forge.join("forge.toml"), ONE_PROJECT_CONFIG).expect("write forge.toml");
442        let app_support = dir.path().join("app-support");
443        std::fs::create_dir_all(app_support.join("stores")).expect("stores/");
444        let canonical = dir.path().canonicalize().expect("canonical config dir");
445        let db_path = crate::store::path_for(&app_support, &canonical);
446        {
447            let db = crate::store::Db::open(&db_path).expect("open db");
448            let txn = db.database().begin_write().expect("write txn");
449            {
450                txn.open_table(WRONG_CRONS).expect("create the mis-typed crons table");
451            }
452            txn.commit().expect("commit");
453        }
454
455        let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace boots"));
456        let facade = ProdCronFacade::from_arc(&ws);
457
458        assert!(ws.db.lock().is_some(), "the store opened; only its cron read fails");
459        assert!(
460            ws.durable_crons_unavailable(),
461            "a failed cron read marks the registry unavailable",
462        );
463        assert_eq!(
464            facade.list_crons(&SessionSlot::lead("TestOrg", "forge")),
465            Err(CronListError::Unavailable),
466            "a boot whose store opened but whose cron read failed must not answer as empty",
467        );
468    }
469
470    /// The negative control for the refusals above: a healthy boot opens the
471    /// store, so an empty registry is a real answer rather than a failed load.
472    #[test]
473    fn a_healthy_boot_reads_an_empty_registry() {
474        let dir = tempfile::tempdir().expect("tempdir");
475        let ws = booted_workspace(&dir);
476        let facade = ProdCronFacade::from_arc(&ws);
477
478        assert!(
479            !ws.durable_crons_unavailable(),
480            "a boot whose store opened is not an unavailable run",
481        );
482        assert_eq!(
483            facade.list_crons(&SessionSlot::lead("TestOrg", "forge")),
484            Ok(Vec::new()),
485            "a healthy boot with no crons answers empty rather than refusing",
486        );
487    }
488
489    /// The two halves the lead's answer actually composes: the production
490    /// facade over a boot with no durable store, driven through the tool.
491    #[tokio::test]
492    async fn the_tool_refuses_over_the_prod_facade_when_the_store_could_not_load() {
493        use forge_sdk::mcp::tool::{Tool, ToolInput};
494
495        let dir = tempfile::tempdir().expect("tempdir");
496        let forge = dir.path().join("forge");
497        std::fs::create_dir_all(&forge).expect("forge/");
498        std::fs::write(forge.join("forge.toml"), ONE_PROJECT_CONFIG).expect("write forge.toml");
499        std::fs::write(dir.path().join("app-support"), b"not a directory")
500            .expect("block the app-support base");
501
502        let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace boots"));
503        let facade = ProdCronFacade::from_arc(&ws);
504        let tool = crate::mcp::cron::List { facade, slot: SessionSlot::lead("TestOrg", "forge") };
505
506        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
507        let text = crate::mcp::test_support::text_of(&out);
508        assert!(out.is_error, "the composed answer is an error: {text}");
509        assert!(
510            text.contains("couldn't load durable crons"),
511            "the composed answer names the failure: {text}",
512        );
513    }
514
515    /// **An unresolvable caller is a refusal from the real facade**, not an
516    /// empty registry: the production arm is what the tool's error renders.
517    #[test]
518    fn list_refuses_a_caller_that_maps_to_no_project() {
519        let (_ws, facade, _lead, _worker) = fixture();
520
521        assert_eq!(
522            facade.list_crons(&SessionSlot::lead("TestOrg", "no-such-project")),
523            Err(CronListError::UnknownCallerProject),
524            "a caller outside every project must not read as a caller with no crons",
525        );
526    }
527
528    /// The other half of the refusal: an id the project never had is a
529    /// different answer from one that exists under another session.
530    #[test]
531    fn delete_refuses_an_id_the_project_never_had() {
532        let (_ws, facade, lead, _worker) = fixture();
533
534        assert_eq!(
535            facade.delete_cron(&lead, &CronId::from("ghost")),
536            Err(CronDeleteError::NoSuchCron),
537            "an id with no entry in the project is reported as absent",
538        );
539    }
540
541    /// Both clauses of that message are about the caller's own project:
542    /// an id living in another project is not "in this project but not
543    /// registered by you", it is not in this project at all.
544    #[test]
545    fn delete_refuses_an_id_from_another_project_as_absent() {
546        let (ws, facade, lead, _worker) = fixture();
547        ws.seed_test_project("otherproj", "/tmp/b2-otherproj");
548        let other = SessionSlot::lead("TestOrg", "otherproj");
549        let (kind, prompt) = daily("other-standup");
550        let other_cron = facade
551            .create_cron(&other, kind, prompt, None, true)
552            .expect("the other project's create");
553
554        assert_eq!(
555            facade.delete_cron(&lead, &other_cron.id),
556            Err(CronDeleteError::NoSuchCron),
557            "an id that lives in another project is absent here, not a cron of this project's",
558        );
559    }
560}