Skip to main content

areev_trigger/
eval.rs

1//! The evaluation cycle: find due, claim, invoke, ingest, start, release.
2//!
3//! One invocation performs the whole cycle and exits. Nothing stays resident.
4//! Losing a claim race is a no-op exit, not an error — that is the steady state
5//! when several nodes share a heartbeat.
6//!
7//! ## Why correctness does not rest on the lease
8//!
9//! A lease deduplicates *"who is working right now"*. But the thing that
10//! actually needs deduplicating — a firing of trigger T for item I — has a
11//! natural identity, so the run id is derived from it. `areev run start`
12//! already refuses a duplicate run id, so two evaluators racing produce one run
13//! and one recorded skip, with no lease consulted at all: no duration to guess,
14//! no fencing token to compare, no clock-skew window.
15//!
16//! The lease then does one narrower job: stopping two nodes from both making
17//! the same expensive, side-effecting connector call. Losing it can cost a
18//! wasted API call; it can never cause a missed or duplicated firing. That is
19//! the risk profile a lease should carry.
20
21use std::sync::Arc;
22
23use areev_cal::AreevFacade;
24use areev_core::types::{Event, Grain, GrainType, Observation, Trigger, TriggerKind};
25use areev_run::HostToolExecutor;
26use areev_store::TriggerState;
27use serde::Serialize;
28use sha2::{Digest, Sha256};
29
30use crate::clock::Clock;
31use crate::connector::{dedup_value, PollItem, PollRequest, PollResponse};
32use crate::error::{Result, TriggerError};
33use crate::schedule;
34
35/// Namespace for trigger firing records. Alongside the runtime's own
36/// `agent:harness`, so operational journalling stays out of user namespaces.
37pub const TRIGGER_NS: &str = "agent:triggers";
38
39/// How many triggers one invocation will look at. A backstop, not a policy.
40const MAX_TRIGGERS_SCANNED: usize = 4096;
41
42/// Knobs for one evaluation pass.
43#[derive(Debug, Clone)]
44pub struct EvalOptions {
45    /// Evaluate only this trigger (its content address).
46    pub only: Option<String>,
47    /// How long a claim is held before another evaluator may take over.
48    /// Deliberately generous: a lease shorter than the worst-case connector
49    /// runtime causes takeovers mid-poll, which costs duplicate API calls.
50    pub lease: std::time::Duration,
51    /// Cap on items requested per firing.
52    pub max_items: usize,
53    /// Report what would happen and touch nothing. The safe first command on a
54    /// new deployment.
55    pub dry_run: bool,
56    /// This evaluator's identity, recorded in the claim.
57    pub node: String,
58}
59
60impl Default for EvalOptions {
61    fn default() -> Self {
62        EvalOptions {
63            only: None,
64            lease: std::time::Duration::from_secs(300),
65            max_items: 100,
66            dry_run: false,
67            node: default_node_id(),
68        }
69    }
70}
71
72/// First `n` characters, safely — `&s[..n]` panics on a short string or a
73/// multi-byte boundary, and a message-formatting helper must not be what takes
74/// the process down. Declarations can arrive by bundle import from an
75/// implementation we did not write.
76fn short(s: &str, n: usize) -> &str {
77    match s.char_indices().nth(n) {
78        Some((idx, _)) => &s[..idx],
79        None => s,
80    }
81}
82
83/// A stable-ish identity for this evaluator. Host name plus pid: enough to tell
84/// two nodes apart in a claim, and to recognise our own stale claim after a
85/// restart.
86fn default_node_id() -> String {
87    let host = std::env::var("HOSTNAME")
88        .or_else(|_| std::env::var("COMPUTERNAME"))
89        .unwrap_or_else(|_| "node".into());
90    format!("{host}/{}", std::process::id())
91}
92
93/// What one evaluation pass did. Shaped like `loop run`'s report for symmetry.
94#[derive(Debug, Clone, Default, Serialize)]
95pub struct EvalReport {
96    pub claimed: usize,
97    pub skipped_not_due: usize,
98    pub skipped_locked: usize,
99    pub skipped_paused: usize,
100    pub items: usize,
101    pub duplicates: usize,
102    pub runs_started: usize,
103    /// Items recorded without starting a workflow, because no runtime is wired
104    /// in. Counted apart from `runs_started` so the report never claims a run
105    /// happened when none did.
106    pub ingested: usize,
107    /// Items the connector returned that carry no usable identity, so no run
108    /// could be minted for them. Reported rather than silently dropped: an item
109    /// nobody can name is a connector bug, and it should be visible as one.
110    pub unidentifiable: usize,
111    /// Not fatal to the pass: one broken trigger must not stop the others.
112    pub errors: Vec<String>,
113    /// A connector reported a backlog, so this trigger is due again at once
114    /// rather than after its interval.
115    pub draining: Vec<String>,
116}
117
118/// One trigger considered, for `--dry-run` and `status`.
119#[derive(Debug, Clone, Serialize)]
120pub struct TriggerStatus {
121    pub trigger: String,
122    pub kind: String,
123    pub workflow: String,
124    pub enabled: bool,
125    pub paused: bool,
126    pub due: bool,
127    pub leased_by: Option<String>,
128    pub next_due_at: Option<i64>,
129    pub last_fired_at: Option<i64>,
130    pub consecutive_failures: u32,
131    pub last_error: Option<String>,
132    /// Will never fire again — a one-shot past its instant.
133    pub exhausted: bool,
134    /// Never fired and no failure recorded — the state an unnoticed
135    /// misconfiguration sits in, so it is reported rather than inferred.
136    pub never_fired: bool,
137}
138
139/// Starts the workflow a trigger is bound to.
140///
141/// A seam rather than a direct `areev_run::Runner` call, for the same reason
142/// the CAL executor takes a governance host: the runtime sits above this crate
143/// in the dependency order for hosts that want it, and a test needs to observe
144/// what *would* be started without spending a real run. It is also where the
145/// duplicate rule lives, which is the whole idempotency story.
146pub trait RunStarter: Send + Sync {
147    fn start(&self, workflow: &str, run_id: &str, input: serde_json::Value) -> StartResult;
148}
149
150/// What starting a run did.
151#[derive(Debug, Clone, PartialEq)]
152pub enum StartResult {
153    Started,
154    /// A run with this id already exists. **Not an error** — it is the
155    /// idempotency guarantee working: this item was already handled, by an
156    /// earlier firing or by another node that won the race.
157    Duplicate,
158    Failed(String),
159}
160
161/// Evaluates triggers against one memory.
162pub struct Evaluator {
163    pub facade: Arc<AreevFacade>,
164    pub clock: Arc<dyn Clock>,
165    /// Executes connector commands. `None` means a due polling trigger fails
166    /// loudly with `TRG-E003` rather than quietly doing nothing — a poll that
167    /// silently returns no items is indistinguishable from a healthy source.
168    pub connector: Option<Arc<dyn HostToolExecutor>>,
169    /// Starts the bound workflow. `None` ingests the item and records the
170    /// firing without starting anything — what `--no-start` is for, and what
171    /// the ingest-only tests use.
172    pub starter: Option<Arc<dyn RunStarter>>,
173    /// Credentials the broker may attach, by name. Values live here and never
174    /// in a grain or a connector's environment: a declaration names a
175    /// credential, it never carries one.
176    pub credentials: std::collections::BTreeMap<String, areev_run::Credential>,
177    pub ns: String,
178    pub principal: String,
179}
180
181impl Evaluator {
182    /// An evaluator that can inspect but not act: no connector, no runtime, no
183    /// credentials. What `list`, `show` and `status` want, and the shape that
184    /// makes "reading cannot fire anything" true by construction rather than by
185    /// remembering to pass `None` four times.
186    pub fn read_only(facade: Arc<AreevFacade>, clock: Arc<dyn Clock>, ns: &str) -> Evaluator {
187        Evaluator {
188            facade,
189            clock,
190            connector: None,
191            starter: None,
192            credentials: Default::default(),
193            ns: ns.to_string(),
194            principal: "user:local".into(),
195        }
196    }
197
198    /// Every trigger declaration in the memory, newest first.
199    pub fn declarations(&self) -> Result<Vec<(String, Trigger)>> {
200        let ns = self.ns.clone();
201        let grains = self
202            .facade
203            .with_store(|m| {
204                m.recent_live_scoped(&[ns], Some(GrainType::Trigger), MAX_TRIGGERS_SCANNED)
205            })
206            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
207
208        let mut out = Vec::new();
209        for g in grains {
210            let hash = g.hash.to_hex();
211            match g.to_trigger() {
212                Ok(t) => out.push((hash, t)),
213                // A declaration this build cannot read is reported, never
214                // skipped silently: silence is the symptom of every trigger
215                // failure, so it must never also be the symptom of a bug.
216                Err(e) => {
217                    return Err(TriggerError::Malformed {
218                        what: format!("declaration {hash} is unreadable: {e}"),
219                    })
220                }
221            }
222        }
223        Ok(out)
224    }
225
226    /// What every trigger's state looks like right now.
227    pub fn status(&self) -> Result<Vec<TriggerStatus>> {
228        let now = self.clock.now_ms();
229        let mut out = Vec::new();
230        for (hash, t) in self.declarations()? {
231            let st = self
232                .facade
233                .with_store(|m| m.trigger_state(&hash))
234                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
235                .map(|(s, _)| s)
236                .unwrap_or_default();
237            out.push(TriggerStatus {
238                trigger: hash,
239                kind: t.kind.as_str().to_string(),
240                workflow: t.workflow.clone(),
241                enabled: t.enabled,
242                paused: st.paused,
243                due: t.enabled
244                    && !st.leased(now)
245                    && schedule::is_due(&t, &st, now).unwrap_or(false),
246                leased_by: st.leased(now).then(|| st.claimed_by.clone()).flatten(),
247                next_due_at: st.next_due_at,
248                last_fired_at: st.last_fired_at,
249                consecutive_failures: st.consecutive_failures,
250                last_error: st.last_error.clone(),
251                exhausted: st.exhausted,
252                never_fired: st.last_fired_at.is_none() && st.consecutive_failures == 0,
253            });
254        }
255        Ok(out)
256    }
257
258    /// Ingest a payload delivered by the host for a `webhook` or `manual`
259    /// trigger.
260    ///
261    /// Areev never opens a port. The host already terminates TLS and
262    /// authenticates the sender — it is far better at both than a memory engine
263    /// would be — and hands the payload to a one-shot invocation. That keeps the
264    /// useful half of webhooks without a listening daemon.
265    ///
266    /// Idempotent on the same terms as a poll: the payload's dedup value mints
267    /// the run id, so a webhook delivered twice (which every provider does)
268    /// produces one run and one recorded skip.
269    pub fn deliver(&self, trigger_hash: &str, payload: serde_json::Value) -> Result<EvalReport> {
270        let now = self.clock.now_ms();
271        let (hash, trigger) = self
272            .declarations()?
273            .into_iter()
274            .find(|(h, _)| h.starts_with(trigger_hash))
275            .ok_or_else(|| TriggerError::Malformed {
276                what: format!("no trigger matching '{trigger_hash}'"),
277            })?;
278
279        if !matches!(trigger.kind, TriggerKind::Webhook | TriggerKind::Manual) {
280            return Err(TriggerError::Malformed {
281                what: format!(
282                    "trigger {} is a {} trigger — only webhook and manual triggers accept a \
283                     delivery; a clocked or polling trigger fires from `trigger run`",
284                    short(&hash, 12),
285                    trigger.kind.as_str()
286                ),
287            });
288        }
289        if !trigger.enabled {
290            return Err(TriggerError::Malformed {
291                what: format!("trigger {} is disabled", short(&hash, 12)),
292            });
293        }
294        let (state, _) = self
295            .facade
296            .with_store(|m| m.trigger_state(&hash))
297            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
298            .unwrap_or_default();
299        if state.paused {
300            return Err(TriggerError::Malformed {
301                what: format!("trigger {} is paused", short(&hash, 12)),
302            });
303        }
304
305        let mut report = EvalReport::default();
306        let item = PollItem { id: String::new(), payload };
307        report.items = 1;
308        let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
309            report.unidentifiable = 1;
310            // Journal before returning. A delivery naming nothing is precisely
311            // the case the record exists for: the sender's payload shape
312            // changed under us, and stdout of whatever invoked this is not an
313            // audit record -- the replicating Observation is.
314            self.journal(
315                &hash,
316                &trigger,
317                &FireOutcome { items: 1, unidentifiable: 1, ..Default::default() },
318                Some("delivered"),
319                now,
320            )?;
321            return Ok(report);
322        };
323        let run_id = run_id_for(&hash, trigger.connector.as_deref(), &value);
324        match self.start_run(&trigger, &run_id, &item, &hash) {
325            StartOutcome::Started => report.runs_started = 1,
326            StartOutcome::Ingested => report.ingested = 1,
327            StartOutcome::Duplicate => report.duplicates = 1,
328            StartOutcome::Failed(why) => report.errors.push(why),
329        }
330
331        let outcome = FireOutcome {
332            items: 1,
333            runs_started: report.runs_started,
334            duplicates: report.duplicates,
335            ..Default::default()
336        };
337        self.journal(&hash, &trigger, &outcome, Some("delivered"), now)?;
338        Ok(report)
339    }
340
341    /// Run one evaluation pass.
342    pub fn run(&self, opts: &EvalOptions) -> Result<EvalReport> {
343        let mut report = EvalReport::default();
344        let now = self.clock.now_ms();
345
346        let declarations = self.declarations()?;
347        // Composites are settled after their members, so a gate can be
348        // satisfied in the same pass that completes it rather than waiting a
349        // whole heartbeat. Declaration order is newest-first and says nothing
350        // about dependency, so the split is explicit.
351        let (composites, members): (Vec<_>, Vec<_>) =
352            declarations.into_iter().partition(|(_, t)| t.kind == TriggerKind::Composite);
353
354        for (hash, trigger) in members.into_iter().chain(composites) {
355            if opts.only.as_ref().is_some_and(|only| only != &hash) {
356                continue;
357            }
358            if !trigger.enabled {
359                report.skipped_not_due += 1;
360                continue;
361            }
362            let stored = self
363                .facade
364                .with_store(|m| m.trigger_state(&hash))
365                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
366            let (state, raw) = match stored {
367                Some((s, r)) => (s, Some(r)),
368                None => (TriggerState::default(), None),
369            };
370
371            if state.paused {
372                report.skipped_paused += 1;
373                continue;
374            }
375            // An absolute schedule seen for the first time: record when it
376            // will be due rather than re-deriving it every pass, so `trigger
377            // status` can say when it next fires and the comparison afterwards
378            // is a plain timestamp check.
379            if state.next_due_at.is_none() && !state.exhausted {
380                if let Some(first) = schedule::initial_due(&trigger, now)? {
381                    let mut seeded = state.clone();
382                    seeded.next_due_at = Some(first);
383                    let _ = self
384                        .facade
385                        .with_store(|m| m.put_trigger_state(&hash, raw.as_deref(), &seeded))
386                        .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
387                    report.skipped_not_due += 1;
388                    continue;
389                }
390            }
391            if !schedule::is_due(&trigger, &state, now)? {
392                report.skipped_not_due += 1;
393                continue;
394            }
395            if state.leased(now) {
396                // The normal steady state with several nodes on one heartbeat,
397                // not a problem.
398                report.skipped_locked += 1;
399                continue;
400            }
401            if opts.dry_run {
402                report.claimed += 1;
403                continue;
404            }
405
406            match self.fire(&hash, &trigger, state, raw.as_deref(), now, opts) {
407                // Lost the claim between deciding it was free and taking it.
408                // Normal with several nodes on one heartbeat, and it must not
409                // be counted as work done.
410                Ok(outcome) if outcome.skipped_locked => report.skipped_locked += 1,
411                Ok(outcome) => {
412                    if !outcome.fired_items.is_empty() {
413                        if let Err(e) = self.record_for_composites(&hash, &outcome.fired_items, now)
414                        {
415                            report.errors.push(e.to_string());
416                        }
417                    }
418                    report.claimed += 1;
419                    report.items += outcome.items;
420                    report.duplicates += outcome.duplicates;
421                    report.runs_started += outcome.runs_started;
422                    report.ingested += outcome.ingested;
423                    report.unidentifiable += outcome.unidentifiable;
424                    for why in &outcome.failures {
425                        report.errors.push(format!("{hash}: {why}"));
426                    }
427                    if outcome.draining {
428                        report.draining.push(hash.clone());
429                    }
430                }
431                Err(e) => {
432                    // One broken trigger must not stop the others.
433                    report.errors.push(e.to_string());
434                }
435            }
436        }
437        Ok(report)
438    }
439
440    fn fire(
441        &self,
442        hash: &str,
443        trigger: &Trigger,
444        state: TriggerState,
445        raw: Option<&str>,
446        now: i64,
447        opts: &EvalOptions,
448    ) -> Result<FireOutcome> {
449        // Claim. Losing means another evaluator got here first.
450        let mut claimed = state.clone();
451        claimed.fence = claimed.fence.wrapping_add(1);
452        claimed.claimed_by = Some(opts.node.clone());
453        claimed.lease_until = Some(now + opts.lease.as_millis() as i64);
454        let won = self
455            .facade
456            .with_store(|m| m.put_trigger_state(hash, raw, &claimed))
457            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
458        if !won {
459            return Ok(FireOutcome { skipped_locked: true, ..Default::default() });
460        }
461        // What a later release must present as `expected`.
462        let claimed_raw = self
463            .facade
464            .with_store(|m| m.trigger_state(hash))
465            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
466            .map(|(_, r)| r);
467
468        let result = self.collect_and_start(hash, trigger, &claimed, now, opts);
469
470        // Release under the fence, whatever happened.
471        let mut released = claimed.clone();
472        released.claimed_by = None;
473        released.lease_until = None;
474        match &result {
475            Ok(outcome) => {
476                released.last_fired_at = Some(now);
477                released.consecutive_failures = 0;
478                released.last_error = None;
479                if let Some(c) = &outcome.cursor {
480                    released.cursor = Some(c.clone());
481                }
482                if let Some(c) = outcome.op_cursor {
483                    released.op_cursor = Some(c);
484                }
485                // A satisfied gate consumes its correlation key. Leaving it
486                // would re-fire the same set on every pass; Flink calls this
487                // the after-match skip strategy, and the conservative choice —
488                // clear only the key that fired — is the one that cannot
489                // swallow an unrelated in-flight match.
490                for k in &outcome.settled_keys {
491                    released.partials.remove(k);
492                }
493                prune_partials(&mut released, trigger, now);
494                released.exhausted = schedule::exhausts_after(trigger, now);
495                released.next_due_at = if outcome.draining {
496                    // A backlog drains at once rather than waiting out the
497                    // interval — a cold start should not take a day.
498                    Some(now)
499                } else {
500                    schedule::advance_after_firing(trigger, now, now)?
501                };
502            }
503            Err(e) => {
504                released.consecutive_failures = claimed.consecutive_failures.saturating_add(1);
505                released.last_error = Some(e.to_string());
506                released.next_due_at =
507                    Some(schedule::backoff_until(trigger, released.consecutive_failures, now));
508            }
509        }
510        let released_ok = self
511            .facade
512            .with_store(|m| m.put_trigger_state(hash, claimed_raw.as_deref(), &released))
513            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
514        if !released_ok {
515            // Our lease expired mid-firing and someone else took over. Their
516            // state stands; ours is refused. The work we did is not lost — the
517            // runs we started are journaled and idempotent — but we must not
518            // write back over the new owner.
519            self.journal(hash, trigger, &FireOutcome::default(), Some("claim lost"), now)?;
520            return Err(TriggerError::ClaimLost { trigger: hash.to_string() });
521        }
522
523        let outcome = result?;
524        self.journal(hash, trigger, &outcome, None, now)?;
525        Ok(outcome)
526    }
527
528    /// Get the items for this firing and start a run per item.
529    fn collect_and_start(
530        &self,
531        hash: &str,
532        trigger: &Trigger,
533        state: &TriggerState,
534        now: i64,
535        opts: &EvalOptions,
536    ) -> Result<FireOutcome> {
537        let mut outcome = FireOutcome::default();
538
539        let items: Vec<PollItem> = match trigger.kind {
540            TriggerKind::Polling => {
541                let response = self.poll(hash, trigger, state, opts)?;
542                outcome.cursor = response.cursor.clone();
543                outcome.draining = response.more;
544                // First contact seeds the cursor and fires nothing. Otherwise
545                // declaring a mailbox trigger starts a run for every message in
546                // history — Zapier's priming poll, and for the same reason.
547                if state.cursor.is_none() {
548                    outcome.seeded = true;
549                    return Ok(outcome);
550                }
551                response.items
552            }
553            // A clocked trigger with no external source fires once per
554            // occurrence, carrying the occurrence itself as the item.
555            TriggerKind::Interval | TriggerKind::Schedule | TriggerKind::Once => {
556                vec![PollItem { id: now.to_string(), payload: serde_json::json!({ "at_ms": now }) }]
557            }
558            TriggerKind::Memory => {
559                // Recorded so the journal says "seeded" rather than leaving a
560                // firing that produced nothing looking like a quiet memory.
561                outcome.seeded = state.op_cursor.is_none();
562                let (found, cursor) = self.scan_memory(hash, trigger, state, opts)?;
563                outcome.op_cursor = Some(cursor);
564                found
565            }
566            TriggerKind::Composite => {
567                let keys = self.satisfied_keys(hash, trigger, now)?;
568                outcome.settled_keys = keys.clone();
569                keys.into_iter()
570                    .map(|k| PollItem {
571                        // The correlation value IS the identity: one firing per
572                        // correlated set, however many members contributed.
573                        id: k.clone(),
574                        payload: serde_json::json!({ "correlation": k }),
575                    })
576                    .collect()
577            }
578            // These do not poll. The host owns the listener and hands the
579            // payload to `deliver`, which fires them on the same idempotency
580            // terms; an evaluation pass simply has nothing to do for one.
581            TriggerKind::Webhook | TriggerKind::Manual => Vec::new(),
582        };
583
584        outcome.items = items.len();
585        for item in items {
586            let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
587                outcome.unidentifiable += 1;
588                continue;
589            };
590            outcome.fired_items.push(item.payload.clone());
591            let run_id = run_id_for(hash, trigger.connector.as_deref(), &value);
592            match self.start_run(trigger, &run_id, &item, hash) {
593                StartOutcome::Started => outcome.runs_started += 1,
594                StartOutcome::Ingested => outcome.ingested += 1,
595                StartOutcome::Duplicate => outcome.duplicates += 1,
596                StartOutcome::Failed(why) => outcome.failures.push(why),
597            }
598        }
599        Ok(outcome)
600    }
601
602    /// Note a member firing against every composite that declares it.
603    ///
604    /// The correlation value comes from the composite's own `correlate`
605    /// pointer applied to the member's item — so two composites may correlate
606    /// the same member on different fields, which is why this is resolved per
607    /// composite rather than once per member.
608    fn record_for_composites(
609        &self,
610        member: &str,
611        items: &[serde_json::Value],
612        now: i64,
613    ) -> Result<()> {
614        for (chash, composite) in self.declarations()? {
615            let Some(alias) = composite.member_alias(member).map(str::to_string) else {
616                continue;
617            };
618            if composite.kind != TriggerKind::Composite {
619                continue;
620            }
621            let (mut state, raw) = self
622                .facade
623                .with_store(|m| m.trigger_state(&chash))
624                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
625                .map(|(s, r)| (s, Some(r)))
626                .unwrap_or_default();
627
628            for item in items {
629                // No correlate pointer means one global match — every member
630                // firing counts toward the same gate.
631                let key = match &composite.correlate {
632                    Some(ptr) => match item.pointer(ptr) {
633                        Some(serde_json::Value::String(s)) => s.clone(),
634                        Some(other) => other.to_string(),
635                        // An item that cannot be correlated cannot join a
636                        // correlated gate. Dropping it is right: attributing it
637                        // to an arbitrary key would pair unrelated work.
638                        None => continue,
639                    },
640                    None => String::new(),
641                };
642                let entry = state.partials.entry(key).or_insert_with(|| {
643                    areev_store::PartialMatch { members: Default::default(), started_ms: now }
644                });
645                // Recorded under the ALIAS, because that is what the gate
646                // expression names.
647                entry.members.insert(alias.clone(), now);
648            }
649            prune_partials(&mut state, &composite, now);
650            self.facade
651                .with_store(|m| m.put_trigger_state(&chash, raw.as_deref(), &state))
652                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
653        }
654        Ok(())
655    }
656
657    /// Which correlation keys currently satisfy this composite's gate.
658    fn satisfied_keys(&self, hash: &str, trigger: &Trigger, now: i64) -> Result<Vec<String>> {
659        let predicate = trigger
660            .predicate
661            .as_ref()
662            .ok_or_else(|| TriggerError::Malformed {
663                what: format!("composite trigger {hash} has no predicate"),
664            })
665            .and_then(crate::predicate::from_value)?;
666
667        // Refuse a gate that names a member the declaration does not carry: it
668        // could never be satisfied, so the trigger would be silently dead.
669        for referenced in crate::predicate::referenced_members(&predicate) {
670            if !trigger.members.contains_key(&referenced) {
671                return Err(TriggerError::UnknownMember {
672                    trigger: hash.to_string(),
673                    member: referenced,
674                });
675            }
676        }
677
678        let (state, _) = self
679            .facade
680            .with_store(|m| m.trigger_state(hash))
681            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
682            .unwrap_or_default();
683
684        let mut out = Vec::new();
685        for (key, partial) in &state.partials {
686            if let Some(w) = trigger.window_ms {
687                if now - partial.started_ms > w {
688                    continue;
689                }
690            }
691            let fired: Vec<String> = partial.members.keys().cloned().collect();
692            if crate::predicate::gate_satisfied(&predicate, &fired) {
693                out.push(key.clone());
694            }
695        }
696        Ok(out)
697    }
698
699    /// Grains matching the predicate that appeared since the stored op cursor.
700    ///
701    /// Built on the op-log rather than a scan: `changes_since` is an indexed
702    /// primary-key range read with a monotonic exclusive cursor, so cost is
703    /// proportional to what changed rather than to how much the memory holds.
704    ///
705    /// Considered and rejected: `PRAGMA data_version` as a cheap "did anything
706    /// change" pre-check. It is documented as unchanged for commits made on the
707    /// same connection — and Areev is single-writer with one connection, so our
708    /// own writes are exactly what it cannot see.
709    fn scan_memory(
710        &self,
711        hash: &str,
712        trigger: &Trigger,
713        state: &TriggerState,
714        opts: &EvalOptions,
715    ) -> Result<(Vec<PollItem>, i64)> {
716        let predicate = trigger
717            .predicate
718            .as_ref()
719            .ok_or_else(|| TriggerError::Malformed {
720                what: format!("memory trigger {hash} has no predicate"),
721            })
722            .and_then(crate::predicate::from_value)?;
723
724        // First contact seeds at the head of the log and matches nothing.
725        // Otherwise declaring a memory trigger on an established memory fires
726        // once for every historical grain that matches — the same reason a
727        // polling trigger primes rather than replaying a mailbox.
728        let Some(after) = state.op_cursor else {
729            let head = self
730                .facade
731                .with_store(|m| m.head_op_seq())
732                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
733            return Ok((Vec::new(), head));
734        };
735        let ops = self
736            .facade
737            .with_store(|m| m.changes_since(after, opts.max_items))
738            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
739
740        let mut items = Vec::new();
741        let mut cursor = after;
742        for op in ops {
743            cursor = cursor.max(op.op_seq);
744            // Additions and supersessions both mean "this is true now".
745            // A tombstone means the grain is gone, so there is nothing to match.
746            if op.op == areev_store::OP_FORGET {
747                continue;
748            }
749            let Ok(grain) = self.facade.with_store(|m| m.get(&op.hash)) else {
750                // A grain that vanished between the op-log read and the fetch
751                // (erasure, retention) is not an error — it simply no longer
752                // matches anything.
753                continue;
754            };
755            if !crate::predicate::grain_matches(&grain, &predicate) {
756                continue;
757            }
758            let hex = op.hash.to_hex();
759            items.push(PollItem {
760                // The content address IS the identity: the same grain can never
761                // fire twice, and two evaluators seeing it agree without
762                // coordinating.
763                id: hex.clone(),
764                payload: serde_json::json!({
765                    "hash": hex,
766                    "grain_type": grain.grain_type.as_str(),
767                    "op_seq": op.op_seq,
768                }),
769            });
770        }
771        Ok((items, cursor))
772    }
773
774    fn poll(
775        &self,
776        hash: &str,
777        trigger: &Trigger,
778        state: &TriggerState,
779        opts: &EvalOptions,
780    ) -> Result<PollResponse> {
781        let connector_name = trigger.connector.as_deref().unwrap_or_default();
782        let Some(exec) = &self.connector else {
783            return Err(TriggerError::NoConnector {
784                trigger: hash.to_string(),
785                connector: connector_name.to_string(),
786            });
787        };
788        // Start a broker for this call, scoped to this trigger's allowlist. The
789        // connector is handed its URL — never a token — so a compromised
790        // connector has nothing to exfiltrate and nowhere undeclared to send
791        // it. The broker stops when this call returns.
792        let policy = areev_run::EgressPolicy::from_config(trigger.config.as_ref())
793            .map_err(|what| TriggerError::Malformed { what })?;
794        // One connector runs per pass, so there is no second caller to tell it
795        // apart from: a default grant covers it. Its methods are whatever the
796        // connector needs to poll, and the credentials are the ones this host
797        // configured — naming one it was not given still fails.
798        let grants = areev_run::EgressGrants::new().default_for_all(
799            self.credentials.keys().fold(
800                areev_run::CallerGrant::new()
801                    .method("GET")
802                    .method("POST")
803                    .method("PUT")
804                    .method("PATCH")
805                    .method("DELETE"),
806                |g, name| g.credential(name),
807            ),
808        );
809        let broker = areev_run::Broker::start(
810            policy,
811            self.credentials.clone(),
812            grants,
813            "TRG-E009",
814        )
815        .map_err(|detail| TriggerError::Storage { detail })?;
816
817        let request = PollRequest {
818            trigger: hash,
819            connector: connector_name,
820            scope: trigger.scope.as_deref(),
821            cursor: state.cursor.as_deref(),
822            max_items: opts.max_items,
823            config: trigger.config.as_ref(),
824        };
825        let payload = serde_json::to_value(&request).map_err(|e| TriggerError::ConnectorFailed {
826            trigger: hash.to_string(),
827            detail: format!("serialize request: {e}"),
828        })?;
829        // The idempotency key ties this call to this occurrence, so a connector
830        // that deduplicates on it sees a retry as a retry.
831        let idem = format!("{hash}:{}", state.fence);
832        // The connector reads AREEV_EGRESS_URL out of its environment. The
833        // spawn seam scrubs whatever `--passphrase-env` and `--token-env` name,
834        // so this is the only network affordance it has from us.
835        //
836        // The variable is PROCESS-GLOBAL, and `HostToolExecutor::execute` has no
837        // per-call environment, so two evaluators in one process would race:
838        // one could hand its broker's URL to the other's connector, pointing it
839        // at the wrong allowlist. The lock makes the set→spawn→unset window
840        // exclusive. Production runs one evaluator per process, so this
841        // serialises nothing that was parallel; it closes a hazard that only
842        // exists in-process (and that our own threaded tests could reach).
843        static EGRESS_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
844        let result = {
845            let _guard = EGRESS_ENV.lock().unwrap_or_else(|e| e.into_inner());
846            std::env::set_var("AREEV_EGRESS_URL", broker.url());
847            // The broker authenticates its callers: loopback is not an
848            // authorization, and without the token every call would 401.
849            if let Some(t) = broker.token_for(connector_name) {
850                std::env::set_var("AREEV_EGRESS_TOKEN", t);
851            }
852            let r = exec.execute(connector_name, hash, &payload, &idem);
853            std::env::remove_var("AREEV_EGRESS_URL");
854            std::env::remove_var("AREEV_EGRESS_TOKEN");
855            r
856        };
857
858        // Surface a refused destination as its own error rather than letting it
859        // read as a generic connector failure — a connector reaching somewhere
860        // undeclared is a different problem from one that crashed.
861        let refused = broker.refusals();
862        if !refused.is_empty() {
863            return Err(TriggerError::EgressRefused {
864                trigger: hash.to_string(),
865                host: refused
866                    .iter()
867                    .map(|r| format!("{} ({})", r.destination, r.reason))
868                    .collect::<Vec<_>>()
869                    .join(", "),
870            });
871        }
872        match result {
873            areev_run::ExecResult::Ok(v) => {
874                serde_json::from_value(v).map_err(|e| TriggerError::ConnectorFailed {
875                    trigger: hash.to_string(),
876                    detail: format!("response is not a poll response: {e}"),
877                })
878            }
879            areev_run::ExecResult::Err { detail, .. } => {
880                Err(TriggerError::ConnectorFailed { trigger: hash.to_string(), detail })
881            }
882        }
883    }
884
885    /// Record the item as an Event and start the bound workflow.
886    fn start_run(
887        &self,
888        trigger: &Trigger,
889        run_id: &str,
890        item: &PollItem,
891        trigger_hash: &str,
892    ) -> StartOutcome {
893        // Idempotency has to hold whether or not a runtime is wired in. With a
894        // starter, the runtime's duplicate-run-id refusal is the guard; without
895        // one, nothing else would stop the same item being re-ingested on every
896        // poll forever, because an Event's `created_at` differs per firing and
897        // so content addressing does not collapse them.
898        let seen = self
899            .facade
900            .with_store(|m| m.run_grains(&self.ns, run_id, 0, 1))
901            .map(|g| !g.is_empty())
902            .unwrap_or(false);
903        if seen {
904            return StartOutcome::Duplicate;
905        }
906
907        let mut event = Event::new(&item.payload.to_string())
908            .namespace(&self.ns)
909            .extra_field("trigger", serde_json::json!(trigger_hash))
910            .extra_field("run_id", serde_json::json!(run_id));
911        if let Some(c) = &trigger.connector {
912            event = event.extra_field("connector", serde_json::json!(c));
913        }
914        if let Err(e) = self.facade.with_store(|m| m.add(&event)) {
915            return StartOutcome::Failed(format!("ingest: {e}"));
916        }
917        let Some(starter) = &self.starter else {
918            // Ingest-only: the item is recorded, nothing is executed. Reported
919            // as ingested rather than as a run, because claiming a run started
920            // when none did would make the report lie.
921            return StartOutcome::Ingested;
922        };
923        let input = serde_json::json!({
924            "trigger": trigger_hash,
925            "connector": trigger.connector,
926            "scope": trigger.scope,
927            "item": item.payload,
928        });
929        match starter.start(&trigger.workflow, run_id, input) {
930            StartResult::Started => StartOutcome::Started,
931            StartResult::Duplicate => StartOutcome::Duplicate,
932            StartResult::Failed(why) => StartOutcome::Failed(why),
933        }
934    }
935
936    /// One Observation per firing: what fired, what it produced, what it
937    /// skipped. An unfired trigger is invisible otherwise.
938    fn journal(
939        &self,
940        hash: &str,
941        trigger: &Trigger,
942        outcome: &FireOutcome,
943        note: Option<&str>,
944        now: i64,
945    ) -> Result<()> {
946        let mut obs = Observation::new("areev:trigger", "trigger:firing")
947            .namespace(TRIGGER_NS)
948            .created_at(now)
949            .extra_field("trigger", serde_json::json!(hash))
950            .extra_field("kind", serde_json::json!(trigger.kind.as_str()))
951            .extra_field("workflow", serde_json::json!(trigger.workflow))
952            .extra_field("items", serde_json::json!(outcome.items))
953            .extra_field("runs_started", serde_json::json!(outcome.runs_started))
954            .extra_field("duplicates", serde_json::json!(outcome.duplicates));
955        // Without these, a firing where every item lacked the dedup key reads
956        // as "items 5, runs_started 0, duplicates 0" -- and that last zero
957        // actively misleads, saying the items were not skipped as duplicates
958        // without saying why they were skipped at all. Emitted only when
959        // non-zero, so an ordinary firing stays as small as it was.
960        if outcome.unidentifiable > 0 {
961            obs = obs.extra_field("unidentifiable", serde_json::json!(outcome.unidentifiable));
962        }
963        if outcome.ingested > 0 {
964            obs = obs.extra_field("ingested", serde_json::json!(outcome.ingested));
965        }
966        if !outcome.failures.is_empty() {
967            obs = obs.extra_field("failures", serde_json::json!(outcome.failures));
968        }
969        if outcome.seeded {
970            obs = obs.extra_field("seeded", serde_json::json!(true));
971        }
972        if let Some(n) = note {
973            obs = obs.extra_field("note", serde_json::json!(n));
974        }
975        self.facade
976            .with_store(|m| m.add_if_novel(&obs))
977            .map(|_| ())
978            .map_err(|e| TriggerError::Storage { detail: e.to_string() })
979    }
980}
981
982/// Drop partial matches past their window.
983///
984/// Without this, a composite that never completes accumulates keys forever and
985/// a stale half-match eventually pairs with unrelated work — the exact failure
986/// Argo Events' reset cron exists to paper over.
987fn prune_partials(state: &mut TriggerState, trigger: &Trigger, now: i64) {
988    let Some(window) = trigger.window_ms else {
989        return;
990    };
991    state.partials.retain(|_, p| now - p.started_ms <= window);
992}
993
994#[derive(Debug, Default, Clone)]
995struct FireOutcome {
996    items: usize,
997    runs_started: usize,
998    ingested: usize,
999    duplicates: usize,
1000    unidentifiable: usize,
1001    failures: Vec<String>,
1002    cursor: Option<String>,
1003    /// The payloads this firing produced, so a composite can pull its
1004    /// correlation value out of them.
1005    fired_items: Vec<serde_json::Value>,
1006    /// Correlation keys a composite consumed, cleared on release.
1007    settled_keys: Vec<String>,
1008    op_cursor: Option<i64>,
1009    draining: bool,
1010    seeded: bool,
1011    skipped_locked: bool,
1012}
1013
1014enum StartOutcome {
1015    Started,
1016    /// Recorded but not executed — no runtime is wired in.
1017    Ingested,
1018    Duplicate,
1019    Failed(String),
1020}
1021
1022/// The run id for one firing of one item.
1023///
1024/// Derived from `(trigger, connector, dedup value)`, so the same item seen
1025/// twice — connector replay, overlapping cursors, two nodes racing a lease
1026/// boundary — produces the same id. `areev run start` refuses a duplicate, so
1027/// the second attempt is a recorded skip rather than a second run. At-most-once
1028/// firing with no lease, no fencing token, and no clock assumption.
1029///
1030/// The connector is part of the identity because CloudEvents made that
1031/// normative for the same reason: `source` + `id` is unique, `id` alone is only
1032/// unique within a producer. Two connectors sharing an id space must not
1033/// collide.
1034pub fn run_id_for(trigger_hash: &str, connector: Option<&str>, dedup_value: &str) -> String {
1035    let mut h = Sha256::new();
1036    h.update(b"areev-trigger:v1");
1037    h.update([0x1f]);
1038    h.update(connector.unwrap_or_default().as_bytes());
1039    h.update([0x1f]);
1040    h.update(dedup_value.as_bytes());
1041    let digest = hex(&h.finalize());
1042    format!("{}-{}", short(trigger_hash, 8), short(&digest, 12))
1043}
1044
1045fn hex(bytes: &[u8]) -> String {
1046    bytes.iter().map(|b| format!("{b:02x}")).collect()
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052
1053    #[test]
1054    fn a_run_id_is_stable_for_the_same_item() {
1055        let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1056        let b = run_id_for("abcdef1234", Some("gmail"), "m1");
1057        assert_eq!(a, b, "the same item must always mint the same id");
1058    }
1059
1060    #[test]
1061    fn different_items_get_different_ids() {
1062        let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1063        let b = run_id_for("abcdef1234", Some("gmail"), "m2");
1064        assert_ne!(a, b);
1065    }
1066
1067    #[test]
1068    fn the_connector_is_part_of_the_identity() {
1069        // CloudEvents' rule: `id` alone is unique only within a producer, so two
1070        // connectors with a shared id space must not collide.
1071        let a = run_id_for("abcdef1234", Some("gmail"), "1");
1072        let b = run_id_for("abcdef1234", Some("stripe"), "1");
1073        assert_ne!(a, b);
1074    }
1075
1076    #[test]
1077    fn the_id_names_its_trigger_for_a_human_reading_a_run_list() {
1078        let id = run_id_for("abcdef1234567890", Some("gmail"), "m1");
1079        assert!(id.starts_with("abcdef12"), "{id}");
1080        assert!(id.len() < 32, "short enough to read: {id}");
1081    }
1082
1083    #[test]
1084    fn a_short_trigger_hash_does_not_panic() {
1085        let id = run_id_for("ab", None, "x");
1086        assert!(id.starts_with("ab-"));
1087    }
1088}