Skip to main content

prospero_core/
automation.rs

1//! Automations (#220): scheduled and webhook-triggered spawns.
2//!
3//! Two layers. The **rules** — cron evaluation, webhook signatures,
4//! task-template interpolation — are pure functions over borrowed data, with
5//! `now` supplied by the caller, so every one of them is testable without
6//! waiting for wall-clock time to pass. [`AutomationEngine`] is the thin
7//! stateful shell that reads the store, decides what is due, and spawns.
8
9use 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/// An automation as persisted, including the secret the wire type omits.
21///
22/// [`Automation`] is shared with the dashboard and every API response;
23/// `webhook_secret` deliberately lives only here, so there is no path by which
24/// serializing an automation exposes its signing key.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StoredAutomation {
27    /// The automation itself — the part that is safe to hand out.
28    pub automation: Automation,
29    /// HMAC signing key for a webhook trigger. Shown once, at creation.
30    pub webhook_secret: Option<String>,
31}
32
33/// Runs retained per automation. Run history is a debugging aid, not an audit
34/// log (the event store holds those), so it is bounded rather than growing
35/// without limit in the config DB.
36pub const RUN_HISTORY_LIMIT: usize = 200;
37
38/// Render a timestamp the one way the store compares them.
39///
40/// Fire times are compared **lexicographically** by the claim statement's
41/// `WHERE last_fired_at < ?`, which is only equivalent to chronological order
42/// if every timestamp has the same fixed width and zone. Whole seconds and a
43/// literal `Z` guarantee that; a stray `+00:00` or fractional-second stamp
44/// would silently break the ordering that keeps a tick from firing twice.
45pub fn stamp(at: DateTime<Utc>) -> String {
46    at.to_rfc3339_opts(SecondsFormat::Secs, true)
47}
48
49/// Adapt an operator-written cron expression to the 6-field form the `cron`
50/// crate parses.
51///
52/// Operators write standard 5-field cron (`min hour dom mon dow`) — that is
53/// what crontab, Kubernetes `CronJob` and every scheduling UI mean by "cron".
54/// The `cron` crate instead expects a leading **seconds** field, so a plain
55/// `*/5 * * * *` would silently parse as *every 5 seconds* rather than every 5
56/// minutes. Prepending `0` pins such a schedule to the top of its minute.
57///
58/// A 6- or 7-field expression is passed through untouched, so the crate's own
59/// seconds/year syntax stays available to anyone who wants it.
60pub 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
69/// Parse an operator-written schedule, normalizing the 5-field form first.
70///
71/// Returned as a `String` error so the API layer can hand the operator the
72/// reason their expression was rejected at create time, rather than the
73/// automation silently never firing.
74pub 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
79/// The scheduled tick this automation owes, or `None` if it owes nothing yet.
80///
81/// `since` is the anchor: the last fire if there was one, otherwise the
82/// automation's creation time — so a newly created automation waits for its
83/// next scheduled tick instead of firing the instant it is saved.
84///
85/// **Missed ticks coalesce.** If the daemon was down across several ticks this
86/// returns only the most recent one, so an hour of downtime on a `*/5` schedule
87/// produces a single catch-up run rather than twelve simultaneous agents.
88pub fn due_fire(schedule: &str, since: DateTime<Utc>, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
89    let parsed = parse_schedule(schedule).ok()?;
90    // Walk *backwards* from now and take the first tick. Walking forwards from
91    // `since` instead would cost one iteration per elapsed tick, so a long
92    // outage on a per-minute schedule would spin through the whole gap on
93    // every poll; this is one step whatever the gap.
94    parsed.after(&now).next_back().filter(|tick| *tick > since)
95}
96
97/// Longest substituted value, in bytes. A webhook payload is caller-controlled,
98/// and an unbounded field would let one request push a multi-megabyte prompt
99/// into an agent. Longer values are truncated with an explicit marker rather
100/// than silently cut, so the agent can see that it is reading a fragment.
101pub const MAX_SUBSTITUTION: usize = 4096;
102
103/// Substitute `{{ dotted.path }}` placeholders in a task template from `payload`.
104///
105/// **The payload is data, not instruction.** Whoever writes the automation
106/// chooses where a webhook's fields land in the prompt; the sender only fills
107/// those slots. Values are truncated at [`MAX_SUBSTITUTION`].
108///
109/// A path that is missing or `null` is an error naming that path — the run is
110/// recorded as failed instead of spawning an agent on a half-formed prompt.
111pub 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        // An unclosed `{{` is a template typo, not a substitution: emit the
121        // remainder verbatim so the operator sees their own text back.
122        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
133/// Resolve one dotted path against the payload, rendered for a prompt.
134///
135/// Object keys only — a path segment is never read as an array index, so a
136/// template cannot reach into a list the automation's author did not name.
137fn 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        // A bare string, so a quoted `"octocat"` never reaches the prompt.
152        serde_json::Value::String(s) => s.clone(),
153        other => other.to_string(),
154    };
155    Ok(truncate(rendered))
156}
157
158/// Clamp one substituted value to [`MAX_SUBSTITUTION`], on a char boundary.
159fn 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
172/// Verify a webhook's `sha256=<hex>` HMAC signature over the raw request body.
173///
174/// Constant-time comparison: a byte-by-byte early return would leak the
175/// expected digest to anyone willing to time their requests. An absent or
176/// malformed header is a rejection, so an unsigned request can never be
177/// mistaken for a signed one.
178pub 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    // `verify_slice` compares in constant time and checks the length itself.
193    mac.verify_slice(&provided).is_ok()
194}
195
196/// The automation runtime: stored triggers, the tick that fires them, and the
197/// run history they leave behind (#220).
198///
199/// Spawns through [`crate::fleet_provider::FleetProvider`] rather than a
200/// concrete backend, so the same engine drives caliband over Unix sockets and
201/// `CalibanTask` CRs under Kubernetes. It needs a **shared** [`ConfigStore`]:
202/// the per-tick claim is what keeps replicas from double-firing, and a config
203/// store local to one process could not arbitrate between them.
204pub 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    /// Build an engine over a shared config store and a fleet backend.
211    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    /// Evaluate due automations every `interval` until `shutdown` flips.
219    ///
220    /// A loop of its own rather than a step inside the fleet poll: how often
221    /// schedules are checked and how often agents are reconciled are unrelated
222    /// questions, and under k8s there is no fleet poll loop at all.
223    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    /// Every configured automation, ordered by id (#220).
243    pub async fn list_automations(&self) -> Result<Vec<StoredAutomation>> {
244        self.config.list_automations().await
245    }
246
247    /// Store a new automation, minting a signing key for a webhook trigger.
248    ///
249    /// The key is returned once, here — nothing reads it back out afterwards,
250    /// so an operator who loses it recreates the automation rather than
251    /// recovering the secret.
252    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    /// [`Self::create_automation`] with the creation time supplied.
260    ///
261    /// `created_at` is the scheduling anchor, so injecting it is what lets a
262    /// caller — a test, or a future import/restore path — place an automation
263    /// on the timeline instead of always at "now".
264    pub async fn create_automation_at(
265        &self,
266        body: CreateAutomationBody,
267        created_at: chrono::DateTime<chrono::Utc>,
268    ) -> Result<CreatedAutomationResponse> {
269        // Reject a bad schedule now, while someone is watching. Accepting it
270        // would produce an automation that simply never fires, with nothing
271        // anywhere to say why.
272        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        // Only a webhook trigger gets a key, and only at creation — there is no
289        // read-back path, so the key cannot later be recovered from the store
290        // by anything short of database access.
291        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    /// Look one automation up by id.
315    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    /// Remove an automation and its run history.
325    pub async fn delete_automation(&self, id: &str) -> Result<bool> {
326        self.config.delete_automation(id).await
327    }
328
329    /// Enable or disable an automation without discarding it.
330    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        // `upsert_automation` leaves `last_fired_at` alone, so disabling and
334        // re-enabling does not replay the ticks that elapsed meanwhile.
335        self.config.upsert_automation(&stored).await
336    }
337
338    /// An automation's recent runs, newest first.
339    pub async fn automation_runs(&self, id: &str, limit: usize) -> Result<Vec<AutomationRun>> {
340        self.config.list_runs(id, limit).await
341    }
342
343    /// Fire every cron automation whose tick has come due at `now`.
344    ///
345    /// Safe to run on every replica concurrently: the store's compare-and-set
346    /// on the tick decides who acts, so this needs no lifecycle lease. That
347    /// also means a scheduled spawn still happens when the lease holder is the
348    /// replica that just died.
349    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            // Before the first fire the anchor is creation time, so a new
367            // automation waits for its next tick rather than firing at once.
368            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            // Claim before spawning. Whoever wins owns this tick, and the
382            // claim stands even if the spawn then fails — a broken automation
383            // records one failed run per tick instead of retrying on every
384            // poll forever.
385            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    /// Perform an automation's spawn and describe what happened.
407    ///
408    /// Never returns an error: a failure *is* the outcome being recorded.
409    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    /// Persist a run, logging rather than propagating a store failure — the
448    /// agent has already been spawned by this point, so failing the caller
449    /// would misreport what happened.
450    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    /// Fire one automation immediately, whatever its trigger.
461    ///
462    /// Backs both "run now" and an accepted webhook. `payload` fills the task
463    /// template's placeholders; `None` means the template must have none.
464    /// Records a run either way — including when the spawn fails, because an
465    /// automation that quietly stops working is the failure this history is
466    /// for.
467    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    /// Verify a webhook's signature and, if it holds, fire the automation.
483    ///
484    /// `body` is the **raw** request bytes: the signature covers exactly what
485    /// the sender transmitted, so re-serializing parsed JSON first would
486    /// compare a different byte string and reject every honest caller.
487    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        // A cron automation has no key, so there is nothing a caller could
496        // sign with — reject rather than treating "no secret" as "no check".
497        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
523/// Build the spawn an automation's template describes.
524///
525/// Everything an automation can ask for is a field a person could set on a
526/// manual spawn, so an automation is never a way to reach a capability the
527/// HTTP API does not already offer.
528fn 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        // An automation runs with nobody at the keyboard, so an interactive
536        // agent would simply sit waiting for input that never comes.
537        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
548/// Mint a webhook signing key: 32 bytes of CSPRNG output, hex-encoded.
549fn 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        // Daemon down 10:00 -> 11:02 on a 5-minute schedule. Firing every
589        // missed tick would launch 12 agents at once; we owe exactly one.
590        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    /// The signature the sender computes, for tests that need a valid one.
658    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        // No header at all — the acceptance criterion for an unsigned request.
678        assert!(!verify_webhook_signature("s3cret", body, None));
679        // Right shape, wrong key.
680        assert!(!verify_webhook_signature("other", body, Some(&good)));
681        // Right key, body tampered with after signing.
682        assert!(!verify_webhook_signature(
683            "s3cret",
684            br#"{"hello":"evil"}"#,
685            Some(&good)
686        ));
687        // Malformed headers must not be mistaken for a signature.
688        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        // Without this, the `cron` crate reads "*/5 * * * *" as every 5
700        // *seconds* — a 12x over-fire that no operator asked for.
701        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    /// #220 acceptance criterion 1: a cron automation spawns on schedule and
714    /// records the run.
715    #[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    /// The tick is claimed, so re-running the same moment — a second replica,
740    /// or the next poll before the next tick — must not spawn again.
741    #[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        // Anchored at 10:00; 10:03 is not yet a tick.
767        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        // Re-enabling lets the next due tick through.
781        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    /// #220 acceptance criterion 2: a signed webhook spawns with payload fields
790    /// in the task; an unsigned one is rejected.
791    #[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    /// A cron automation cannot be fired by webhook, however well signed: its
882    /// trigger is the schedule, and it was never issued a key.
883    #[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    /// A failing spawn is still recorded, so an automation that has quietly
902    /// stopped working is visible in its history rather than silent.
903    #[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        // And the tick stays claimed, so a broken automation does not retry in
932        // a tight loop on every poll.
933        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    /// Every automation in these tests is anchored here, so a fixed `now`
938    /// passed to `tick_automations` is a real elapsed interval rather than a
939    /// moment relative to the wall clock.
940    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    /// An engine over a real `LocalFleet` backed by `FakeCaliband`, with
971    /// workspace "p" registered. Driving the actual `FleetProvider` seam means
972    /// a passing test here is a spawn that would really reach a backend.
973    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}