1use std::sync::Arc;
22
23use areev_cal::AreevFacade;
24use areev_core::types::{ContentRef, 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::{
32 dedup_value, rewrite_blob_refs, PollItem, PollRequest, PollResponse,
33 MAX_BLOB_BYTES_PER_ITEM, MAX_BLOB_BYTES_PER_RESPONSE,
34};
35use crate::error::{Result, TriggerError};
36use crate::schedule;
37
38pub const TRIGGER_NS: &str = "agent:triggers";
41
42const MAX_TRIGGERS_SCANNED: usize = 4096;
44
45#[derive(Debug, Clone)]
47pub struct EvalOptions {
48 pub only: Option<String>,
50 pub lease: std::time::Duration,
54 pub max_items: usize,
56 pub dry_run: bool,
59 pub node: String,
61 pub max_blob_bytes_per_item: usize,
64 pub max_blob_bytes_per_response: usize,
66}
67
68impl Default for EvalOptions {
69 fn default() -> Self {
70 EvalOptions {
71 only: None,
72 lease: std::time::Duration::from_secs(300),
73 max_items: 100,
74 dry_run: false,
75 node: default_node_id(),
76 max_blob_bytes_per_item: MAX_BLOB_BYTES_PER_ITEM,
77 max_blob_bytes_per_response: MAX_BLOB_BYTES_PER_RESPONSE,
78 }
79 }
80}
81
82fn short(s: &str, n: usize) -> &str {
87 match s.char_indices().nth(n) {
88 Some((idx, _)) => &s[..idx],
89 None => s,
90 }
91}
92
93fn default_node_id() -> String {
97 let host = std::env::var("HOSTNAME")
98 .or_else(|_| std::env::var("COMPUTERNAME"))
99 .unwrap_or_else(|_| "node".into());
100 format!("{host}/{}", std::process::id())
101}
102
103#[derive(Debug, Clone, Default, Serialize)]
105pub struct EvalReport {
106 pub claimed: usize,
107 pub skipped_not_due: usize,
108 pub skipped_locked: usize,
109 pub skipped_paused: usize,
110 pub items: usize,
111 pub duplicates: usize,
112 pub runs_started: usize,
113 pub ingested: usize,
117 pub unidentifiable: usize,
121 pub unusable: usize,
130 pub errors: Vec<String>,
132 pub draining: Vec<String>,
135}
136
137#[derive(Debug, Clone, Serialize)]
139pub struct TriggerStatus {
140 pub trigger: String,
141 pub kind: String,
142 pub workflow: String,
143 pub enabled: bool,
144 pub paused: bool,
145 pub due: bool,
146 pub leased_by: Option<String>,
147 pub next_due_at: Option<i64>,
148 pub last_fired_at: Option<i64>,
149 pub consecutive_failures: u32,
150 pub last_error: Option<String>,
151 pub exhausted: bool,
153 pub never_fired: bool,
156 #[serde(skip_serializing_if = "Option::is_none")]
163 pub unusable: Option<String>,
164}
165
166pub trait RunStarter: Send + Sync {
174 fn start(&self, workflow: &str, run_id: &str, input: serde_json::Value) -> StartResult;
175}
176
177#[derive(Debug, Clone, PartialEq)]
179pub enum StartResult {
180 Started,
181 Duplicate,
185 Failed(String),
186}
187
188pub struct Evaluator {
190 pub facade: Arc<AreevFacade>,
191 pub clock: Arc<dyn Clock>,
192 pub connector: Option<Arc<dyn HostToolExecutor>>,
196 pub starter: Option<Arc<dyn RunStarter>>,
200 pub credentials: std::collections::BTreeMap<String, areev_run::Credential>,
204 pub ns: String,
205 pub principal: String,
206}
207
208impl Evaluator {
209 pub fn read_only(facade: Arc<AreevFacade>, clock: Arc<dyn Clock>, ns: &str) -> Evaluator {
214 Evaluator {
215 facade,
216 clock,
217 connector: None,
218 starter: None,
219 credentials: Default::default(),
220 ns: ns.to_string(),
221 principal: "user:local".into(),
222 }
223 }
224
225 pub fn declarations(&self) -> Result<Vec<(String, Trigger)>> {
227 let ns = self.ns.clone();
228 let grains = self
229 .facade
230 .with_store(|m| {
231 m.recent_live_scoped(&[ns], Some(GrainType::Trigger), MAX_TRIGGERS_SCANNED)
232 })
233 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
234
235 let mut out = Vec::new();
236 for g in grains {
237 let hash = g.hash.to_hex();
238 match g.to_trigger() {
239 Ok(t) => out.push((hash, t)),
240 Err(e) => {
244 return Err(TriggerError::Malformed {
245 what: format!("declaration {hash} is unreadable: {e}"),
246 })
247 }
248 }
249 }
250 Ok(out)
251 }
252
253 pub fn status(&self) -> Result<Vec<TriggerStatus>> {
255 let now = self.clock.now_ms();
256 let mut out = Vec::new();
257 for (hash, t) in self.declarations()? {
258 let st = self
259 .facade
260 .with_store(|m| m.trigger_state(&hash))
261 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
262 .map(|(s, _)| s)
263 .unwrap_or_default();
264 let unusable = schedule::validate(&t).err().map(|e| e.to_string());
265 out.push(TriggerStatus {
266 trigger: hash,
267 kind: t.kind.as_str().to_string(),
268 workflow: t.workflow.clone(),
269 enabled: t.enabled,
270 paused: st.paused,
271 due: unusable.is_none()
274 && t.enabled
275 && !st.leased(now)
276 && schedule::is_due(&t, &st, now).unwrap_or(false),
277 leased_by: st.leased(now).then(|| st.claimed_by.clone()).flatten(),
278 next_due_at: st.next_due_at,
279 last_fired_at: st.last_fired_at,
280 consecutive_failures: st.consecutive_failures,
281 last_error: st.last_error.clone(),
282 exhausted: st.exhausted,
283 never_fired: st.last_fired_at.is_none() && st.consecutive_failures == 0,
284 unusable,
285 });
286 }
287 Ok(out)
288 }
289
290 pub fn deliver(&self, trigger_hash: &str, payload: serde_json::Value) -> Result<EvalReport> {
302 let now = self.clock.now_ms();
303 let (hash, trigger) = self
304 .declarations()?
305 .into_iter()
306 .find(|(h, _)| h.starts_with(trigger_hash))
307 .ok_or_else(|| TriggerError::Malformed {
308 what: format!("no trigger matching '{trigger_hash}'"),
309 })?;
310
311 if !matches!(trigger.kind, TriggerKind::Webhook | TriggerKind::Manual) {
312 return Err(TriggerError::Malformed {
313 what: format!(
314 "trigger {} is a {} trigger — only webhook and manual triggers accept a \
315 delivery; a clocked or polling trigger fires from `trigger run`",
316 short(&hash, 12),
317 trigger.kind.as_str()
318 ),
319 });
320 }
321 if !trigger.enabled {
322 return Err(TriggerError::Malformed {
323 what: format!("trigger {} is disabled", short(&hash, 12)),
324 });
325 }
326 let (state, _) = self
327 .facade
328 .with_store(|m| m.trigger_state(&hash))
329 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
330 .unwrap_or_default();
331 if state.paused {
332 return Err(TriggerError::Malformed {
333 what: format!("trigger {} is paused", short(&hash, 12)),
334 });
335 }
336
337 let mut report = EvalReport::default();
338 let item = PollItem { id: String::new(), payload, blobs: Vec::new() };
339 report.items = 1;
340 let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
341 report.unidentifiable = 1;
342 self.journal(
347 &hash,
348 &trigger,
349 &FireOutcome { items: 1, unidentifiable: 1, ..Default::default() },
350 Some("delivered"),
351 now,
352 )?;
353 return Ok(report);
354 };
355 let run_id = run_id_for(&hash, trigger.connector.as_deref(), &value);
356 match self.start_run(&trigger, &run_id, &item, &hash, &[]) {
357 StartOutcome::Started => report.runs_started = 1,
358 StartOutcome::Ingested => report.ingested = 1,
359 StartOutcome::Duplicate => report.duplicates = 1,
360 StartOutcome::Failed(why) => report.errors.push(why),
361 }
362
363 let outcome = FireOutcome {
364 items: 1,
365 runs_started: report.runs_started,
366 duplicates: report.duplicates,
367 ..Default::default()
368 };
369 self.journal(&hash, &trigger, &outcome, Some("delivered"), now)?;
370 Ok(report)
371 }
372
373 pub fn run(&self, opts: &EvalOptions) -> Result<EvalReport> {
375 let mut report = EvalReport::default();
376 let now = self.clock.now_ms();
377
378 let declarations = self.declarations()?;
379 let (composites, members): (Vec<_>, Vec<_>) =
384 declarations.into_iter().partition(|(_, t)| t.kind == TriggerKind::Composite);
385
386 for (hash, trigger) in members.into_iter().chain(composites) {
387 if opts.only.as_ref().is_some_and(|only| only != &hash) {
388 continue;
389 }
390 if !trigger.enabled {
391 report.skipped_not_due += 1;
392 continue;
393 }
394 if let Err(why) = schedule::validate(&trigger) {
398 report.unusable += 1;
399 report.errors.push(format!("{}: {why}", short(&hash, 12)));
400 continue;
401 }
402 let stored = self
403 .facade
404 .with_store(|m| m.trigger_state(&hash))
405 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
406 let (state, raw) = match stored {
407 Some((s, r)) => (s, Some(r)),
408 None => (TriggerState::default(), None),
409 };
410
411 if state.paused {
412 report.skipped_paused += 1;
413 continue;
414 }
415 if state.next_due_at.is_none() && !state.exhausted {
420 if let Some(first) = schedule::initial_due(&trigger, now)? {
421 let mut seeded = state.clone();
422 seeded.next_due_at = Some(first);
423 let _ = self
424 .facade
425 .with_store(|m| m.put_trigger_state(&hash, raw.as_deref(), &seeded))
426 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
427 report.skipped_not_due += 1;
428 continue;
429 }
430 }
431 if !schedule::is_due(&trigger, &state, now)? {
432 report.skipped_not_due += 1;
433 continue;
434 }
435 if state.leased(now) {
436 report.skipped_locked += 1;
439 continue;
440 }
441 if opts.dry_run {
442 report.claimed += 1;
443 continue;
444 }
445
446 match self.fire(&hash, &trigger, state, raw.as_deref(), now, opts) {
447 Ok(outcome) if outcome.skipped_locked => report.skipped_locked += 1,
451 Ok(outcome) => {
452 if !outcome.fired_items.is_empty() {
453 if let Err(e) = self.record_for_composites(&hash, &outcome.fired_items, now)
454 {
455 report.errors.push(e.to_string());
456 }
457 }
458 report.claimed += 1;
459 report.items += outcome.items;
460 report.duplicates += outcome.duplicates;
461 report.runs_started += outcome.runs_started;
462 report.ingested += outcome.ingested;
463 report.unidentifiable += outcome.unidentifiable;
464 for why in &outcome.failures {
465 report.errors.push(format!("{hash}: {why}"));
466 }
467 if outcome.draining {
468 report.draining.push(hash.clone());
469 }
470 }
471 Err(e) => {
472 report.errors.push(e.to_string());
474 }
475 }
476 }
477 Ok(report)
478 }
479
480 fn fire(
481 &self,
482 hash: &str,
483 trigger: &Trigger,
484 state: TriggerState,
485 raw: Option<&str>,
486 now: i64,
487 opts: &EvalOptions,
488 ) -> Result<FireOutcome> {
489 let mut claimed = state.clone();
491 claimed.fence = claimed.fence.wrapping_add(1);
492 claimed.claimed_by = Some(opts.node.clone());
493 claimed.lease_until = Some(now + opts.lease.as_millis() as i64);
494 let won = self
495 .facade
496 .with_store(|m| m.put_trigger_state(hash, raw, &claimed))
497 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
498 if !won {
499 return Ok(FireOutcome { skipped_locked: true, ..Default::default() });
500 }
501 let claimed_raw = self
503 .facade
504 .with_store(|m| m.trigger_state(hash))
505 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
506 .map(|(_, r)| r);
507
508 let result = self.collect_and_start(hash, trigger, &claimed, now, opts);
509
510 let mut released = claimed.clone();
512 released.claimed_by = None;
513 released.lease_until = None;
514 match &result {
515 Ok(outcome) => {
516 released.last_fired_at = Some(now);
517 released.consecutive_failures = 0;
518 released.last_error = None;
519 if let Some(c) = &outcome.cursor {
520 released.cursor = Some(c.clone());
521 }
522 if let Some(c) = outcome.op_cursor {
523 released.op_cursor = Some(c);
524 }
525 for k in &outcome.settled_keys {
531 released.partials.remove(k);
532 }
533 prune_partials(&mut released, trigger, now);
534 released.exhausted = schedule::exhausts_after(trigger, now);
535 released.next_due_at = if outcome.draining {
536 Some(now)
539 } else {
540 schedule::advance_after_firing(trigger, now, now)?
541 };
542 }
543 Err(e) => {
544 released.consecutive_failures = claimed.consecutive_failures.saturating_add(1);
545 released.last_error = Some(e.to_string());
546 released.next_due_at =
547 Some(schedule::backoff_until(trigger, released.consecutive_failures, now));
548 }
549 }
550 let released_ok = self
551 .facade
552 .with_store(|m| m.put_trigger_state(hash, claimed_raw.as_deref(), &released))
553 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
554 if !released_ok {
555 self.journal(hash, trigger, &FireOutcome::default(), Some("claim lost"), now)?;
560 return Err(TriggerError::ClaimLost { trigger: hash.to_string() });
561 }
562
563 let outcome = result?;
564 self.journal(hash, trigger, &outcome, None, now)?;
565 Ok(outcome)
566 }
567
568 fn collect_and_start(
570 &self,
571 hash: &str,
572 trigger: &Trigger,
573 state: &TriggerState,
574 now: i64,
575 opts: &EvalOptions,
576 ) -> Result<FireOutcome> {
577 let mut outcome = FireOutcome::default();
578
579 let items: Vec<PollItem> = match trigger.kind {
580 TriggerKind::Polling => {
581 let response = self.poll(hash, trigger, state, opts)?;
582 outcome.cursor = response.cursor.clone();
583 outcome.draining = response.more;
584 if state.cursor.is_none() {
588 outcome.seeded = true;
589 return Ok(outcome);
590 }
591 response.items
592 }
593 TriggerKind::Interval | TriggerKind::Schedule | TriggerKind::Once => {
596 vec![PollItem {
597 id: now.to_string(),
598 payload: serde_json::json!({ "at_ms": now }),
599 blobs: Vec::new(),
600 }]
601 }
602 TriggerKind::Memory => {
603 outcome.seeded = state.op_cursor.is_none();
606 let (found, cursor) = self.scan_memory(hash, trigger, state, opts)?;
607 outcome.op_cursor = Some(cursor);
608 found
609 }
610 TriggerKind::Composite => {
611 let keys = self.satisfied_keys(hash, trigger, now)?;
612 outcome.settled_keys = keys.clone();
613 keys.into_iter()
614 .map(|k| PollItem {
615 id: k.clone(),
618 payload: serde_json::json!({ "correlation": k }),
619 blobs: Vec::new(),
620 })
621 .collect()
622 }
623 TriggerKind::Webhook | TriggerKind::Manual => Vec::new(),
627 };
628
629 let mut items = items;
637 let item_refs = self.ingest_item_blobs(hash, &mut items, opts)?;
638
639 outcome.items = items.len();
640 for (item, refs) in items.into_iter().zip(item_refs) {
641 let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
642 outcome.unidentifiable += 1;
643 continue;
644 };
645 outcome.fired_items.push(item.payload.clone());
646 let run_id = run_id_for(hash, trigger.connector.as_deref(), &value);
647 match self.start_run(trigger, &run_id, &item, hash, &refs) {
648 StartOutcome::Started => outcome.runs_started += 1,
649 StartOutcome::Ingested => outcome.ingested += 1,
650 StartOutcome::Duplicate => outcome.duplicates += 1,
651 StartOutcome::Failed(why) => outcome.failures.push(why),
652 }
653 }
654 Ok(outcome)
655 }
656
657 fn record_for_composites(
664 &self,
665 member: &str,
666 items: &[serde_json::Value],
667 now: i64,
668 ) -> Result<()> {
669 for (chash, composite) in self.declarations()? {
670 let Some(alias) = composite.member_alias(member).map(str::to_string) else {
671 continue;
672 };
673 if composite.kind != TriggerKind::Composite {
674 continue;
675 }
676 let (mut state, raw) = self
677 .facade
678 .with_store(|m| m.trigger_state(&chash))
679 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
680 .map(|(s, r)| (s, Some(r)))
681 .unwrap_or_default();
682
683 for item in items {
684 let key = match &composite.correlate {
687 Some(ptr) => match item.pointer(ptr) {
688 Some(serde_json::Value::String(s)) => s.clone(),
689 Some(other) => other.to_string(),
690 None => continue,
694 },
695 None => String::new(),
696 };
697 let entry = state.partials.entry(key).or_insert_with(|| {
698 areev_store::PartialMatch { members: Default::default(), started_ms: now }
699 });
700 entry.members.insert(alias.clone(), now);
703 }
704 prune_partials(&mut state, &composite, now);
705 self.facade
706 .with_store(|m| m.put_trigger_state(&chash, raw.as_deref(), &state))
707 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
708 }
709 Ok(())
710 }
711
712 fn satisfied_keys(&self, hash: &str, trigger: &Trigger, now: i64) -> Result<Vec<String>> {
714 let predicate = trigger
715 .predicate
716 .as_ref()
717 .ok_or_else(|| TriggerError::Malformed {
718 what: format!("composite trigger {hash} has no predicate"),
719 })
720 .and_then(crate::predicate::from_value)?;
721
722 for referenced in crate::predicate::referenced_members(&predicate) {
725 if !trigger.members.contains_key(&referenced) {
726 return Err(TriggerError::UnknownMember {
727 trigger: hash.to_string(),
728 member: referenced,
729 });
730 }
731 }
732
733 let (state, _) = self
734 .facade
735 .with_store(|m| m.trigger_state(hash))
736 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
737 .unwrap_or_default();
738
739 let mut out = Vec::new();
740 for (key, partial) in &state.partials {
741 if let Some(w) = trigger.window_ms {
742 if now - partial.started_ms > w {
743 continue;
744 }
745 }
746 let fired: Vec<String> = partial.members.keys().cloned().collect();
747 if crate::predicate::gate_satisfied(&predicate, &fired) {
748 out.push(key.clone());
749 }
750 }
751 Ok(out)
752 }
753
754 fn scan_memory(
765 &self,
766 hash: &str,
767 trigger: &Trigger,
768 state: &TriggerState,
769 opts: &EvalOptions,
770 ) -> Result<(Vec<PollItem>, i64)> {
771 let predicate = trigger
772 .predicate
773 .as_ref()
774 .ok_or_else(|| TriggerError::Malformed {
775 what: format!("memory trigger {hash} has no predicate"),
776 })
777 .and_then(crate::predicate::from_value)?;
778
779 let Some(after) = state.op_cursor else {
784 let head = self
785 .facade
786 .with_store(|m| m.head_op_seq())
787 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
788 return Ok((Vec::new(), head));
789 };
790 let ops = self
791 .facade
792 .with_store(|m| m.changes_since(after, opts.max_items))
793 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
794
795 let mut items = Vec::new();
796 let mut cursor = after;
797 for op in ops {
798 cursor = cursor.max(op.op_seq);
799 if op.op == areev_store::OP_FORGET {
802 continue;
803 }
804 let Ok(grain) = self.facade.with_store(|m| m.get(&op.hash)) else {
805 continue;
809 };
810 if !crate::predicate::grain_matches(&grain, &predicate) {
811 continue;
812 }
813 let hex = op.hash.to_hex();
814 items.push(PollItem {
815 id: hex.clone(),
819 payload: serde_json::json!({
820 "hash": hex,
821 "grain_type": grain.grain_type.as_str(),
822 "op_seq": op.op_seq,
823 }),
824 blobs: Vec::new(),
825 });
826 }
827 Ok((items, cursor))
828 }
829
830 fn ingest_item_blobs(
838 &self,
839 hash: &str,
840 items: &mut [PollItem],
841 opts: &EvalOptions,
842 ) -> Result<Vec<Vec<ContentRef>>> {
843 let violation = |detail: String| TriggerError::BlobContract {
844 trigger: short(hash, 12).to_string(),
845 detail,
846 };
847 let mut response_total = 0usize;
848 let mut all_refs = Vec::with_capacity(items.len());
849 for (idx, item) in items.iter_mut().enumerate() {
850 if item.blobs.is_empty() {
851 all_refs.push(Vec::new());
852 continue;
853 }
854 let mut item_total = 0usize;
855 let mut refs = Vec::with_capacity(item.blobs.len());
856 let mut uris = Vec::with_capacity(item.blobs.len());
857 for (bi, blob) in item.blobs.iter().enumerate() {
858 let name = blob.filename.as_deref().unwrap_or("unnamed");
859 let bytes = areev_core::b64::decode(&blob.b64).ok_or_else(|| {
860 violation(format!("item {idx} blob {bi} ({name}) is not valid base64"))
861 })?;
862 item_total += bytes.len();
863 response_total += bytes.len();
864 if item_total > opts.max_blob_bytes_per_item {
865 return Err(violation(format!(
866 "item {idx} blobs exceed the per-item budget ({} bytes decoded)",
867 opts.max_blob_bytes_per_item
868 )));
869 }
870 if response_total > opts.max_blob_bytes_per_response {
871 return Err(violation(format!(
872 "response blobs exceed the per-response budget ({} bytes decoded)",
873 opts.max_blob_bytes_per_response
874 )));
875 }
876 let uri = self
877 .facade
878 .with_store(|m| m.put_blob(&bytes))
879 .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
880 refs.push(ContentRef {
881 uri: uri.clone(),
882 modality: None,
883 mime_type: blob.mime.clone(),
884 size_bytes: Some(bytes.len() as u64),
885 checksum: Some(uri.trim_start_matches("cas://").to_string()),
886 metadata: blob.filename.as_ref().map(|f| serde_json::json!({ "filename": f })),
887 });
888 uris.push(uri);
889 }
890 rewrite_blob_refs(&mut item.payload, &uris)
891 .map_err(|d| violation(format!("item {idx}: {d}")))?;
892 item.blobs.clear();
895 all_refs.push(refs);
896 }
897 Ok(all_refs)
898 }
899
900 fn poll(
901 &self,
902 hash: &str,
903 trigger: &Trigger,
904 state: &TriggerState,
905 opts: &EvalOptions,
906 ) -> Result<PollResponse> {
907 let connector_name = trigger.connector.as_deref().unwrap_or_default();
908 let Some(exec) = &self.connector else {
909 return Err(TriggerError::NoConnector {
910 trigger: hash.to_string(),
911 connector: connector_name.to_string(),
912 });
913 };
914 let policy = areev_run::EgressPolicy::from_config(trigger.config.as_ref())
919 .map_err(|what| TriggerError::Malformed { what })?;
920 let grants = areev_run::EgressGrants::new().default_for_all(
925 self.credentials.keys().fold(
926 areev_run::CallerGrant::new()
927 .method("GET")
928 .method("POST")
929 .method("PUT")
930 .method("PATCH")
931 .method("DELETE"),
932 |g, name| g.credential(name),
933 ),
934 );
935 let broker = areev_run::Broker::start(
936 policy,
937 self.credentials.clone(),
938 grants,
939 "TRG-E009",
940 )
941 .map_err(|detail| TriggerError::Storage { detail })?;
942
943 let request = PollRequest {
944 trigger: hash,
945 connector: connector_name,
946 scope: trigger.scope.as_deref(),
947 cursor: state.cursor.as_deref(),
948 max_items: opts.max_items,
949 config: trigger.config.as_ref(),
950 };
951 let payload = serde_json::to_value(&request).map_err(|e| TriggerError::ConnectorFailed {
952 trigger: hash.to_string(),
953 detail: format!("serialize request: {e}"),
954 })?;
955 let idem = format!("{hash}:{}", state.fence);
958 static EGRESS_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
970 let result = {
971 let _guard = EGRESS_ENV.lock().unwrap_or_else(|e| e.into_inner());
972 std::env::set_var("AREEV_EGRESS_URL", broker.url());
973 if let Some(t) = broker.token_for(connector_name) {
976 std::env::set_var("AREEV_EGRESS_TOKEN", t);
977 }
978 let r = exec.execute(connector_name, hash, &payload, &idem);
979 std::env::remove_var("AREEV_EGRESS_URL");
980 std::env::remove_var("AREEV_EGRESS_TOKEN");
981 r
982 };
983
984 let refused = broker.refusals();
988 if !refused.is_empty() {
989 return Err(TriggerError::EgressRefused {
990 trigger: hash.to_string(),
991 host: refused
992 .iter()
993 .map(|r| format!("{} ({})", r.destination, r.reason))
994 .collect::<Vec<_>>()
995 .join(", "),
996 });
997 }
998 match result {
999 areev_run::ExecResult::Ok(v) => {
1000 serde_json::from_value(v).map_err(|e| TriggerError::ConnectorFailed {
1001 trigger: hash.to_string(),
1002 detail: format!("response is not a poll response: {e}"),
1003 })
1004 }
1005 areev_run::ExecResult::Err { detail, .. } => {
1006 Err(TriggerError::ConnectorFailed { trigger: hash.to_string(), detail })
1007 }
1008 }
1009 }
1010
1011 fn start_run(
1013 &self,
1014 trigger: &Trigger,
1015 run_id: &str,
1016 item: &PollItem,
1017 trigger_hash: &str,
1018 content_refs: &[ContentRef],
1019 ) -> StartOutcome {
1020 let seen = self
1026 .facade
1027 .with_store(|m| m.run_grains(&self.ns, run_id, 0, 1))
1028 .map(|g| !g.is_empty())
1029 .unwrap_or(false);
1030 if seen {
1031 return StartOutcome::Duplicate;
1032 }
1033
1034 let mut event = Event::new(&item.payload.to_string())
1035 .namespace(&self.ns)
1036 .extra_field("trigger", serde_json::json!(trigger_hash))
1037 .extra_field("run_id", serde_json::json!(run_id));
1038 if let Some(c) = &trigger.connector {
1039 event = event.extra_field("connector", serde_json::json!(c));
1040 }
1041 for cr in content_refs {
1046 event = event.content_ref(cr.clone());
1047 }
1048 if let Err(e) = self.facade.with_store(|m| m.add(&event)) {
1049 return StartOutcome::Failed(format!("ingest: {e}"));
1050 }
1051 let Some(starter) = &self.starter else {
1052 return StartOutcome::Ingested;
1056 };
1057 let mut input = serde_json::json!({
1058 "trigger": trigger_hash,
1059 "connector": trigger.connector,
1060 "scope": trigger.scope,
1061 "item": item.payload,
1062 });
1063 if let Some(spec) = &trigger.context_query {
1071 match self.assemble_context(spec, &item.payload) {
1072 Ok(ctx) => {
1073 input["context"] = ctx;
1074 }
1075 Err(why) => {
1076 return StartOutcome::Failed(format!("context_query \"{spec}\": {why}"));
1077 }
1078 }
1079 }
1080 match starter.start(&trigger.workflow, run_id, input) {
1081 StartResult::Started => StartOutcome::Started,
1082 StartResult::Duplicate => StartOutcome::Duplicate,
1083 StartResult::Failed(why) => StartOutcome::Failed(why),
1084 }
1085 }
1086
1087 fn assemble_context(
1101 &self,
1102 spec: &str,
1103 payload: &serde_json::Value,
1104 ) -> std::result::Result<serde_json::Value, String> {
1105 let spec = crate::context::parse_context_query(spec)?;
1106 let mut bindings: Vec<(String, areev_cal::ast::Value)> = Vec::new();
1107 for (param, pointer) in &spec.bindings {
1108 let v = payload.pointer(pointer).ok_or_else(|| {
1109 format!("parameter ${param}: pointer {pointer} does not resolve against the firing item")
1110 })?;
1111 let value = match v {
1112 serde_json::Value::String(s) => {
1113 areev_cal::ast::Value::String { value: s.clone() }
1114 }
1115 serde_json::Value::Number(n) => areev_cal::ast::Value::Number {
1116 value: n.as_f64().ok_or_else(|| {
1117 format!("parameter ${param}: {pointer} is not a representable number")
1118 })?,
1119 },
1120 serde_json::Value::Bool(b) => areev_cal::ast::Value::Boolean { value: *b },
1121 other => {
1122 return Err(format!(
1123 "parameter ${param}: pointer {pointer} must land on a scalar, found {}",
1124 match other {
1125 serde_json::Value::Null => "null",
1126 serde_json::Value::Array(_) => "an array",
1127 _ => "an object",
1128 }
1129 ))
1130 }
1131 };
1132 bindings.push((param.clone(), value));
1133 }
1134 let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig {
1135 allow_destructive_ops: false,
1136 ..areev_cal::CalExecutorConfig::default()
1137 });
1138 let query = areev_cal::ast::CalQuery {
1139 version: Default::default(),
1140 statement: areev_cal::ast::CalStatement::RunQuery(areev_cal::ast::RunQueryStmt {
1141 name: spec.name.clone(),
1142 bindings,
1143 span: None,
1144 }),
1145 pipeline: Vec::new(),
1146 with_options: Vec::new(),
1147 format: None,
1148 let_bindings: Vec::new(),
1149 user_vars: Default::default(),
1150 let_values: Default::default(),
1151 warnings: Vec::new(),
1152 };
1153 let res = ex
1154 .execute_parsed(query, &format!("RUN \"{}\"", spec.name), self.facade.as_ref())
1155 .map_err(|e| e.to_string())?;
1156 serde_json::to_value(&res.result).map_err(|e| e.to_string())
1157 }
1158
1159 fn journal(
1162 &self,
1163 hash: &str,
1164 trigger: &Trigger,
1165 outcome: &FireOutcome,
1166 note: Option<&str>,
1167 now: i64,
1168 ) -> Result<()> {
1169 let mut obs = Observation::new("areev:trigger", "trigger:firing")
1170 .namespace(TRIGGER_NS)
1171 .created_at(now)
1172 .extra_field("trigger", serde_json::json!(hash))
1173 .extra_field("kind", serde_json::json!(trigger.kind.as_str()))
1174 .extra_field("workflow", serde_json::json!(trigger.workflow))
1175 .extra_field("items", serde_json::json!(outcome.items))
1176 .extra_field("runs_started", serde_json::json!(outcome.runs_started))
1177 .extra_field("duplicates", serde_json::json!(outcome.duplicates));
1178 if outcome.unidentifiable > 0 {
1184 obs = obs.extra_field("unidentifiable", serde_json::json!(outcome.unidentifiable));
1185 }
1186 if outcome.ingested > 0 {
1187 obs = obs.extra_field("ingested", serde_json::json!(outcome.ingested));
1188 }
1189 if !outcome.failures.is_empty() {
1190 obs = obs.extra_field("failures", serde_json::json!(outcome.failures));
1191 }
1192 if outcome.seeded {
1193 obs = obs.extra_field("seeded", serde_json::json!(true));
1194 }
1195 if let Some(n) = note {
1196 obs = obs.extra_field("note", serde_json::json!(n));
1197 }
1198 self.facade
1199 .with_store(|m| m.add_if_novel(&obs))
1200 .map(|_| ())
1201 .map_err(|e| TriggerError::Storage { detail: e.to_string() })
1202 }
1203}
1204
1205fn prune_partials(state: &mut TriggerState, trigger: &Trigger, now: i64) {
1211 let Some(window) = trigger.window_ms else {
1212 return;
1213 };
1214 state.partials.retain(|_, p| now - p.started_ms <= window);
1215}
1216
1217#[derive(Debug, Default, Clone)]
1218struct FireOutcome {
1219 items: usize,
1220 runs_started: usize,
1221 ingested: usize,
1222 duplicates: usize,
1223 unidentifiable: usize,
1224 failures: Vec<String>,
1225 cursor: Option<String>,
1226 fired_items: Vec<serde_json::Value>,
1229 settled_keys: Vec<String>,
1231 op_cursor: Option<i64>,
1232 draining: bool,
1233 seeded: bool,
1234 skipped_locked: bool,
1235}
1236
1237enum StartOutcome {
1238 Started,
1239 Ingested,
1241 Duplicate,
1242 Failed(String),
1243}
1244
1245pub fn run_id_for(trigger_hash: &str, connector: Option<&str>, dedup_value: &str) -> String {
1258 let mut h = Sha256::new();
1259 h.update(b"areev-trigger:v1");
1260 h.update([0x1f]);
1261 h.update(connector.unwrap_or_default().as_bytes());
1262 h.update([0x1f]);
1263 h.update(dedup_value.as_bytes());
1264 let digest = hex(&h.finalize());
1265 format!("{}-{}", short(trigger_hash, 8), short(&digest, 12))
1266}
1267
1268fn hex(bytes: &[u8]) -> String {
1269 bytes.iter().map(|b| format!("{b:02x}")).collect()
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275
1276 #[test]
1277 fn a_run_id_is_stable_for_the_same_item() {
1278 let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1279 let b = run_id_for("abcdef1234", Some("gmail"), "m1");
1280 assert_eq!(a, b, "the same item must always mint the same id");
1281 }
1282
1283 #[test]
1284 fn different_items_get_different_ids() {
1285 let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1286 let b = run_id_for("abcdef1234", Some("gmail"), "m2");
1287 assert_ne!(a, b);
1288 }
1289
1290 #[test]
1291 fn the_connector_is_part_of_the_identity() {
1292 let a = run_id_for("abcdef1234", Some("gmail"), "1");
1295 let b = run_id_for("abcdef1234", Some("stripe"), "1");
1296 assert_ne!(a, b);
1297 }
1298
1299 #[test]
1300 fn the_id_names_its_trigger_for_a_human_reading_a_run_list() {
1301 let id = run_id_for("abcdef1234567890", Some("gmail"), "m1");
1302 assert!(id.starts_with("abcdef12"), "{id}");
1303 assert!(id.len() < 32, "short enough to read: {id}");
1304 }
1305
1306 #[test]
1307 fn a_short_trigger_hash_does_not_panic() {
1308 let id = run_id_for("ab", None, "x");
1309 assert!(id.starts_with("ab-"));
1310 }
1311}