1use 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#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) enum CronCreateError {
23 UnknownCallerProject,
26 InvalidExpression(String),
28 NoUpcomingOccurrence,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
39pub(crate) enum CronListError {
40 UnknownCallerProject,
43 Unavailable,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
52pub(crate) enum CronDeleteError {
53 UnknownCallerProject,
56 NoSuchCron,
58 NotOwnedByCaller,
60}
61
62pub(crate) trait CronFacade: Send + Sync {
65 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 fn list_crons(&self, caller: &SessionSlot) -> Result<Vec<CronEntry>, CronListError>;
81
82 fn delete_cron(&self, caller: &SessionSlot, id: &CronId) -> Result<CronEntry, CronDeleteError>;
88}
89
90pub(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 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 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#[cfg(test)]
173type CreateCall = (SessionSlot, CronKind, String, Option<String>, bool);
174
175#[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#[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 #[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 #[test]
407 fn list_refuses_when_the_boot_could_not_load_the_store() {
408 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 #[test]
431 fn list_refuses_when_the_open_store_could_not_read_its_crons() {
432 use redb::TableDefinition;
433
434 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 #[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 #[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 #[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 #[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 #[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}