1use std::collections::BTreeMap;
2use std::sync::{Arc, Mutex};
3
4use serde::{Deserialize, Serialize};
5
6use crate::plugin::PluginError;
7
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub struct TriggerEvent {
10 pub resource_type: String,
11 pub alias: String,
12 pub event: String,
13 pub payload_schema: crate::LashSchema,
14}
15
16impl TriggerEvent {
17 pub fn new(
18 resource_type: impl Into<String>,
19 alias: impl Into<String>,
20 event: impl Into<String>,
21 payload_schema: crate::LashSchema,
22 ) -> Self {
23 Self {
24 resource_type: resource_type.into(),
25 alias: alias.into(),
26 event: event.into(),
27 payload_schema,
28 }
29 }
30
31 pub fn payload_schema(&self) -> &crate::LashSchema {
32 &self.payload_schema
33 }
34
35 pub fn key(&self) -> TriggerEventKey {
36 TriggerEventKey {
37 resource_type: self.resource_type.clone(),
38 alias: self.alias.clone(),
39 event: self.event.clone(),
40 }
41 }
42
43 pub fn source_type(&self) -> String {
44 trigger_event_type(&self.alias, &self.event)
45 }
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
49pub struct TriggerEventKey {
50 pub resource_type: String,
51 pub alias: String,
52 pub event: String,
53}
54
55impl TriggerEventKey {
56 pub fn new(
57 resource_type: impl Into<String>,
58 alias: impl Into<String>,
59 event: impl Into<String>,
60 ) -> Self {
61 Self {
62 resource_type: resource_type.into(),
63 alias: alias.into(),
64 event: event.into(),
65 }
66 }
67
68 pub fn source_type(&self) -> String {
69 trigger_event_type(&self.alias, &self.event)
70 }
71}
72
73pub fn trigger_event_type(alias: &str, event: &str) -> String {
74 format!("{alias}.{event}")
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
78pub struct TriggerEventCatalog {
79 events: BTreeMap<TriggerEventKey, TriggerEvent>,
80}
81
82impl TriggerEventCatalog {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn declare(&mut self, event: TriggerEvent) -> Result<(), String> {
88 let key = event.key();
89 if self.events.contains_key(&key) {
90 return Err(format!(
91 "duplicate trigger occurrence `{}.{}.{}`",
92 key.resource_type, key.alias, key.event
93 ));
94 }
95 let source_type = event.source_type();
96 if let Some(existing) = self
97 .events
98 .values()
99 .find(|existing| existing.source_type() == source_type)
100 {
101 return Err(format!(
102 "duplicate trigger source `{source_type}` declared by `{}.{}.{}` and `{}.{}.{}`",
103 existing.resource_type,
104 existing.alias,
105 existing.event,
106 key.resource_type,
107 key.alias,
108 key.event
109 ));
110 }
111 self.events.insert(key, event);
112 Ok(())
113 }
114
115 pub fn from_events(events: impl IntoIterator<Item = TriggerEvent>) -> Result<Self, String> {
116 let mut catalog = Self::new();
117 for event in events {
118 catalog.declare(event)?;
119 }
120 Ok(catalog)
121 }
122
123 pub fn get(&self, resource_type: &str, alias: &str, event: &str) -> Option<&TriggerEvent> {
124 self.events
125 .get(&TriggerEventKey::new(resource_type, alias, event))
126 }
127
128 pub fn is_empty(&self) -> bool {
129 self.events.is_empty()
130 }
131
132 pub fn events(&self) -> impl Iterator<Item = &TriggerEvent> {
133 self.events.values()
134 }
135}
136
137#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum TriggerDeliveryEmitOutcome {
140 Started,
141 AlreadyReserved,
142 Failed { reason: String },
143}
144
145#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
146pub struct TriggerDeliveryEmitReport {
147 pub occurrence_id: String,
148 pub subscription_id: String,
149 pub process_id: String,
150 pub outcome: TriggerDeliveryEmitOutcome,
151}
152
153#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct TriggerEmitReport {
155 #[serde(default, skip_serializing_if = "String::is_empty")]
156 pub occurrence_id: String,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub deliveries: Vec<TriggerDeliveryEmitReport>,
159}
160
161impl TriggerEmitReport {
162 pub fn empty() -> Self {
163 Self::default()
164 }
165
166 fn new(occurrence_id: String, deliveries: Vec<TriggerDeliveryEmitReport>) -> Self {
167 Self {
168 occurrence_id,
169 deliveries,
170 }
171 }
172
173 pub fn started_process_ids(&self) -> Vec<String> {
174 self.deliveries
175 .iter()
176 .filter(|delivery| delivery.outcome == TriggerDeliveryEmitOutcome::Started)
177 .map(|delivery| delivery.process_id.clone())
178 .collect()
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
183pub struct TriggerOccurrenceRequest {
184 pub source_type: String,
185 pub source_key: String,
186 #[serde(default)]
187 pub payload: serde_json::Value,
188 pub idempotency_key: String,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub source: Option<serde_json::Value>,
191}
192
193impl TriggerOccurrenceRequest {
194 pub fn new(
195 source_type: impl Into<String>,
196 source_key: impl Into<String>,
197 payload: serde_json::Value,
198 idempotency_key: impl Into<String>,
199 ) -> Self {
200 Self {
201 source_type: source_type.into(),
202 source_key: source_key.into(),
203 payload,
204 idempotency_key: idempotency_key.into(),
205 source: None,
206 }
207 }
208
209 pub fn with_source(mut self, source: serde_json::Value) -> Self {
210 self.source = Some(source);
211 self
212 }
213}
214
215#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
216pub struct TriggerOccurrenceRecord {
217 pub occurrence_id: String,
218 pub source_type: String,
219 pub source_key: String,
220 #[serde(default)]
221 pub payload: serde_json::Value,
222 pub idempotency_key: String,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub source: Option<serde_json::Value>,
225 pub occurred_at_ms: u64,
226}
227
228#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct TriggerOccurrenceFilter {
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub source_type: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub source_key: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub occurred_at_start_ms: Option<u64>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub occurred_at_end_ms: Option<u64>,
238}
239
240impl TriggerOccurrenceFilter {
241 pub fn for_source(source_type: impl Into<String>, source_key: impl Into<String>) -> Self {
242 Self {
243 source_type: Some(source_type.into()),
244 source_key: Some(source_key.into()),
245 ..Self::default()
246 }
247 }
248
249 pub fn matches(&self, record: &TriggerOccurrenceRecord) -> bool {
250 self.source_type
251 .as_deref()
252 .is_none_or(|source_type| record.source_type == source_type)
253 && self
254 .source_key
255 .as_deref()
256 .is_none_or(|source_key| record.source_key == source_key)
257 && self
258 .occurred_at_start_ms
259 .is_none_or(|start_ms| record.occurred_at_ms >= start_ms)
260 && self
261 .occurred_at_end_ms
262 .is_none_or(|end_ms| record.occurred_at_ms < end_ms)
263 }
264}
265
266#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
267#[serde(transparent)]
268pub struct TriggerEventType(String);
269
270impl TriggerEventType {
271 pub fn new(value: impl Into<String>) -> Self {
272 Self(value.into())
273 }
274
275 pub fn as_str(&self) -> &str {
276 &self.0
277 }
278}
279
280impl From<String> for TriggerEventType {
281 fn from(value: String) -> Self {
282 Self::new(value)
283 }
284}
285
286impl From<&str> for TriggerEventType {
287 fn from(value: &str) -> Self {
288 Self::new(value)
289 }
290}
291
292impl AsRef<str> for TriggerEventType {
293 fn as_ref(&self) -> &str {
294 self.as_str()
295 }
296}
297
298impl std::fmt::Display for TriggerEventType {
299 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 formatter.write_str(self.as_str())
301 }
302}
303
304#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
305pub struct TriggerRegistration {
306 pub handle: String,
307 pub source_key: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub name: Option<String>,
310 pub source_type: TriggerEventType,
311 pub source: serde_json::Value,
312 pub target: TriggerTargetSummary,
313 #[serde(default = "default_enabled")]
314 pub enabled: bool,
315}
316
317#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
318pub struct TriggerTargetSummary {
319 pub label: Option<String>,
320 pub identity: crate::ProcessIdentity,
321 pub input: crate::ProcessInput,
322 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
323 pub inputs: BTreeMap<String, TriggerInputBinding>,
324}
325
326#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
327#[serde(tag = "type", rename_all = "snake_case")]
328pub enum TriggerInputBinding {
329 Event,
330 Fixed { value: serde_json::Value },
331}
332
333#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
334pub struct TriggerSubscriptionDraft {
335 pub registrant: crate::ProcessOriginator,
336 pub env_ref: crate::ProcessExecutionEnvRef,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub wake_target: Option<crate::SessionScope>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub name: Option<String>,
341 pub source_type: String,
342 pub source_key: String,
343 pub source: serde_json::Value,
344 pub payload_schema: crate::LashSchema,
345 pub target: crate::ProcessInput,
346 pub target_identity: crate::ProcessIdentity,
347 #[serde(default)]
348 pub event_types: Vec<crate::ProcessEventType>,
349 #[serde(default)]
350 pub input_template: BTreeMap<String, TriggerInputBinding>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub target_label: Option<String>,
353}
354
355impl TriggerSubscriptionDraft {
356 pub fn for_process(
357 registrant: crate::ProcessOriginator,
358 env_ref: crate::ProcessExecutionEnvRef,
359 source_type: impl Into<String>,
360 source_key: impl Into<String>,
361 target: crate::ProcessInput,
362 target_identity: crate::ProcessIdentity,
363 ) -> Self {
364 let target_label = target_identity.label.clone();
365 Self {
366 registrant,
367 env_ref,
368 wake_target: None,
369 name: None,
370 source_type: source_type.into(),
371 source_key: source_key.into(),
372 source: serde_json::Value::Object(serde_json::Map::new()),
373 payload_schema: crate::LashSchema::new(serde_json::Value::Object(
374 serde_json::Map::new(),
375 )),
376 target,
377 target_identity,
378 event_types: Vec::new(),
379 input_template: BTreeMap::new(),
380 target_label,
381 }
382 }
383
384 pub fn with_name(mut self, name: impl Into<String>) -> Self {
385 self.name = Some(name.into());
386 self
387 }
388
389 pub fn with_source(mut self, source: serde_json::Value) -> Self {
390 self.source = source;
391 self
392 }
393
394 pub fn with_payload_schema(mut self, payload_schema: crate::LashSchema) -> Self {
395 self.payload_schema = payload_schema;
396 self
397 }
398
399 pub fn with_wake_target(mut self, wake_target: crate::SessionScope) -> Self {
400 self.wake_target = Some(wake_target);
401 self
402 }
403
404 pub fn with_event_types(
405 mut self,
406 event_types: impl IntoIterator<Item = crate::ProcessEventType>,
407 ) -> Self {
408 self.event_types = event_types.into_iter().collect();
409 self
410 }
411
412 pub fn with_input_template(
413 mut self,
414 input_template: BTreeMap<String, TriggerInputBinding>,
415 ) -> Self {
416 self.input_template = input_template;
417 self
418 }
419
420 pub fn with_target_label(mut self, target_label: impl Into<String>) -> Self {
421 self.target_label = Some(target_label.into());
422 self
423 }
424
425 pub fn validate(&self) -> Result<(), PluginError> {
426 validate_trigger_subscription_target_label(
427 self.target_label.as_deref(),
428 self.target_identity.label.as_deref(),
429 )
430 }
431}
432
433#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
434pub struct TriggerSubscriptionRecord {
435 pub subscription_id: String,
436 pub registrant: crate::ProcessOriginator,
437 pub env_ref: crate::ProcessExecutionEnvRef,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub wake_target: Option<crate::SessionScope>,
440 pub handle: String,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub name: Option<String>,
443 pub source_type: String,
444 pub source_key: String,
445 pub source: serde_json::Value,
446 pub payload_schema: crate::LashSchema,
447 pub target: crate::ProcessInput,
448 pub target_identity: crate::ProcessIdentity,
449 #[serde(default)]
450 pub event_types: Vec<crate::ProcessEventType>,
451 #[serde(default)]
452 pub input_template: BTreeMap<String, TriggerInputBinding>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub target_label: Option<String>,
455 #[serde(default = "default_enabled")]
456 pub enabled: bool,
457 pub created_at_ms: u64,
458 pub updated_at_ms: u64,
459}
460
461impl TriggerSubscriptionRecord {
462 pub fn registrant_scope_id(&self) -> String {
463 self.registrant.scope_id()
464 }
465
466 pub fn registrant_session_id(&self) -> Option<&str> {
467 match &self.registrant {
468 crate::ProcessOriginator::Session { scope } => Some(scope.session_id.as_str()),
469 crate::ProcessOriginator::Host { .. } => None,
470 }
471 }
472}
473
474fn validate_trigger_subscription_target_label(
475 target_label: Option<&str>,
476 identity_label: Option<&str>,
477) -> Result<(), PluginError> {
478 match (target_label, identity_label) {
479 (Some(target_label), Some(identity_label)) if target_label != identity_label => {
480 Err(PluginError::Session(
481 "trigger target_label must match target_identity.label when both are present"
482 .to_string(),
483 ))
484 }
485 _ => Ok(()),
486 }
487}
488
489impl From<&TriggerSubscriptionRecord> for TriggerRegistration {
490 fn from(route: &TriggerSubscriptionRecord) -> Self {
491 Self {
492 handle: route.handle.clone(),
493 source_key: route.source_key.clone(),
494 name: route.name.clone(),
495 source_type: TriggerEventType::new(route.source_type.clone()),
496 source: route.source.clone(),
497 target: TriggerTargetSummary {
498 label: route.target_label.clone(),
499 identity: route.target_identity.clone(),
500 input: route.target.clone(),
501 inputs: route.input_template.clone(),
502 },
503 enabled: route.enabled,
504 }
505 }
506}
507
508#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
509pub struct TriggerSubscriptionFilter {
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub registrant_scope_id: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub session_id: Option<String>,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub handle: Option<String>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub name: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub source_type: Option<String>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub source_key: Option<String>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub target: Option<serde_json::Value>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub enabled: Option<bool>,
526}
527
528impl TriggerSubscriptionFilter {
529 pub fn for_session(session_id: impl Into<String>) -> Self {
530 Self {
531 session_id: Some(session_id.into()),
532 ..Self::default()
533 }
534 }
535
536 pub fn for_registrant_scope(scope_id: impl Into<String>) -> Self {
537 Self {
538 registrant_scope_id: Some(scope_id.into()),
539 ..Self::default()
540 }
541 }
542
543 pub fn for_source_type(source_type: impl Into<String>) -> Self {
544 Self {
545 source_type: Some(source_type.into()),
546 ..Self::default()
547 }
548 }
549
550 pub fn effective_registrant_scope_id(&self) -> Option<String> {
551 self.registrant_scope_id.clone()
552 }
553
554 pub fn matches(&self, record: &TriggerSubscriptionRecord) -> bool {
555 self.effective_registrant_scope_id()
556 .is_none_or(|scope_id| record.registrant_scope_id() == scope_id)
557 && self
558 .session_id
559 .as_deref()
560 .is_none_or(|session_id| record.registrant_session_id() == Some(session_id))
561 && self
562 .handle
563 .as_deref()
564 .is_none_or(|handle| record.handle == handle)
565 && self
566 .name
567 .as_deref()
568 .is_none_or(|name| record.name.as_deref() == Some(name))
569 && self
570 .source_type
571 .as_deref()
572 .is_none_or(|source_type| record.source_type == source_type)
573 && self
574 .source_key
575 .as_deref()
576 .is_none_or(|source_key| record.source_key == source_key)
577 && self.enabled.is_none_or(|enabled| record.enabled == enabled)
578 && self
579 .target
580 .as_ref()
581 .is_none_or(|target| record.target_identity.definition.as_ref() == Some(target))
582 }
583}
584
585#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum TriggerDeliveryReservationStatus {
588 Reserved,
589 AlreadyReserved,
590}
591
592#[derive(Clone, Debug, Serialize, Deserialize)]
593pub struct TriggerDeliveryReservation {
594 pub occurrence: TriggerOccurrenceRecord,
595 pub subscription: TriggerSubscriptionRecord,
596 pub process_id: String,
597 pub created_at_ms: u64,
598 pub reservation_status: TriggerDeliveryReservationStatus,
599}
600
601impl TriggerDeliveryReservation {
602 fn emit_report(&self, outcome: TriggerDeliveryEmitOutcome) -> TriggerDeliveryEmitReport {
603 TriggerDeliveryEmitReport {
604 occurrence_id: self.occurrence.occurrence_id.clone(),
605 subscription_id: self.subscription.subscription_id.clone(),
606 process_id: self.process_id.clone(),
607 outcome,
608 }
609 }
610}
611
612#[async_trait::async_trait]
613pub trait TriggerStore: Send + Sync {
614 fn durability_tier(&self) -> crate::DurabilityTier {
615 crate::DurabilityTier::Inline
616 }
617
618 async fn source_key_for_subscription(
619 &self,
620 source_type: &str,
621 source: &serde_json::Value,
622 ) -> Result<String, PluginError> {
623 default_trigger_source_key(source_type, source)
624 }
625
626 async fn register_subscription(
627 &self,
628 draft: TriggerSubscriptionDraft,
629 ) -> Result<TriggerSubscriptionRecord, PluginError>;
630
631 async fn list_subscriptions(
632 &self,
633 filter: TriggerSubscriptionFilter,
634 ) -> Result<Vec<TriggerSubscriptionRecord>, PluginError>;
635
636 async fn cancel_subscription(
637 &self,
638 registrant_scope_id: &str,
639 handle: &str,
640 ) -> Result<bool, PluginError>;
641
642 async fn delete_session_subscriptions(&self, session_id: &str) -> Result<usize, PluginError>;
643
644 async fn record_occurrence(
645 &self,
646 request: TriggerOccurrenceRequest,
647 ) -> Result<TriggerOccurrenceRecord, PluginError>;
648
649 async fn list_occurrences(
650 &self,
651 filter: TriggerOccurrenceFilter,
652 ) -> Result<Vec<TriggerOccurrenceRecord>, PluginError>;
653
654 async fn reserve_matching_deliveries(
655 &self,
656 occurrence_id: &str,
657 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
658
659 async fn list_deliveries_by_occurrence_id(
660 &self,
661 occurrence_id: &str,
662 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
663
664 async fn list_deliveries_by_subscription_id(
665 &self,
666 subscription_id: &str,
667 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
668
669 async fn list_deliveries_by_process_id(
670 &self,
671 process_id: &str,
672 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
673}
674
675pub struct InMemoryTriggerStore {
676 clock: Arc<dyn crate::Clock>,
677 state: Mutex<InMemoryTriggerEventState>,
678}
679
680impl InMemoryTriggerStore {
681 pub fn new() -> Self {
682 Self::with_clock(Arc::new(crate::SystemClock))
683 }
684
685 pub fn with_clock(clock: Arc<dyn crate::Clock>) -> Self {
686 Self {
687 clock,
688 state: Mutex::new(InMemoryTriggerEventState::default()),
689 }
690 }
691
692 fn list_deliveries(
693 &self,
694 matches: impl Fn(&InMemoryTriggerDeliveryRecord) -> bool,
695 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
696 let state = self
697 .state
698 .lock()
699 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
700 let mut deliveries = state
701 .deliveries
702 .values()
703 .filter(|delivery| matches(delivery))
704 .map(|delivery| {
705 in_memory_delivery_reservation(
706 &state,
707 delivery,
708 TriggerDeliveryReservationStatus::AlreadyReserved,
709 )
710 })
711 .collect::<Result<Vec<_>, _>>()?;
712 deliveries.sort_by(|left, right| {
713 left.created_at_ms
714 .cmp(&right.created_at_ms)
715 .then_with(|| {
716 left.occurrence
717 .occurrence_id
718 .cmp(&right.occurrence.occurrence_id)
719 })
720 .then_with(|| {
721 left.subscription
722 .subscription_id
723 .cmp(&right.subscription.subscription_id)
724 })
725 });
726 Ok(deliveries)
727 }
728
729 #[cfg(any(test, feature = "testing"))]
730 pub(crate) fn delete_deliveries_by_process_ids(
731 &self,
732 process_ids: &std::collections::HashSet<String>,
733 ) -> Result<usize, PluginError> {
734 if process_ids.is_empty() {
735 return Ok(0);
736 }
737 let mut state = self
738 .state
739 .lock()
740 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
741 let before = state.deliveries.len();
742 state
743 .deliveries
744 .retain(|_, delivery| !process_ids.contains(&delivery.process_id));
745 Ok(before.saturating_sub(state.deliveries.len()))
746 }
747}
748
749impl Default for InMemoryTriggerStore {
750 fn default() -> Self {
751 Self::new()
752 }
753}
754
755#[derive(Default)]
756struct InMemoryTriggerEventState {
757 next_subscription_seq: u64,
758 subscriptions: BTreeMap<String, TriggerSubscriptionRecord>,
759 occurrences: BTreeMap<String, TriggerOccurrenceRecord>,
760 occurrence_id_by_idempotency_key: BTreeMap<String, String>,
761 occurrence_hashes: BTreeMap<String, String>,
762 deliveries: BTreeMap<(String, String), InMemoryTriggerDeliveryRecord>,
763}
764
765#[derive(Clone)]
766struct InMemoryTriggerDeliveryRecord {
767 occurrence_id: String,
768 subscription_id: String,
769 process_id: String,
770 created_at_ms: u64,
771}
772
773fn in_memory_delivery_reservation(
774 state: &InMemoryTriggerEventState,
775 delivery: &InMemoryTriggerDeliveryRecord,
776 reservation_status: TriggerDeliveryReservationStatus,
777) -> Result<TriggerDeliveryReservation, PluginError> {
778 let occurrence = state
779 .occurrences
780 .get(&delivery.occurrence_id)
781 .cloned()
782 .ok_or_else(|| {
783 PluginError::Session(format!(
784 "missing trigger occurrence `{}` for delivery",
785 delivery.occurrence_id
786 ))
787 })?;
788 let subscription = state
789 .subscriptions
790 .get(&delivery.subscription_id)
791 .cloned()
792 .ok_or_else(|| {
793 PluginError::Session(format!(
794 "missing trigger subscription `{}` for delivery",
795 delivery.subscription_id
796 ))
797 })?;
798 Ok(TriggerDeliveryReservation {
799 occurrence,
800 subscription,
801 process_id: delivery.process_id.clone(),
802 created_at_ms: delivery.created_at_ms,
803 reservation_status,
804 })
805}
806
807#[async_trait::async_trait]
808impl TriggerStore for InMemoryTriggerStore {
809 async fn register_subscription(
810 &self,
811 draft: TriggerSubscriptionDraft,
812 ) -> Result<TriggerSubscriptionRecord, PluginError> {
813 draft.validate()?;
814 let mut state = self
815 .state
816 .lock()
817 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
818 state.next_subscription_seq = state.next_subscription_seq.saturating_add(1);
819 let handle = format!("trigger:{}", state.next_subscription_seq);
820 let subscription_id = format!("subscription:{}", state.next_subscription_seq);
821 let now = self.clock.timestamp_ms();
822 let record = TriggerSubscriptionRecord {
823 subscription_id: subscription_id.clone(),
824 registrant: draft.registrant,
825 env_ref: draft.env_ref,
826 wake_target: draft.wake_target,
827 handle,
828 name: draft.name,
829 source_type: draft.source_type,
830 source_key: draft.source_key,
831 source: draft.source,
832 payload_schema: draft.payload_schema,
833 target: draft.target,
834 target_identity: draft.target_identity,
835 event_types: draft.event_types,
836 input_template: draft.input_template,
837 target_label: draft.target_label,
838 enabled: true,
839 created_at_ms: now,
840 updated_at_ms: now,
841 };
842 state.subscriptions.insert(subscription_id, record.clone());
843 Ok(record)
844 }
845
846 async fn list_subscriptions(
847 &self,
848 filter: TriggerSubscriptionFilter,
849 ) -> Result<Vec<TriggerSubscriptionRecord>, PluginError> {
850 let state = self
851 .state
852 .lock()
853 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
854 let mut records = state
855 .subscriptions
856 .values()
857 .filter(|record| filter.matches(record))
858 .cloned()
859 .collect::<Vec<_>>();
860 records.sort_by(|left, right| {
861 left.registrant_scope_id()
862 .cmp(&right.registrant_scope_id())
863 .then_with(|| left.handle.cmp(&right.handle))
864 });
865 Ok(records)
866 }
867
868 async fn cancel_subscription(
869 &self,
870 registrant_scope_id: &str,
871 handle: &str,
872 ) -> Result<bool, PluginError> {
873 let mut state = self
874 .state
875 .lock()
876 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
877 let now = self.clock.timestamp_ms();
878 let Some(record) = state.subscriptions.values_mut().find(|record| {
879 record.registrant_scope_id() == registrant_scope_id && record.handle == handle
880 }) else {
881 return Ok(false);
882 };
883 let changed = record.enabled;
884 record.enabled = false;
885 record.updated_at_ms = now;
886 Ok(changed)
887 }
888
889 async fn delete_session_subscriptions(&self, session_id: &str) -> Result<usize, PluginError> {
890 let mut state = self
891 .state
892 .lock()
893 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
894 let before = state.subscriptions.len();
895 state
896 .subscriptions
897 .retain(|_, record| record.registrant_session_id() != Some(session_id));
898 Ok(before.saturating_sub(state.subscriptions.len()))
899 }
900
901 async fn record_occurrence(
902 &self,
903 request: TriggerOccurrenceRequest,
904 ) -> Result<TriggerOccurrenceRecord, PluginError> {
905 validate_trigger_occurrence_request(&request)?;
906 let mut state = self
907 .state
908 .lock()
909 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
910 let request_hash = trigger_occurrence_request_hash(&request)?;
911 if let Some(existing_id) = state
912 .occurrence_id_by_idempotency_key
913 .get(&request.idempotency_key)
914 .cloned()
915 {
916 let existing_hash = state
917 .occurrence_hashes
918 .get(&existing_id)
919 .cloned()
920 .unwrap_or_default();
921 if existing_hash != request_hash {
922 return Err(PluginError::Session(format!(
923 "trigger occurrence idempotency conflict for `{}`",
924 request.idempotency_key
925 )));
926 }
927 return state.occurrences.get(&existing_id).cloned().ok_or_else(|| {
928 PluginError::Session(format!(
929 "missing trigger occurrence `{existing_id}` for idempotency key"
930 ))
931 });
932 }
933 let occurrence_id = deterministic_occurrence_id(&request)?;
934 let record = TriggerOccurrenceRecord {
935 occurrence_id: occurrence_id.clone(),
936 source_type: request.source_type,
937 source_key: request.source_key,
938 payload: request.payload,
939 idempotency_key: request.idempotency_key.clone(),
940 source: request.source,
941 occurred_at_ms: self.clock.timestamp_ms(),
942 };
943 state
944 .occurrence_id_by_idempotency_key
945 .insert(request.idempotency_key, occurrence_id.clone());
946 state
947 .occurrence_hashes
948 .insert(occurrence_id.clone(), request_hash);
949 state.occurrences.insert(occurrence_id, record.clone());
950 Ok(record)
951 }
952
953 async fn list_occurrences(
954 &self,
955 filter: TriggerOccurrenceFilter,
956 ) -> Result<Vec<TriggerOccurrenceRecord>, PluginError> {
957 let state = self
958 .state
959 .lock()
960 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
961 let mut records = state
962 .occurrences
963 .values()
964 .filter(|record| filter.matches(record))
965 .cloned()
966 .collect::<Vec<_>>();
967 records.sort_by(|left, right| {
968 left.occurred_at_ms
969 .cmp(&right.occurred_at_ms)
970 .then_with(|| left.occurrence_id.cmp(&right.occurrence_id))
971 });
972 Ok(records)
973 }
974
975 async fn reserve_matching_deliveries(
976 &self,
977 occurrence_id: &str,
978 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
979 let mut state = self
980 .state
981 .lock()
982 .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
983 let occurrence = state
984 .occurrences
985 .get(occurrence_id)
986 .cloned()
987 .ok_or_else(|| {
988 PluginError::Session(format!("unknown trigger occurrence `{occurrence_id}`"))
989 })?;
990 let subscriptions = state
991 .subscriptions
992 .values()
993 .filter(|record| {
994 record.enabled
995 && record.source_type == occurrence.source_type
996 && record.source_key == occurrence.source_key
997 })
998 .cloned()
999 .collect::<Vec<_>>();
1000 let mut deliveries = Vec::new();
1001 for subscription in subscriptions {
1002 let process_id = deterministic_delivery_process_id(
1003 &occurrence.occurrence_id,
1004 &subscription.subscription_id,
1005 )?;
1006 let key = (
1007 occurrence.occurrence_id.clone(),
1008 subscription.subscription_id.clone(),
1009 );
1010 let (delivery, reservation_status) =
1011 if let Some(delivery) = state.deliveries.get(&key).cloned() {
1012 (delivery, TriggerDeliveryReservationStatus::AlreadyReserved)
1013 } else {
1014 let delivery = InMemoryTriggerDeliveryRecord {
1015 occurrence_id: occurrence.occurrence_id.clone(),
1016 subscription_id: subscription.subscription_id.clone(),
1017 process_id,
1018 created_at_ms: self.clock.timestamp_ms(),
1019 };
1020 state.deliveries.insert(key, delivery.clone());
1021 (delivery, TriggerDeliveryReservationStatus::Reserved)
1022 };
1023 deliveries.push(TriggerDeliveryReservation {
1024 occurrence: occurrence.clone(),
1025 subscription,
1026 process_id: delivery.process_id,
1027 created_at_ms: delivery.created_at_ms,
1028 reservation_status,
1029 });
1030 }
1031 Ok(deliveries)
1032 }
1033
1034 async fn list_deliveries_by_occurrence_id(
1035 &self,
1036 occurrence_id: &str,
1037 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
1038 self.list_deliveries(|delivery| delivery.occurrence_id == occurrence_id)
1039 }
1040
1041 async fn list_deliveries_by_subscription_id(
1042 &self,
1043 subscription_id: &str,
1044 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
1045 self.list_deliveries(|delivery| delivery.subscription_id == subscription_id)
1046 }
1047
1048 async fn list_deliveries_by_process_id(
1049 &self,
1050 process_id: &str,
1051 ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
1052 self.list_deliveries(|delivery| delivery.process_id == process_id)
1053 }
1054}
1055
1056fn default_enabled() -> bool {
1057 true
1058}
1059
1060pub fn default_trigger_source_key(
1061 source_type: &str,
1062 source: &serde_json::Value,
1063) -> Result<String, PluginError> {
1064 let digest = crate::stable_hash::stable_json_sha256_hex(&(source_type, source))
1065 .map_err(|err| PluginError::Session(format!("failed to hash trigger source key: {err}")))?;
1066 Ok(format!("source:{source_type}:sha256:{digest}"))
1067}
1068
1069pub fn empty_trigger_source_key(source_type: &str) -> Result<String, PluginError> {
1070 default_trigger_source_key(source_type, &serde_json::json!({}))
1071}
1072
1073pub fn deterministic_occurrence_id(
1074 request: &TriggerOccurrenceRequest,
1075) -> Result<String, PluginError> {
1076 let digest = crate::stable_hash::stable_json_sha256_hex(&(
1077 request.source_type.as_str(),
1078 request.source_key.as_str(),
1079 request.idempotency_key.as_str(),
1080 ))
1081 .map_err(|err| PluginError::Session(format!("failed to hash trigger occurrence: {err}")))?;
1082 Ok(format!("trigger:{digest}"))
1083}
1084
1085pub fn deterministic_delivery_process_id(
1086 occurrence_id: &str,
1087 subscription_id: &str,
1088) -> Result<String, PluginError> {
1089 let digest = crate::stable_hash::stable_json_sha256_hex(&(occurrence_id, subscription_id))
1090 .map_err(|err| PluginError::Session(format!("failed to hash trigger delivery: {err}")))?;
1091 Ok(format!("process:trigger:{digest}"))
1092}
1093
1094#[derive(Clone)]
1095pub struct TriggerRouter {
1096 store: Arc<dyn TriggerStore>,
1097 process_registry: Option<Arc<dyn crate::ProcessRegistry>>,
1098 process_work_driver: Option<crate::ProcessWorkDriver>,
1099}
1100
1101impl TriggerRouter {
1102 pub fn new(
1103 store: Arc<dyn TriggerStore>,
1104 process_registry: Option<Arc<dyn crate::ProcessRegistry>>,
1105 process_work_driver: Option<crate::ProcessWorkDriver>,
1106 ) -> Self {
1107 Self {
1108 store,
1109 process_registry,
1110 process_work_driver,
1111 }
1112 }
1113
1114 pub fn store(&self) -> Arc<dyn TriggerStore> {
1115 Arc::clone(&self.store)
1116 }
1117
1118 pub async fn emit(
1119 &self,
1120 request: TriggerOccurrenceRequest,
1121 effect_controller: &dyn crate::RuntimeEffectController,
1122 ) -> Result<TriggerEmitReport, PluginError> {
1123 let occurrence = self.store.record_occurrence(request).await?;
1124 let reservations = self
1125 .store
1126 .reserve_matching_deliveries(&occurrence.occurrence_id)
1127 .await?;
1128 let Some(process_registry) = self.process_registry.as_ref() else {
1129 let deliveries = reservations
1130 .iter()
1131 .map(|reservation| {
1132 let outcome = match reservation.reservation_status {
1133 TriggerDeliveryReservationStatus::Reserved => {
1134 TriggerDeliveryEmitOutcome::Failed {
1135 reason: "trigger delivery requires a process registry".to_string(),
1136 }
1137 }
1138 TriggerDeliveryReservationStatus::AlreadyReserved => {
1139 TriggerDeliveryEmitOutcome::AlreadyReserved
1140 }
1141 };
1142 reservation.emit_report(outcome)
1143 })
1144 .collect();
1145 return Ok(TriggerEmitReport::new(occurrence.occurrence_id, deliveries));
1146 };
1147 let mut deliveries = Vec::new();
1148 let mut started_any = false;
1149 for reservation in reservations {
1150 if reservation.reservation_status == TriggerDeliveryReservationStatus::AlreadyReserved {
1151 deliveries
1152 .push(reservation.emit_report(TriggerDeliveryEmitOutcome::AlreadyReserved));
1153 continue;
1154 }
1155 if let Err(err) = self
1156 .start_delivery(
1157 &reservation,
1158 Arc::clone(process_registry),
1159 effect_controller,
1160 )
1161 .await
1162 {
1163 deliveries.push(reservation.emit_report(TriggerDeliveryEmitOutcome::Failed {
1164 reason: err.to_string(),
1165 }));
1166 continue;
1167 }
1168 started_any = true;
1169 deliveries.push(reservation.emit_report(TriggerDeliveryEmitOutcome::Started));
1170 }
1171 if started_any && let Some(driver) = self.process_work_driver.as_ref() {
1172 driver.claim_and_run_pending("trigger_delivery").await?;
1173 }
1174 Ok(TriggerEmitReport::new(occurrence.occurrence_id, deliveries))
1175 }
1176
1177 pub(crate) async fn start_delivery(
1178 &self,
1179 reservation: &TriggerDeliveryReservation,
1180 process_registry: Arc<dyn crate::ProcessRegistry>,
1181 effect_controller: &dyn crate::RuntimeEffectController,
1182 ) -> Result<(), PluginError> {
1183 let subscription = &reservation.subscription;
1184 let occurrence = &reservation.occurrence;
1185 subscription
1186 .payload_schema
1187 .validate(&occurrence.payload)
1188 .map_err(|err| {
1189 PluginError::Session(format!(
1190 "invalid payload for trigger `{}`: {err}",
1191 subscription.handle
1192 ))
1193 })?;
1194 let args =
1195 materialize_trigger_process_args(&subscription.input_template, &occurrence.payload)?;
1196 let target = apply_trigger_inputs(subscription.target.clone(), args)?;
1197 let originator_scope_id = subscription.registrant_scope_id();
1198 let trigger_causal_ref = crate::CausalRef::TriggerOccurrence {
1199 occurrence_id: occurrence.occurrence_id.clone(),
1200 subscription_id: Some(subscription.subscription_id.clone()),
1201 };
1202 let trigger_occurrence_invocation = crate::runtime::causal::trigger_occurrence_invocation(
1203 &originator_scope_id,
1204 &occurrence.occurrence_id,
1205 );
1206 let registration = crate::ProcessRegistration::new(
1207 reservation.process_id.clone(),
1208 target.clone(),
1209 crate::RecoveryDisposition::Rerunnable,
1212 crate::ProcessProvenance::new(subscription.registrant.clone())
1213 .with_caused_by(Some(trigger_causal_ref.clone())),
1214 )
1215 .with_identity(subscription.target_identity.clone())
1216 .with_extra_event_types(subscription.event_types.clone())
1217 .with_execution_env_ref(Some(subscription.env_ref.clone()))
1218 .with_wake_target(subscription.wake_target.clone());
1219 let descriptor_kind = subscription.target_identity.kind.clone();
1220 let grant =
1221 subscription
1222 .wake_target
1223 .clone()
1224 .map(|session_scope| crate::ProcessStartGrant {
1225 session_scope,
1226 descriptor: crate::ProcessHandleDescriptor::new(
1227 Some(descriptor_kind.as_str()),
1228 subscription.target_label.as_deref(),
1229 ),
1230 });
1231 let execution_context = crate::ProcessExecutionContext::default()
1232 .with_causal_invocation(Some(trigger_occurrence_invocation));
1233 let command = crate::ProcessCommand::Start {
1234 registration,
1235 grant,
1236 execution_context: Box::new(execution_context),
1237 };
1238 let effect_id = command.effect_id();
1239 let invocation = crate::RuntimeInvocation::effect(
1240 crate::RuntimeScope::new(originator_scope_id),
1241 effect_id.clone(),
1242 crate::RuntimeEffectKind::Process,
1243 format!(
1244 "trigger:{}:{}",
1245 occurrence.occurrence_id, subscription.subscription_id
1246 ),
1247 )
1248 .with_caused_by(Some(trigger_causal_ref));
1249 let outcome = effect_controller
1250 .execute_effect(
1251 crate::RuntimeEffectEnvelope::new(
1252 invocation,
1253 crate::RuntimeEffectCommand::process(command),
1254 ),
1255 crate::RuntimeEffectLocalExecutor::processes(
1256 process_registry,
1257 self.process_work_driver.clone(),
1258 ),
1259 )
1260 .await?;
1261 match outcome {
1262 crate::RuntimeEffectOutcome::Process {
1263 result: crate::ProcessEffectOutcome::Start { .. },
1264 } => Ok(()),
1265 other => Err(PluginError::Session(format!(
1266 "trigger process start returned the wrong outcome: {}",
1267 other.kind().as_str()
1268 ))),
1269 }
1270 }
1271}
1272
1273fn materialize_trigger_process_args(
1274 input_template: &BTreeMap<String, TriggerInputBinding>,
1275 event_payload: &serde_json::Value,
1276) -> Result<serde_json::Map<String, serde_json::Value>, PluginError> {
1277 let mut args = serde_json::Map::new();
1278 for (input_name, input) in input_template {
1279 let value = match input {
1280 TriggerInputBinding::Event => event_payload.clone(),
1281 TriggerInputBinding::Fixed { value } => value.clone(),
1282 };
1283 args.insert(input_name.to_string(), value);
1284 }
1285 Ok(args)
1286}
1287
1288fn apply_trigger_inputs(
1289 mut target: crate::ProcessInput,
1290 args: serde_json::Map<String, serde_json::Value>,
1291) -> Result<crate::ProcessInput, PluginError> {
1292 match &mut target {
1293 crate::ProcessInput::Engine { payload, .. } => {
1294 let object = payload.as_object_mut().ok_or_else(|| {
1295 PluginError::Session(
1296 "trigger engine target payload must be a JSON object".to_string(),
1297 )
1298 })?;
1299 object.insert("args".to_string(), serde_json::Value::Object(args));
1300 Ok(target)
1301 }
1302 other => Err(PluginError::Session(format!(
1303 "trigger target must be an engine process, got {}",
1304 other.engine_kind()
1305 ))),
1306 }
1307}
1308
1309pub fn validate_trigger_occurrence_request(
1310 request: &TriggerOccurrenceRequest,
1311) -> Result<(), PluginError> {
1312 if request.source_type.trim().is_empty() {
1313 return Err(PluginError::Session(
1314 "trigger occurrence requires source_type".to_string(),
1315 ));
1316 }
1317 if request.source_key.trim().is_empty() {
1318 return Err(PluginError::Session(
1319 "trigger occurrence requires source_key".to_string(),
1320 ));
1321 }
1322 if request.idempotency_key.trim().is_empty() {
1323 return Err(PluginError::Session(
1324 "trigger occurrence requires idempotency_key".to_string(),
1325 ));
1326 }
1327 Ok(())
1328}
1329
1330pub fn trigger_occurrence_request_hash(
1331 request: &TriggerOccurrenceRequest,
1332) -> Result<String, PluginError> {
1333 crate::stable_hash::stable_json_sha256_hex(&(
1334 request.source_type.as_str(),
1335 request.source_key.as_str(),
1336 &request.payload,
1337 &request.source,
1338 ))
1339 .map_err(|err| PluginError::Session(format!("failed to hash trigger occurrence: {err}")))
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344 use super::*;
1345
1346 fn button_payload_schema() -> crate::LashSchema {
1347 crate::LashSchema::any()
1348 }
1349
1350 fn trigger_process_draft(source_key: &str, process_name: &str) -> TriggerSubscriptionDraft {
1351 TriggerSubscriptionDraft::for_process(
1352 crate::ProcessOriginator::host(),
1353 crate::ProcessExecutionEnvRef::new(format!("process-env:{process_name}")),
1354 "ui.button.pressed",
1355 source_key,
1356 crate::ProcessInput::Engine {
1357 kind: "test-engine".to_string(),
1358 payload: serde_json::json!({ "process": process_name }),
1359 },
1360 crate::ProcessIdentity::new("test-engine").with_label(Some(process_name)),
1361 )
1362 .with_payload_schema(crate::LashSchema::any())
1363 }
1364
1365 fn button_occurrence(
1366 source_key: impl Into<String>,
1367 idempotency_key: impl Into<String>,
1368 ) -> TriggerOccurrenceRequest {
1369 TriggerOccurrenceRequest::new(
1370 "ui.button.pressed",
1371 source_key,
1372 serde_json::json!({ "button": "Blue" }),
1373 idempotency_key,
1374 )
1375 }
1376
1377 #[test]
1378 fn trigger_catalog_rejects_duplicate_trigger_source_identity() {
1379 let mut catalog = TriggerEventCatalog::new();
1380 catalog
1381 .declare(TriggerEvent::new(
1382 "Button",
1383 "ui.button",
1384 "pressed",
1385 button_payload_schema(),
1386 ))
1387 .expect("first trigger occurrence");
1388
1389 let err = catalog
1390 .declare(TriggerEvent::new(
1391 "AlternateButton",
1392 "ui.button",
1393 "pressed",
1394 button_payload_schema(),
1395 ))
1396 .expect_err("duplicate public source identity should be rejected");
1397
1398 assert!(err.contains("duplicate trigger source `ui.button.pressed`"));
1399 }
1400
1401 #[tokio::test]
1402 async fn trigger_store_rejects_mismatched_target_label() {
1403 let store = InMemoryTriggerStore::default();
1404 let draft = TriggerSubscriptionDraft::for_process(
1405 crate::ProcessOriginator::host(),
1406 crate::ProcessExecutionEnvRef::new("process-env:test"),
1407 "ui.button.pressed",
1408 "source-key",
1409 crate::ProcessInput::External {
1410 metadata: serde_json::json!({}),
1411 },
1412 crate::ProcessIdentity::new("external").with_label(Some("expected")),
1413 )
1414 .with_target_label("other");
1415
1416 let err = store
1417 .register_subscription(draft)
1418 .await
1419 .expect_err("mismatched target labels should be rejected");
1420 assert!(err.to_string().contains("target_label must match"));
1421 }
1422
1423 #[tokio::test]
1424 async fn trigger_emit_report_records_started_and_already_reserved_deliveries() {
1425 let store = Arc::new(InMemoryTriggerStore::default());
1426 let registry: Arc<dyn crate::ProcessRegistry> =
1427 Arc::new(crate::TestLocalProcessRegistry::default());
1428 let source_key = empty_trigger_source_key("ui.button.pressed").expect("source key");
1429 let subscription = store
1430 .register_subscription(trigger_process_draft(&source_key, "started"))
1431 .await
1432 .expect("register subscription");
1433 let router = TriggerRouter::new(store, Some(Arc::clone(®istry)), None);
1434 let controller = crate::InlineRuntimeEffectController;
1435
1436 let report = router
1437 .emit(
1438 button_occurrence(source_key.clone(), "button-blue-report"),
1439 &controller,
1440 )
1441 .await
1442 .expect("emit trigger");
1443 assert_eq!(report.deliveries.len(), 1);
1444 let delivery = &report.deliveries[0];
1445 assert_eq!(delivery.occurrence_id, report.occurrence_id);
1446 assert_eq!(delivery.subscription_id, subscription.subscription_id);
1447 assert_eq!(delivery.outcome, TriggerDeliveryEmitOutcome::Started);
1448 let record = registry
1449 .get_process(&delivery.process_id)
1450 .await
1451 .expect("started process record");
1452 assert!(matches!(
1453 record.provenance.caused_by,
1454 Some(crate::CausalRef::TriggerOccurrence {
1455 occurrence_id,
1456 subscription_id: Some(subscription_id),
1457 }) if occurrence_id == report.occurrence_id
1458 && subscription_id == subscription.subscription_id
1459 ));
1460
1461 let replay = router
1462 .emit(
1463 button_occurrence(source_key, "button-blue-report"),
1464 &controller,
1465 )
1466 .await
1467 .expect("replay trigger");
1468 assert_eq!(replay.deliveries.len(), 1);
1469 assert_eq!(
1470 replay.deliveries[0].outcome,
1471 TriggerDeliveryEmitOutcome::AlreadyReserved
1472 );
1473 assert_eq!(replay.deliveries[0].process_id, delivery.process_id);
1474 }
1475
1476 #[tokio::test]
1477 async fn trigger_emit_report_records_failed_delivery_outcome() {
1478 let store = Arc::new(InMemoryTriggerStore::default());
1479 let source_key = empty_trigger_source_key("ui.button.pressed").expect("source key");
1480 let subscription = store
1481 .register_subscription(trigger_process_draft(&source_key, "failed"))
1482 .await
1483 .expect("register subscription");
1484 let router = TriggerRouter::new(store, None, None);
1485 let controller = crate::InlineRuntimeEffectController;
1486
1487 let report = router
1488 .emit(
1489 button_occurrence(source_key, "button-blue-failed"),
1490 &controller,
1491 )
1492 .await
1493 .expect("emit trigger");
1494 assert_eq!(report.deliveries.len(), 1);
1495 let delivery = &report.deliveries[0];
1496 assert_eq!(delivery.subscription_id, subscription.subscription_id);
1497 assert!(matches!(
1498 &delivery.outcome,
1499 TriggerDeliveryEmitOutcome::Failed { reason }
1500 if reason.contains("process registry")
1501 ));
1502 }
1503}