1use std::str::FromStr as _;
10
11use chrono::{DateTime, SecondsFormat, Utc};
12
13use crate::error::{CoreError, Result};
14
15pub use prospero_types::{
16 Automation, AutomationRun, CreateAutomationBody, CreatedAutomationResponse, FiredResponse,
17 RunSource, SetEnabledBody, SpawnTemplate, Trigger,
18};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StoredAutomation {
27 pub automation: Automation,
29 pub webhook_secret: Option<String>,
31}
32
33pub const RUN_HISTORY_LIMIT: usize = 200;
37
38pub fn stamp(at: DateTime<Utc>) -> String {
46 at.to_rfc3339_opts(SecondsFormat::Secs, true)
47}
48
49pub fn normalize_schedule(schedule: &str) -> String {
61 let trimmed = schedule.trim();
62 if trimmed.split_whitespace().count() == 5 {
63 format!("0 {trimmed}")
64 } else {
65 trimmed.to_string()
66 }
67}
68
69pub fn parse_schedule(schedule: &str) -> std::result::Result<cron::Schedule, String> {
75 cron::Schedule::from_str(&normalize_schedule(schedule))
76 .map_err(|e| format!("invalid cron schedule '{schedule}': {e}"))
77}
78
79pub fn due_fire(schedule: &str, since: DateTime<Utc>, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
89 let parsed = parse_schedule(schedule).ok()?;
90 parsed.after(&now).next_back().filter(|tick| *tick > since)
95}
96
97pub const MAX_SUBSTITUTION: usize = 4096;
102
103pub fn interpolate(
112 template: &str,
113 payload: &serde_json::Value,
114) -> std::result::Result<String, String> {
115 let mut out = String::with_capacity(template.len());
116 let mut rest = template;
117 while let Some(start) = rest.find("{{") {
118 out.push_str(&rest[..start]);
119 let after = &rest[start + 2..];
120 let Some(end) = after.find("}}") else {
123 out.push_str(&rest[start..]);
124 return Ok(out);
125 };
126 out.push_str(&lookup(payload, after[..end].trim())?);
127 rest = &after[end + 2..];
128 }
129 out.push_str(rest);
130 Ok(out)
131}
132
133fn lookup(payload: &serde_json::Value, path: &str) -> std::result::Result<String, String> {
138 let mut cursor = payload;
139 for segment in path.split('.') {
140 cursor = cursor
141 .as_object()
142 .and_then(|map| map.get(segment))
143 .ok_or_else(|| format!("task template references '{path}', absent from the payload"))?;
144 }
145 let rendered = match cursor {
146 serde_json::Value::Null => {
147 return Err(format!(
148 "task template references '{path}', null in the payload"
149 ));
150 }
151 serde_json::Value::String(s) => s.clone(),
153 other => other.to_string(),
154 };
155 Ok(truncate(rendered))
156}
157
158fn truncate(mut value: String) -> String {
160 if value.len() <= MAX_SUBSTITUTION {
161 return value;
162 }
163 let mut end = MAX_SUBSTITUTION;
164 while !value.is_char_boundary(end) {
165 end -= 1;
166 }
167 value.truncate(end);
168 value.push_str(" […truncated]");
169 value
170}
171
172pub fn verify_webhook_signature(secret: &str, body: &[u8], header: Option<&str>) -> bool {
179 use hmac::Mac as _;
180
181 let Some(header) = header else { return false };
182 let Some(hex_digest) = header.trim().strip_prefix("sha256=") else {
183 return false;
184 };
185 let Ok(provided) = hex::decode(hex_digest) else {
186 return false;
187 };
188 let Ok(mut mac) = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()) else {
189 return false;
190 };
191 mac.update(body);
192 mac.verify_slice(&provided).is_ok()
194}
195
196pub struct AutomationEngine {
205 config: std::sync::Arc<dyn crate::config_store::ConfigStore>,
206 fleet: std::sync::Arc<dyn crate::fleet_provider::FleetProvider>,
207}
208
209impl AutomationEngine {
210 pub fn new(
212 config: std::sync::Arc<dyn crate::config_store::ConfigStore>,
213 fleet: std::sync::Arc<dyn crate::fleet_provider::FleetProvider>,
214 ) -> Self {
215 Self { config, fleet }
216 }
217
218 pub async fn run(
224 self: std::sync::Arc<Self>,
225 interval: std::time::Duration,
226 shutdown: tokio::sync::watch::Receiver<bool>,
227 ) {
228 let mut shutdown = shutdown;
229 if *shutdown.borrow_and_update() {
230 return;
231 }
232 loop {
233 self.tick_automations(chrono::Utc::now()).await;
234 tokio::select! {
235 _ = tokio::time::sleep(interval) => {}
236 _ = shutdown.changed() => break,
237 }
238 }
239 tracing::info!(target: "prospero_automation", "automation loop stopped");
240 }
241
242 pub async fn list_automations(&self) -> Result<Vec<StoredAutomation>> {
244 self.config.list_automations().await
245 }
246
247 pub async fn create_automation(
253 &self,
254 body: CreateAutomationBody,
255 ) -> Result<CreatedAutomationResponse> {
256 self.create_automation_at(body, chrono::Utc::now()).await
257 }
258
259 pub async fn create_automation_at(
265 &self,
266 body: CreateAutomationBody,
267 created_at: chrono::DateTime<chrono::Utc>,
268 ) -> Result<CreatedAutomationResponse> {
269 if let Trigger::Cron { schedule } = &body.trigger {
273 parse_schedule(schedule).map_err(CoreError::InvalidConfig)?;
274 }
275 if self
276 .config
277 .list_automations()
278 .await?
279 .iter()
280 .any(|s| s.automation.id == body.id)
281 {
282 return Err(CoreError::Conflict(format!(
283 "automation '{}' already exists",
284 body.id
285 )));
286 }
287
288 let webhook_secret = match body.trigger {
292 Trigger::Webhook => Some(mint_webhook_secret()),
293 Trigger::Cron { .. } => None,
294 };
295 let stored = StoredAutomation {
296 automation: Automation {
297 id: body.id,
298 workspace: body.workspace,
299 trigger: body.trigger,
300 template: body.template,
301 enabled: body.enabled,
302 created_at: stamp(created_at),
303 last_fired_at: None,
304 },
305 webhook_secret,
306 };
307 self.config.upsert_automation(&stored).await?;
308 Ok(CreatedAutomationResponse {
309 automation: stored.automation,
310 webhook_secret: stored.webhook_secret,
311 })
312 }
313
314 async fn automation(&self, id: &str) -> Result<StoredAutomation> {
316 self.config
317 .list_automations()
318 .await?
319 .into_iter()
320 .find(|s| s.automation.id == id)
321 .ok_or_else(|| CoreError::AutomationNotFound(id.to_string()))
322 }
323
324 pub async fn delete_automation(&self, id: &str) -> Result<bool> {
326 self.config.delete_automation(id).await
327 }
328
329 pub async fn set_automation_enabled(&self, id: &str, enabled: bool) -> Result<()> {
331 let mut stored = self.automation(id).await?;
332 stored.automation.enabled = enabled;
333 self.config.upsert_automation(&stored).await
336 }
337
338 pub async fn automation_runs(&self, id: &str, limit: usize) -> Result<Vec<AutomationRun>> {
340 self.config.list_runs(id, limit).await
341 }
342
343 pub async fn tick_automations(&self, now: chrono::DateTime<chrono::Utc>) {
350 let automations = match self.config.list_automations().await {
351 Ok(a) => a,
352 Err(e) => {
353 tracing::warn!(target: "prospero_automation", error = %e, "listing automations failed");
354 return;
355 }
356 };
357
358 for stored in automations {
359 let a = &stored.automation;
360 if !a.enabled {
361 continue;
362 }
363 let Trigger::Cron { schedule } = &a.trigger else {
364 continue;
365 };
366 let anchor_raw = a.last_fired_at.as_deref().unwrap_or(a.created_at.as_str());
369 let Ok(anchor) = chrono::DateTime::parse_from_rfc3339(anchor_raw) else {
370 tracing::warn!(
371 target: "prospero_automation",
372 automation = %a.id, anchor = %anchor_raw,
373 "unparseable automation timestamp; skipping"
374 );
375 continue;
376 };
377 let Some(tick) = due_fire(schedule, anchor.with_timezone(&chrono::Utc), now) else {
378 continue;
379 };
380
381 let fire_at = stamp(tick);
386 match self.config.claim_automation_fire(&a.id, &fire_at).await {
387 Ok(true) => {}
388 Ok(false) => continue,
389 Err(e) => {
390 tracing::warn!(
391 target: "prospero_automation",
392 automation = %a.id, error = %e,
393 "claiming an automation tick failed"
394 );
395 continue;
396 }
397 }
398
399 let run = self
400 .run_automation(&stored, fire_at, RunSource::Schedule, None)
401 .await;
402 self.save_run(&run).await;
403 }
404 }
405
406 async fn run_automation(
410 &self,
411 stored: &StoredAutomation,
412 fired_at: String,
413 source: RunSource,
414 payload: Option<&serde_json::Value>,
415 ) -> AutomationRun {
416 let a = &stored.automation;
417 let mut run = AutomationRun {
418 automation_id: a.id.clone(),
419 fired_at,
420 source,
421 agent_id: None,
422 error: None,
423 };
424
425 let task = match payload {
426 Some(payload) => match interpolate(&a.template.task, payload) {
427 Ok(task) => task,
428 Err(e) => {
429 run.error = Some(e);
430 return run;
431 }
432 },
433 None => a.template.task.clone(),
434 };
435
436 let spec = crate::model::TaskSpec {
437 workspace: a.workspace.clone(),
438 request: spawn_request_from(&a.template, task),
439 };
440 match self.fleet.ensure_agent(spec).await {
441 Ok(handle) => run.agent_id = Some(handle.id.to_string()),
442 Err(e) => run.error = Some(e.to_string()),
443 }
444 run
445 }
446
447 async fn save_run(&self, run: &AutomationRun) {
451 if let Err(e) = self.config.record_run(run).await {
452 tracing::warn!(
453 target: "prospero_automation",
454 automation = %run.automation_id, error = %e,
455 "recording an automation run failed"
456 );
457 }
458 }
459
460 pub async fn fire_automation(
468 &self,
469 id: &str,
470 source: RunSource,
471 payload: Option<&serde_json::Value>,
472 ) -> Result<AutomationRun> {
473 let stored = self.automation(id).await?;
474 let fired_at = stamp(chrono::Utc::now());
475 let run = self
476 .run_automation(&stored, fired_at, source, payload)
477 .await;
478 self.save_run(&run).await;
479 Ok(run)
480 }
481
482 pub async fn trigger_webhook(
488 &self,
489 id: &str,
490 body: &[u8],
491 signature: Option<&str>,
492 ) -> Result<AutomationRun> {
493 let stored = self.automation(id).await?;
494
495 let Some(secret) = stored.webhook_secret.as_deref() else {
498 return Err(CoreError::Unauthorized(format!(
499 "automation '{id}' has no webhook trigger"
500 )));
501 };
502 if !verify_webhook_signature(secret, body, signature) {
503 return Err(CoreError::Unauthorized(format!(
504 "bad or missing signature for automation '{id}'"
505 )));
506 }
507 if !stored.automation.enabled {
508 return Err(CoreError::Conflict(format!(
509 "automation '{id}' is disabled"
510 )));
511 }
512
513 let payload: serde_json::Value = serde_json::from_slice(body)?;
514 let fired_at = stamp(chrono::Utc::now());
515 let run = self
516 .run_automation(&stored, fired_at, RunSource::Webhook, Some(&payload))
517 .await;
518 self.save_run(&run).await;
519 Ok(run)
520 }
521}
522
523fn spawn_request_from(template: &SpawnTemplate, task: String) -> crate::fleet::SpawnRequest {
529 crate::fleet::SpawnRequest {
530 prompt: task,
531 label: template.label.clone(),
532 model: template.model.clone(),
533 isolation_worktree: template.isolation_worktree,
534 tool_allowlist: template.tool_allowlist.clone(),
535 interactive: false,
538 frontmatter_path: template
539 .frontmatter_path
540 .as_ref()
541 .map(std::path::PathBuf::from),
542 provider_ref: template.provider_ref.clone(),
543 permission_posture: template.permission_posture,
544 timeout_secs: template.timeout_secs,
545 }
546}
547
548fn mint_webhook_secret() -> String {
550 use rand::RngCore as _;
551 let mut bytes = [0u8; 32];
552 rand::thread_rng().fill_bytes(&mut bytes);
553 hex::encode(bytes)
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 fn t(s: &str) -> DateTime<Utc> {
561 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
562 }
563
564 #[test]
565 fn fires_the_first_tick_after_the_anchor() {
566 let fire = due_fire(
567 "*/5 * * * *",
568 t("2026-09-20T10:00:00Z"),
569 t("2026-09-20T10:05:30Z"),
570 );
571 assert_eq!(fire, Some(t("2026-09-20T10:05:00Z")));
572 }
573
574 #[test]
575 fn does_not_fire_between_ticks() {
576 assert_eq!(
577 due_fire(
578 "*/5 * * * *",
579 t("2026-09-20T10:00:00Z"),
580 t("2026-09-20T10:03:00Z")
581 ),
582 None
583 );
584 }
585
586 #[test]
587 fn missed_ticks_coalesce_into_a_single_catch_up_fire() {
588 let fire = due_fire(
591 "*/5 * * * *",
592 t("2026-09-20T10:00:00Z"),
593 t("2026-09-20T11:02:00Z"),
594 );
595 assert_eq!(fire, Some(t("2026-09-20T11:00:00Z")));
596 }
597
598 #[test]
599 fn a_rejected_schedule_is_reported_not_silently_never_fired() {
600 assert!(parse_schedule("not a cron expression").is_err());
601 assert!(parse_schedule("*/5 * * * *").is_ok());
602 }
603
604 #[test]
605 fn an_invalid_schedule_never_claims_to_be_due() {
606 assert_eq!(
607 due_fire(
608 "nonsense",
609 t("2026-09-20T10:00:00Z"),
610 t("2026-09-20T11:00:00Z")
611 ),
612 None
613 );
614 }
615
616 #[test]
617 fn placeholders_take_their_values_from_the_payload() {
618 let payload = serde_json::json!({
619 "issue": { "number": 42, "title": "the bug" },
620 "actor": "octocat",
621 });
622 let out = interpolate(
623 "Fix {{ issue.title }} (#{{issue.number}}) reported by {{ actor }}",
624 &payload,
625 )
626 .unwrap();
627 assert_eq!(out, "Fix the bug (#42) reported by octocat");
628 }
629
630 #[test]
631 fn a_missing_field_fails_the_run_instead_of_spawning_a_broken_prompt() {
632 let payload = serde_json::json!({ "actor": "octocat" });
633 let err = interpolate("Fix {{ issue.title }}", &payload).unwrap_err();
634 assert!(
635 err.contains("issue.title"),
636 "error should name the path: {err}"
637 );
638
639 let null = serde_json::json!({ "issue": { "title": null } });
640 assert!(interpolate("Fix {{ issue.title }}", &null).is_err());
641 }
642
643 #[test]
644 fn an_oversized_payload_field_is_truncated_not_passed_through() {
645 let payload = serde_json::json!({ "body": "x".repeat(MAX_SUBSTITUTION * 2) });
646 let out = interpolate("{{ body }}", &payload).unwrap();
647 assert!(
648 out.len() < MAX_SUBSTITUTION * 2,
649 "a caller-controlled field must not set the prompt size"
650 );
651 assert!(
652 out.contains("truncated"),
653 "truncation must be visible: {out}"
654 );
655 }
656
657 fn sign(secret: &str, body: &[u8]) -> String {
659 use hmac::Mac as _;
660 let mut mac =
661 hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).expect("any key length");
662 mac.update(body);
663 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
664 }
665
666 #[test]
667 fn a_correctly_signed_body_is_accepted() {
668 let body = br#"{"hello":"world"}"#;
669 let sig = sign("s3cret", body);
670 assert!(verify_webhook_signature("s3cret", body, Some(&sig)));
671 }
672
673 #[test]
674 fn an_unsigned_or_wrongly_signed_request_is_rejected() {
675 let body = br#"{"hello":"world"}"#;
676 let good = sign("s3cret", body);
677 assert!(!verify_webhook_signature("s3cret", body, None));
679 assert!(!verify_webhook_signature("other", body, Some(&good)));
681 assert!(!verify_webhook_signature(
683 "s3cret",
684 br#"{"hello":"evil"}"#,
685 Some(&good)
686 ));
687 assert!(!verify_webhook_signature("s3cret", body, Some("")));
689 assert!(!verify_webhook_signature("s3cret", body, Some("garbage")));
690 assert!(!verify_webhook_signature(
691 "s3cret",
692 body,
693 Some(&good.replace("sha256=", ""))
694 ));
695 }
696
697 #[test]
698 fn five_field_cron_gains_a_zero_seconds_field() {
699 assert_eq!(normalize_schedule("*/5 * * * *"), "0 */5 * * * *");
702 assert_eq!(normalize_schedule("30 2 * * 1"), "0 30 2 * * 1");
703 }
704
705 #[test]
706 fn six_and_seven_field_cron_pass_through() {
707 assert_eq!(normalize_schedule("15 */5 * * * *"), "15 */5 * * * *");
708 assert_eq!(
709 normalize_schedule("0 0 12 * * Mon 2030"),
710 "0 0 12 * * Mon 2030"
711 );
712 }
713 #[tokio::test]
716 async fn a_due_cron_automation_spawns_and_records_the_run() {
717 let (engine, fake, _dir) = automation_fixture().await;
718 create(&engine, cron_body("nightly", "*/5 * * * *"))
719 .await
720 .unwrap();
721
722 engine.tick_automations(at("2026-09-20T10:05:30Z")).await;
723
724 let specs = fake.received_specs();
725 assert_eq!(specs.len(), 1, "exactly one agent spawned");
726 assert_eq!(specs[0].initial_prompt, "sweep the logs");
727
728 let runs = engine.automation_runs("nightly", 10).await.unwrap();
729 assert_eq!(runs.len(), 1);
730 assert_eq!(runs[0].source, RunSource::Schedule);
731 assert_eq!(
732 runs[0].fired_at, "2026-09-20T10:05:00Z",
733 "the tick, not now"
734 );
735 assert!(runs[0].agent_id.is_some(), "the run names the agent");
736 assert!(runs[0].error.is_none());
737 }
738
739 #[tokio::test]
742 async fn the_same_tick_never_fires_twice() {
743 let (engine, fake, _dir) = automation_fixture().await;
744 create(&engine, cron_body("nightly", "*/5 * * * *"))
745 .await
746 .unwrap();
747
748 for _ in 0..3 {
749 engine.tick_automations(at("2026-09-20T10:05:30Z")).await;
750 }
751
752 assert_eq!(fake.received_specs().len(), 1, "one tick, one agent");
753 assert_eq!(
754 engine.automation_runs("nightly", 10).await.unwrap().len(),
755 1
756 );
757 }
758
759 #[tokio::test]
760 async fn an_automation_does_not_fire_before_its_tick_or_while_disabled() {
761 let (engine, fake, _dir) = automation_fixture().await;
762 create(&engine, cron_body("nightly", "*/5 * * * *"))
763 .await
764 .unwrap();
765
766 engine.tick_automations(at("2026-09-20T10:03:00Z")).await;
768 assert!(fake.received_specs().is_empty(), "fired between ticks");
769
770 engine
771 .set_automation_enabled("nightly", false)
772 .await
773 .unwrap();
774 engine.tick_automations(at("2026-09-20T10:05:30Z")).await;
775 assert!(
776 fake.received_specs().is_empty(),
777 "a disabled automation fired"
778 );
779
780 engine
782 .set_automation_enabled("nightly", true)
783 .await
784 .unwrap();
785 engine.tick_automations(at("2026-09-20T10:10:30Z")).await;
786 assert_eq!(fake.received_specs().len(), 1);
787 }
788
789 #[tokio::test]
792 async fn a_signed_webhook_spawns_with_payload_fields_in_the_task() {
793 let (engine, fake, _dir) = automation_fixture().await;
794 let created = create(
795 &engine,
796 CreateAutomationBody {
797 id: "on-issue".into(),
798 workspace: "p".into(),
799 trigger: Trigger::Webhook,
800 template: SpawnTemplate {
801 task: "Fix {{ issue.title }} for {{ actor }}".into(),
802 ..Default::default()
803 },
804 enabled: true,
805 },
806 )
807 .await
808 .unwrap();
809 let secret = created
810 .webhook_secret
811 .expect("a webhook automation is issued a signing key");
812
813 let body = br#"{"issue":{"title":"the bug"},"actor":"octocat"}"#;
814 let signature = {
815 use hmac::Mac as _;
816 let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).unwrap();
817 mac.update(body);
818 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
819 };
820
821 let run = engine
822 .trigger_webhook("on-issue", body, Some(&signature))
823 .await
824 .unwrap();
825 assert_eq!(run.source, RunSource::Webhook);
826 assert!(run.error.is_none());
827
828 let specs = fake.received_specs();
829 assert_eq!(specs.len(), 1);
830 assert_eq!(specs[0].initial_prompt, "Fix the bug for octocat");
831 }
832
833 #[tokio::test]
834 async fn an_unsigned_or_mis_signed_webhook_spawns_nothing() {
835 let (engine, fake, _dir) = automation_fixture().await;
836 create(
837 &engine,
838 CreateAutomationBody {
839 id: "on-issue".into(),
840 workspace: "p".into(),
841 trigger: Trigger::Webhook,
842 template: SpawnTemplate {
843 task: "Fix {{ issue.title }}".into(),
844 ..Default::default()
845 },
846 enabled: true,
847 },
848 )
849 .await
850 .unwrap();
851
852 let body = br#"{"issue":{"title":"the bug"}}"#;
853 assert!(
854 engine
855 .trigger_webhook("on-issue", body, None)
856 .await
857 .is_err(),
858 "an unsigned request must be rejected"
859 );
860 assert!(
861 engine
862 .trigger_webhook("on-issue", body, Some("sha256=deadbeef"))
863 .await
864 .is_err(),
865 "a bad signature must be rejected"
866 );
867 assert!(
868 fake.received_specs().is_empty(),
869 "a rejected webhook spawned an agent"
870 );
871 assert!(
872 engine
873 .automation_runs("on-issue", 10)
874 .await
875 .unwrap()
876 .is_empty(),
877 "a rejected webhook must not even be recorded as a run"
878 );
879 }
880
881 #[tokio::test]
884 async fn a_cron_automation_has_no_webhook_surface() {
885 let (engine, _fake, _dir) = automation_fixture().await;
886 let created = create(&engine, cron_body("nightly", "*/5 * * * *"))
887 .await
888 .unwrap();
889 assert!(
890 created.webhook_secret.is_none(),
891 "a cron automation must not be issued a signing key"
892 );
893 assert!(
894 engine
895 .trigger_webhook("nightly", b"{}", Some("sha256=00"))
896 .await
897 .is_err()
898 );
899 }
900
901 #[tokio::test]
904 async fn a_failed_spawn_is_recorded_as_a_failed_run() {
905 let (engine, _fake, _dir) = automation_fixture().await;
906 create(
907 &engine,
908 CreateAutomationBody {
909 id: "ghost".into(),
910 workspace: "not-registered".into(),
911 trigger: Trigger::Cron {
912 schedule: "*/5 * * * *".into(),
913 },
914 template: SpawnTemplate {
915 task: "sweep the logs".into(),
916 ..Default::default()
917 },
918 enabled: true,
919 },
920 )
921 .await
922 .unwrap();
923
924 engine.tick_automations(at("2026-09-20T10:05:30Z")).await;
925
926 let runs = engine.automation_runs("ghost", 10).await.unwrap();
927 assert_eq!(runs.len(), 1, "a failed fire is still a run");
928 assert!(runs[0].agent_id.is_none());
929 assert!(runs[0].error.is_some(), "the run says why it failed");
930
931 engine.tick_automations(at("2026-09-20T10:05:40Z")).await;
934 assert_eq!(engine.automation_runs("ghost", 10).await.unwrap().len(), 1);
935 }
936
937 const ANCHOR: &str = "2026-09-20T10:00:00Z";
941
942 async fn create(
943 engine: &AutomationEngine,
944 body: CreateAutomationBody,
945 ) -> Result<CreatedAutomationResponse> {
946 engine.create_automation_at(body, at(ANCHOR)).await
947 }
948
949 fn at(s: &str) -> chrono::DateTime<chrono::Utc> {
950 chrono::DateTime::parse_from_rfc3339(s)
951 .unwrap()
952 .with_timezone(&chrono::Utc)
953 }
954
955 fn cron_body(id: &str, schedule: &str) -> CreateAutomationBody {
956 CreateAutomationBody {
957 id: id.into(),
958 workspace: "p".into(),
959 trigger: Trigger::Cron {
960 schedule: schedule.into(),
961 },
962 template: SpawnTemplate {
963 task: "sweep the logs".into(),
964 ..Default::default()
965 },
966 enabled: true,
967 }
968 }
969
970 async fn automation_fixture() -> (
974 AutomationEngine,
975 crate::testkit::FakeCaliband,
976 tempfile::TempDir,
977 ) {
978 use crate::testkit::FakeCaliband;
979
980 let dir = tempfile::tempdir().unwrap();
981 let mut config = crate::fleet::FleetConfig::new("local", dir.path());
982 config.discovery_env.caliban_daemon_runtime_dir = Some(dir.path().to_path_buf());
983 config.ensure.autostart = false;
984 let root = dir.path().join("repo");
985 std::fs::create_dir_all(&root).unwrap();
986 let socket = crate::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
987 let fake = FakeCaliband::start_at(&socket).await.unwrap();
988 let store = std::sync::Arc::new(crate::store::JsonlStore::open(dir.path()).unwrap());
989 let config_store: std::sync::Arc<dyn crate::config_store::ConfigStore> =
990 std::sync::Arc::new(
991 crate::config_store::SqliteConfigStore::open(dir.path())
992 .await
993 .unwrap(),
994 );
995 let mgr =
996 crate::fleet::FleetManager::with_config_store(config, store, config_store.clone())
997 .await
998 .unwrap();
999 mgr.add_repo("p", &root).await.unwrap();
1000 let fleet: std::sync::Arc<dyn crate::fleet_provider::FleetProvider> =
1001 std::sync::Arc::new(crate::fleet_provider::LocalFleet::new(mgr));
1002 (AutomationEngine::new(config_store, fleet), fake, dir)
1003 }
1004}