1use 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
35pub const TRIGGER_NS: &str = "agent:triggers";
38
39const MAX_TRIGGERS_SCANNED: usize = 4096;
41
42#[derive(Debug, Clone)]
44pub struct EvalOptions {
45 pub only: Option<String>,
47 pub lease: std::time::Duration,
51 pub max_items: usize,
53 pub dry_run: bool,
56 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
72fn short(s: &str, n: usize) -> &str {
77 match s.char_indices().nth(n) {
78 Some((idx, _)) => &s[..idx],
79 None => s,
80 }
81}
82
83fn 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#[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 pub ingested: usize,
107 pub unidentifiable: usize,
111 pub errors: Vec<String>,
113 pub draining: Vec<String>,
116}
117
118#[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 pub exhausted: bool,
134 pub never_fired: bool,
137}
138
139pub trait RunStarter: Send + Sync {
147 fn start(&self, workflow: &str, run_id: &str, input: serde_json::Value) -> StartResult;
148}
149
150#[derive(Debug, Clone, PartialEq)]
152pub enum StartResult {
153 Started,
154 Duplicate,
158 Failed(String),
159}
160
161pub struct Evaluator {
163 pub facade: Arc<AreevFacade>,
164 pub clock: Arc<dyn Clock>,
165 pub connector: Option<Arc<dyn HostToolExecutor>>,
169 pub starter: Option<Arc<dyn RunStarter>>,
173 pub credentials: std::collections::BTreeMap<String, areev_run::Credential>,
177 pub ns: String,
178 pub principal: String,
179}
180
181impl Evaluator {
182 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if state.cursor.is_none() {
548 outcome.seeded = true;
549 return Ok(outcome);
550 }
551 response.items
552 }
553 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 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 id: k.clone(),
574 payload: serde_json::json!({ "correlation": k }),
575 })
576 .collect()
577 }
578 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 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 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 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 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 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 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 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 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 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 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 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 let policy = areev_run::EgressPolicy::from_config(trigger.config.as_ref())
793 .map_err(|what| TriggerError::Malformed { what })?;
794 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 let idem = format!("{hash}:{}", state.fence);
832 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 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 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 fn start_run(
887 &self,
888 trigger: &Trigger,
889 run_id: &str,
890 item: &PollItem,
891 trigger_hash: &str,
892 ) -> StartOutcome {
893 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 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 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 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
982fn 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 fired_items: Vec<serde_json::Value>,
1006 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 Ingested,
1018 Duplicate,
1019 Failed(String),
1020}
1021
1022pub 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 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}