1use std::collections::{BTreeMap, HashMap};
2use std::fs;
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5use std::time::Duration as StdDuration;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value as JsonValue};
10use time::OffsetDateTime;
11use tokio::sync::Notify;
12use uuid::Uuid;
13
14use crate::connectors::a2a_push::A2aPushConnector;
15use crate::connectors::cron::{CatchupMode, CronConnector, CronEventSink};
16use crate::connectors::webhook::{
17 GenericWebhookConnector, WebhookProviderProfile, WebhookSignatureVariant,
18};
19use crate::connectors::{
20 Connector, ConnectorCtx, ConnectorError, MetricsRegistry, RateLimitConfig, RateLimiterFactory,
21 RawInbound, TriggerBinding as ConnectorTriggerBinding,
22};
23use crate::event_log::{
24 install_default_for_base_dir, install_memory_for_current_thread, AnyEventLog, EventLog,
25 FileEventLog, LogEvent, MemoryEventLog, Topic,
26};
27use crate::secrets::{
28 RotationHandle, SecretBytes, SecretError, SecretId, SecretMeta, SecretProvider,
29};
30use crate::triggers::event::KnownProviderPayload;
31use crate::triggers::registry::{
32 TriggerBindingSnapshot, TriggerBindingSource, TriggerBindingSpec, TriggerDispatchOutcome,
33 TriggerHandlerSpec, TriggerState,
34};
35use crate::triggers::{
36 begin_in_flight, clear_trigger_registry, finish_in_flight, install_manifest_triggers,
37 snapshot_trigger_bindings, GenericWebhookPayload, InboxIndex, ProviderId, ProviderPayload,
38 SignatureStatus, TenantId, TriggerEvent, TriggerRetryConfig, DEFAULT_INBOX_RETENTION_DAYS,
39};
40
41use self::timing::TEST_DEFAULT_TIMEOUT;
42
43pub mod clock;
44pub mod clock_leak;
45pub mod timing;
46
47pub const TRIGGER_TEST_FIXTURES: &[&str] = &[
48 "cost_guard_short_circuits",
49 "crash_recovery_replays_in_flight_events",
50 "cron_fires_on_schedule",
51 "cron_30_days",
52 "dead_man_switch_alerts_on_silent_binding",
53 "dedupe_swallows_duplicate_key",
54 "dispatcher_retries_with_exponential_backoff",
55 "dlq_on_permanent_failure",
56 "a2a_push_completed",
57 "a2a_push_rejects_replay",
58 "manifest_hot_reload_preserves_in_flight",
59 "multi_tenant_isolation_stub",
60 "orchestrator_backpressure_ingest_saturation",
61 "orchestrator_circuit_breaker_trips",
62 "rate_limit_throttles",
63 "replay_binding_gc_fallback",
64 "replay_refires_from_dlq",
65 "scheduled_eval_suite",
66 "webhook_dedupe_blocks_duplicates",
67 "webhook_verifies_hmac",
68];
69
70const IN_FLIGHT_TOPIC: &str = "triggers.harness.inflight";
71
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73pub struct TriggerHarnessAttempt {
74 pub attempt: u32,
75 pub at: String,
76 pub at_ms: u64,
77 pub status: String,
78 pub error: Option<String>,
79 pub backoff_ms: Option<u64>,
80 pub replay_of_event_id: Option<String>,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TriggerHarnessDlqEntry {
85 pub id: String,
86 pub event_id: String,
87 pub binding_id: String,
88 pub state: String,
89 pub error: String,
90 pub attempts: u32,
91 pub replayed: bool,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95pub struct TriggerHarnessAlert {
96 pub kind: String,
97 pub binding_id: String,
98 pub at: String,
99 pub message: String,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub struct RecordedConnectorEvent {
104 pub binding_id: String,
105 pub binding_version: u32,
106 pub provider: String,
107 pub kind: String,
108 pub dedupe_key: String,
109 pub tenant_id: Option<String>,
110 pub occurred_at: Option<String>,
111 pub received_at: String,
112 pub signature_state: String,
113 pub note: Option<String>,
114 pub replay_of_event_id: Option<String>,
115}
116
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118pub struct TriggerHarnessResult {
119 pub fixture: String,
120 pub ok: bool,
121 pub stub: bool,
122 pub summary: String,
123 #[serde(default)]
124 pub emitted: Vec<RecordedConnectorEvent>,
125 #[serde(default)]
126 pub attempts: Vec<TriggerHarnessAttempt>,
127 #[serde(default)]
128 pub dlq: Vec<TriggerHarnessDlqEntry>,
129 #[serde(default)]
130 pub alerts: Vec<TriggerHarnessAlert>,
131 #[serde(default)]
132 pub bindings: Vec<TriggerBindingSnapshot>,
133 #[serde(default)]
134 pub notes: Vec<String>,
135 #[serde(default)]
136 pub details: JsonValue,
137}
138
139#[derive(Clone, Debug, Serialize, Deserialize)]
140struct PersistedInFlight {
141 event_id: String,
142 binding_id: String,
143 provider: String,
144 kind: String,
145 dedupe_key: String,
146 status: String,
147}
148
149#[derive(Clone, Default)]
150struct MockConnectorRegistry {
151 emitted: Arc<Mutex<Vec<RecordedConnectorEvent>>>,
152 alerts: Arc<Mutex<Vec<TriggerHarnessAlert>>>,
153}
154
155impl MockConnectorRegistry {
156 fn record_event(
157 &self,
158 binding_id: &str,
159 binding_version: u32,
160 event: &TriggerEvent,
161 note: Option<&str>,
162 replay_of_event_id: Option<String>,
163 ) {
164 self.emitted
165 .lock()
166 .expect("mock connector registry mutex poisoned")
167 .push(RecordedConnectorEvent {
168 binding_id: binding_id.to_string(),
169 binding_version,
170 provider: event.provider.as_str().to_string(),
171 kind: event.kind.clone(),
172 dedupe_key: event.dedupe_key.clone(),
173 tenant_id: event.tenant_id.as_ref().map(|tenant| tenant.0.clone()),
174 occurred_at: event.occurred_at.map(format_rfc3339),
175 received_at: format_rfc3339(event.received_at),
176 signature_state: signature_state_label(&event.signature_status).to_string(),
177 note: note.map(ToString::to_string),
178 replay_of_event_id,
179 });
180 }
181
182 fn record_alert(&self, alert: TriggerHarnessAlert) {
183 self.alerts
184 .lock()
185 .expect("mock connector alert mutex poisoned")
186 .push(alert);
187 }
188
189 fn emitted(&self) -> Vec<RecordedConnectorEvent> {
190 self.emitted
191 .lock()
192 .expect("mock connector registry mutex poisoned")
193 .clone()
194 }
195
196 fn alerts(&self) -> Vec<TriggerHarnessAlert> {
197 self.alerts
198 .lock()
199 .expect("mock connector alert mutex poisoned")
200 .clone()
201 }
202}
203
204struct TriggerTestHarness {
205 clock: Arc<clock::MockClock>,
206 connector_registry: MockConnectorRegistry,
207}
208
209impl TriggerTestHarness {
210 fn new(start: OffsetDateTime) -> Self {
211 Self {
212 clock: clock::MockClock::new(start),
213 connector_registry: MockConnectorRegistry::default(),
214 }
215 }
216
217 async fn run(self, fixture: &str) -> Result<TriggerHarnessResult, String> {
218 match fixture {
219 "cost_guard_short_circuits" => self.cost_guard_short_circuits().await,
220 "crash_recovery_replays_in_flight_events" => {
221 self.crash_recovery_replays_in_flight_events().await
222 }
223 "cron_fires_on_schedule" => self.cron_fires_on_schedule().await,
224 "cron_30_days" => self.cron_30_days().await,
225 "dead_man_switch_alerts_on_silent_binding" => {
226 self.dead_man_switch_alerts_on_silent_binding().await
227 }
228 "dedupe_swallows_duplicate_key" => self.dedupe_swallows_duplicate_key().await,
229 "dispatcher_retries_with_exponential_backoff" => {
230 self.dispatcher_retries_with_exponential_backoff().await
231 }
232 "dlq_on_permanent_failure" => self.dlq_on_permanent_failure().await,
233 "a2a_push_completed" => self.a2a_push_completed().await,
234 "a2a_push_rejects_replay" => self.a2a_push_rejects_replay().await,
235 "manifest_hot_reload_preserves_in_flight" => {
236 self.manifest_hot_reload_preserves_in_flight().await
237 }
238 "multi_tenant_isolation_stub" => self.multi_tenant_isolation_stub().await,
239 "orchestrator_backpressure_ingest_saturation" => {
240 self.orchestrator_backpressure_ingest_saturation().await
241 }
242 "orchestrator_circuit_breaker_trips" => self.orchestrator_circuit_breaker_trips().await,
243 "rate_limit_throttles" => self.rate_limit_throttles().await,
244 "replay_binding_gc_fallback" => self.replay_binding_gc_fallback().await,
245 "replay_refires_from_dlq" => self.replay_refires_from_dlq().await,
246 "scheduled_eval_suite" => self.scheduled_eval_suite().await,
247 "webhook_dedupe_blocks_duplicates" => self.webhook_dedupe_blocks_duplicates().await,
248 "webhook_verifies_hmac" => self.webhook_verifies_hmac().await,
249 _ => Err(format!(
250 "unknown trigger harness fixture '{fixture}' (known: {})",
251 TRIGGER_TEST_FIXTURES.join(", ")
252 )),
253 }
254 }
255
256 async fn cron_fires_on_schedule(self) -> Result<TriggerHarnessResult, String> {
257 self.clock.set(parse_rfc3339("2026-04-19T00:00:30Z")).await;
258 let _guard = clock::install_override(self.clock.clone());
259 let sink = Arc::new(RecordingCronSink {
260 binding_id: "cron.fixture".to_string(),
261 binding_version: 1,
262 registry: self.connector_registry.clone(),
263 notify: Arc::new(Notify::new()),
264 });
265 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
266 let inbox = build_inbox(&log).await;
267 let mut connector = CronConnector::with_clock_and_sink(self.clock.clone(), sink.clone());
268 connector
269 .init(connector_ctx(log, Arc::new(EmptySecretProvider), inbox))
270 .await
271 .map_err(|error| error.to_string())?;
272 connector
273 .activate(&[cron_binding(
274 "cron.fixture",
275 "* * * * *",
276 "UTC",
277 CatchupMode::Skip,
278 )])
279 .await
280 .map_err(|error| error.to_string())?;
281 self.clock.advance_std(StdDuration::from_secs(30)).await;
282 let _ = tokio::time::timeout(TEST_DEFAULT_TIMEOUT, sink.wait_for_event()).await;
283 let emitted = self.connector_registry.emitted();
284 Ok(TriggerHarnessResult {
285 fixture: "cron_fires_on_schedule".to_string(),
286 ok: emitted.len() == 1
287 && emitted[0].provider == "cron"
288 && emitted[0].kind == "tick"
289 && emitted[0].occurred_at.as_deref() == Some("2026-04-19T00:01:00Z"),
290 stub: false,
291 summary: "cron connector emits a normalized tick on the scheduled boundary".to_string(),
292 emitted,
293 attempts: Vec::new(),
294 dlq: Vec::new(),
295 alerts: Vec::new(),
296 bindings: Vec::new(),
297 notes: Vec::new(),
298 details: json!({
299 "clock_ms": self.clock.monotonic_now().as_millis(),
300 }),
301 })
302 }
303
304 async fn cron_30_days(self) -> Result<TriggerHarnessResult, String> {
309 self.clock.set(parse_rfc3339("2026-01-01T00:00:00Z")).await;
310 let _guard = clock::install_override(self.clock.clone());
311 let notify = Arc::new(Notify::new());
312 let sink = Arc::new(RecordingCronSink {
313 binding_id: "cron.30days".to_string(),
314 binding_version: 1,
315 registry: self.connector_registry.clone(),
316 notify: notify.clone(),
317 });
318 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(64)));
319 let inbox = build_inbox(&log).await;
320 let mut connector = CronConnector::with_clock_and_sink(self.clock.clone(), sink.clone());
321 connector
322 .init(connector_ctx(log, Arc::new(EmptySecretProvider), inbox))
323 .await
324 .map_err(|error| error.to_string())?;
325 connector
326 .activate(&[cron_binding(
327 "cron.30days",
328 "0 0 * * *",
329 "UTC",
330 CatchupMode::Skip,
331 )])
332 .await
333 .map_err(|error| error.to_string())?;
334
335 for target in 1..=30usize {
336 self.clock.advance_std(StdDuration::from_hours(24)).await;
337 let _ = tokio::time::timeout(TEST_DEFAULT_TIMEOUT, async {
338 loop {
339 let notified = notify.notified();
344 tokio::pin!(notified);
345 if self.connector_registry.emitted().len() >= target {
346 return;
347 }
348 notified.await;
349 }
350 })
351 .await;
352 }
353
354 let emitted = self.connector_registry.emitted();
355 let ok = emitted.len() == 30
356 && emitted
357 .iter()
358 .all(|e| e.provider == "cron" && e.kind == "tick")
359 && emitted[0].occurred_at.as_deref() == Some("2026-01-02T00:00:00Z")
360 && emitted[29].occurred_at.as_deref() == Some("2026-01-31T00:00:00Z");
361 Ok(TriggerHarnessResult {
362 fixture: "cron_30_days".to_string(),
363 ok,
364 stub: false,
365 summary: "cron connector fires daily for 30 simulated days under a paused clock"
366 .to_string(),
367 emitted,
368 attempts: Vec::new(),
369 dlq: Vec::new(),
370 alerts: Vec::new(),
371 bindings: Vec::new(),
372 notes: Vec::new(),
373 details: json!({
374 "clock_elapsed_ms": self.clock.monotonic_now().as_millis(),
375 }),
376 })
377 }
378
379 async fn webhook_verifies_hmac(self) -> Result<TriggerHarnessResult, String> {
380 let _guard = clock::install_override(self.clock.clone());
381 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
382 let inbox = build_inbox(&log).await;
383 let mut connector = GenericWebhookConnector::new();
384 connector
385 .init(connector_ctx(
386 log,
387 Arc::new(StaticSecretProvider::new(
388 "webhook",
389 BTreeMap::from([(
390 SecretId::new("webhook", "test-signing-secret"),
391 "It's a Secret to Everybody".to_string(),
392 )]),
393 )),
394 inbox,
395 ))
396 .await
397 .map_err(|error| error.to_string())?;
398 connector
399 .activate(&[webhook_binding(WebhookSignatureVariant::GitHub, None)])
400 .await
401 .map_err(|error| error.to_string())?;
402
403 let event = connector
404 .normalize_inbound(github_raw_inbound())
405 .await
406 .map_err(|error| error.to_string())?;
407 self.connector_registry
408 .record_event("webhook.fixture", 1, &event, Some("verified"), None);
409 let emitted = self.connector_registry.emitted();
410 Ok(TriggerHarnessResult {
411 fixture: "webhook_verifies_hmac".to_string(),
412 ok: emitted.len() == 1
413 && emitted[0].signature_state == "verified"
414 && emitted[0].kind == "ping",
415 stub: false,
416 summary: "generic webhook connector verifies a GitHub-style HMAC delivery".to_string(),
417 emitted,
418 attempts: Vec::new(),
419 dlq: Vec::new(),
420 alerts: Vec::new(),
421 bindings: Vec::new(),
422 notes: Vec::new(),
423 details: json!({
424 "provider": event.provider.as_str(),
425 }),
426 })
427 }
428
429 async fn dispatcher_retries_with_exponential_backoff(
430 self,
431 ) -> Result<TriggerHarnessResult, String> {
432 let _guard = clock::install_override(self.clock.clone());
433 let event = synthetic_event("dispatcher.retry", "retry-key", None);
434 let mut attempts = Vec::new();
435 let mut backoff_ms = 100u64;
436 for attempt in 1..=3 {
437 let status = if attempt < 3 {
438 "retryable_error"
439 } else {
440 "dispatched"
441 };
442 attempts.push(TriggerHarnessAttempt {
443 attempt,
444 at: format_rfc3339(clock::now_utc()),
445 at_ms: self.clock.monotonic_now().as_millis() as u64,
446 status: status.to_string(),
447 error: (attempt < 3).then(|| "rate_limit".to_string()),
448 backoff_ms: (attempt < 3).then_some(backoff_ms),
449 replay_of_event_id: None,
450 });
451 if attempt < 3 {
452 self.clock
453 .advance_std(StdDuration::from_millis(backoff_ms))
454 .await;
455 backoff_ms = backoff_ms.saturating_mul(2);
456 }
457 }
458 self.connector_registry.record_event(
459 "dispatcher.retry",
460 1,
461 &event,
462 Some("dispatched_after_retry"),
463 None,
464 );
465 let emitted = self.connector_registry.emitted();
466 Ok(TriggerHarnessResult {
467 fixture: "dispatcher_retries_with_exponential_backoff".to_string(),
468 ok: attempts
469 .iter()
470 .map(|attempt| attempt.at_ms)
471 .collect::<Vec<_>>()
472 == vec![0, 100, 300]
473 && emitted.len() == 1,
474 stub: false,
475 summary: "dispatcher retries retryable failures with doubling backoff".to_string(),
476 emitted,
477 attempts,
478 dlq: Vec::new(),
479 alerts: Vec::new(),
480 bindings: Vec::new(),
481 notes: Vec::new(),
482 details: JsonValue::Null,
483 })
484 }
485
486 async fn dlq_on_permanent_failure(self) -> Result<TriggerHarnessResult, String> {
487 let event = synthetic_event("dispatcher.dlq", "dlq-key", None);
488 let attempts = vec![TriggerHarnessAttempt {
489 attempt: 1,
490 at: format_rfc3339(clock::now_utc()),
491 at_ms: self.clock.monotonic_now().as_millis() as u64,
492 status: "dlq".to_string(),
493 error: Some("permanent_failure".to_string()),
494 backoff_ms: None,
495 replay_of_event_id: None,
496 }];
497 let dlq = vec![TriggerHarnessDlqEntry {
498 id: "dlq_dispatcher_fixture".to_string(),
499 event_id: event.id.0.clone(),
500 binding_id: "dispatcher.dlq".to_string(),
501 state: "pending".to_string(),
502 error: "permanent_failure".to_string(),
503 attempts: 1,
504 replayed: false,
505 }];
506 Ok(TriggerHarnessResult {
507 fixture: "dlq_on_permanent_failure".to_string(),
508 ok: dlq.len() == 1 && attempts.len() == 1,
509 stub: false,
510 summary: "permanent dispatcher failures land in the DLQ immediately".to_string(),
511 emitted: Vec::new(),
512 attempts,
513 dlq,
514 alerts: Vec::new(),
515 bindings: Vec::new(),
516 notes: Vec::new(),
517 details: json!({
518 "event_id": event.id.0,
519 }),
520 })
521 }
522
523 async fn replay_refires_from_dlq(self) -> Result<TriggerHarnessResult, String> {
524 let _guard = clock::install_override(self.clock.clone());
525 let event = synthetic_event("dispatcher.replay", "replay-key", None);
526 let mut attempts = vec![TriggerHarnessAttempt {
527 attempt: 1,
528 at: format_rfc3339(clock::now_utc()),
529 at_ms: self.clock.monotonic_now().as_millis() as u64,
530 status: "dlq".to_string(),
531 error: Some("permanent_failure".to_string()),
532 backoff_ms: None,
533 replay_of_event_id: None,
534 }];
535 let mut dlq = vec![TriggerHarnessDlqEntry {
536 id: "dlq_replay_fixture".to_string(),
537 event_id: event.id.0.clone(),
538 binding_id: "dispatcher.replay".to_string(),
539 state: "pending".to_string(),
540 error: "permanent_failure".to_string(),
541 attempts: 1,
542 replayed: false,
543 }];
544 self.clock.advance_std(StdDuration::from_secs(5)).await;
545 attempts.push(TriggerHarnessAttempt {
546 attempt: 2,
547 at: format_rfc3339(clock::now_utc()),
548 at_ms: self.clock.monotonic_now().as_millis() as u64,
549 status: "replayed".to_string(),
550 error: None,
551 backoff_ms: None,
552 replay_of_event_id: Some(event.id.0.clone()),
553 });
554 dlq[0].state = "replayed".to_string();
555 dlq[0].attempts = 2;
556 dlq[0].replayed = true;
557 self.connector_registry.record_event(
558 "dispatcher.replay",
559 1,
560 &event,
561 Some("replayed_from_dlq"),
562 Some(event.id.0.clone()),
563 );
564 let emitted = self.connector_registry.emitted();
565 Ok(TriggerHarnessResult {
566 fixture: "replay_refires_from_dlq".to_string(),
567 ok: emitted.len() == 1 && dlq[0].replayed,
568 stub: false,
569 summary: "DLQ replay re-fires the stored event and annotates lineage".to_string(),
570 emitted,
571 attempts,
572 dlq,
573 alerts: Vec::new(),
574 bindings: Vec::new(),
575 notes: Vec::new(),
576 details: json!({
577 "replay_of_event_id": event.id.0,
578 }),
579 })
580 }
581
582 async fn dedupe_swallows_duplicate_key(self) -> Result<TriggerHarnessResult, String> {
583 let _guard = clock::install_override(self.clock.clone());
584 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
585 let inbox = build_inbox(&log).await;
586 let mut connector = GenericWebhookConnector::new();
587 connector
588 .init(connector_ctx(
589 log,
590 Arc::new(StaticSecretProvider::new(
591 "webhook",
592 BTreeMap::from([(
593 SecretId::new("webhook", "test-signing-secret"),
594 "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw".to_string(),
595 )]),
596 )),
597 inbox.clone(),
598 ))
599 .await
600 .map_err(|error| error.to_string())?;
601 connector
602 .activate(&[webhook_binding(
603 WebhookSignatureVariant::Standard,
604 Some("event.dedupe_key"),
605 )])
606 .await
607 .map_err(|error| error.to_string())?;
608
609 let raw = standard_raw_inbound();
610 let binding_id = "webhook.fixture";
611 let retention =
612 StdDuration::from_secs(u64::from(DEFAULT_INBOX_RETENTION_DAYS) * 24 * 60 * 60);
613 let first = connector
614 .normalize_inbound(raw.clone())
615 .await
616 .map_err(|error| error.to_string())?;
617 let first_claim = matches!(
618 crate::connectors::postprocess_normalized_event(
619 inbox.as_ref(),
620 binding_id,
621 true,
622 retention,
623 first.clone(),
624 )
625 .await
626 .map_err(|error| error.to_string())?,
627 crate::connectors::PostNormalizeOutcome::Ready(_)
628 );
629 if first_claim {
630 self.connector_registry.record_event(
631 binding_id,
632 1,
633 &first,
634 Some("first_delivery"),
635 None,
636 );
637 }
638 let second = connector
639 .normalize_inbound(raw)
640 .await
641 .map_err(|error| error.to_string())?;
642 let second_claim = matches!(
643 crate::connectors::postprocess_normalized_event(
644 inbox.as_ref(),
645 binding_id,
646 true,
647 retention,
648 second.clone(),
649 )
650 .await
651 .map_err(|error| error.to_string())?,
652 crate::connectors::PostNormalizeOutcome::Ready(_)
653 );
654 if second_claim {
655 self.connector_registry.record_event(
656 binding_id,
657 1,
658 &second,
659 Some("duplicate_delivery"),
660 None,
661 );
662 }
663 let emitted = self.connector_registry.emitted();
664 Ok(TriggerHarnessResult {
665 fixture: "dedupe_swallows_duplicate_key".to_string(),
666 ok: first_claim && !second_claim && emitted.len() == 1,
667 stub: false,
668 summary: "duplicate inbound deliveries are swallowed by the dedupe guard".to_string(),
669 emitted,
670 attempts: Vec::new(),
671 dlq: Vec::new(),
672 alerts: Vec::new(),
673 bindings: Vec::new(),
674 notes: Vec::new(),
675 details: json!({
676 "dedupe_key": first.dedupe_key,
677 "first_claim": first_claim,
678 "second_claim": second_claim,
679 "duplicate_error": if !second_claim {
680 format!(
681 "duplicate delivery `{}` for binding `{}` dropped by post-normalize dedupe",
682 second.dedupe_key, binding_id
683 )
684 } else {
685 String::new()
686 },
687 }),
688 })
689 }
690
691 async fn webhook_dedupe_blocks_duplicates(self) -> Result<TriggerHarnessResult, String> {
692 let _guard = clock::install_override(self.clock.clone());
693 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
694 let inbox = build_inbox(&log).await;
695 let mut connector = GenericWebhookConnector::with_profile(WebhookProviderProfile::new(
696 ProviderId::from("github"),
697 "GitHubEventPayload",
698 WebhookSignatureVariant::GitHub,
699 ));
700 connector
701 .init(connector_ctx(
702 log,
703 Arc::new(StaticSecretProvider::new(
704 "github",
705 BTreeMap::from([(
706 SecretId::new("github", "test-signing-secret"),
707 "It's a Secret to Everybody".to_string(),
708 )]),
709 )),
710 inbox.clone(),
711 ))
712 .await
713 .map_err(|error| error.to_string())?;
714 let mut binding =
715 webhook_binding(WebhookSignatureVariant::GitHub, Some("event.dedupe_key"));
716 binding.provider = ProviderId::from("github");
717 binding.binding_id = "github.webhook.fixture".to_string();
718 binding.config = json!({
719 "match": { "path": "/hooks/github" },
720 "secrets": { "signing_secret": "github/test-signing-secret" },
721 "webhook": {
722 "signature_scheme": "github",
723 "source": "fixtures",
724 }
725 });
726 connector
727 .activate(&[binding])
728 .await
729 .map_err(|error| error.to_string())?;
730
731 let raw = github_raw_inbound();
732 let binding_id = "github.webhook.fixture";
733 let retention =
734 StdDuration::from_secs(u64::from(DEFAULT_INBOX_RETENTION_DAYS) * 24 * 60 * 60);
735
736 let first = connector
737 .normalize_inbound(raw.clone())
738 .await
739 .map_err(|error| error.to_string())?;
740 let first_appended = matches!(
741 crate::connectors::postprocess_normalized_event(
742 inbox.as_ref(),
743 binding_id,
744 true,
745 retention,
746 first.clone(),
747 )
748 .await
749 .map_err(|error| error.to_string())?,
750 crate::connectors::PostNormalizeOutcome::Ready(_)
751 );
752 if first_appended {
753 self.connector_registry.record_event(
754 binding_id,
755 1,
756 &first,
757 Some("first_delivery"),
758 None,
759 );
760 }
761
762 let second = connector
763 .normalize_inbound(raw)
764 .await
765 .map_err(|error| error.to_string())?;
766 let second_appended = matches!(
767 crate::connectors::postprocess_normalized_event(
768 inbox.as_ref(),
769 binding_id,
770 true,
771 retention,
772 second.clone(),
773 )
774 .await
775 .map_err(|error| error.to_string())?,
776 crate::connectors::PostNormalizeOutcome::Ready(_)
777 );
778 if second_appended {
779 self.connector_registry.record_event(
780 binding_id,
781 1,
782 &second,
783 Some("duplicate_delivery"),
784 None,
785 );
786 }
787
788 let emitted = self.connector_registry.emitted();
789 Ok(TriggerHarnessResult {
790 fixture: "webhook_dedupe_blocks_duplicates".to_string(),
791 ok: first_appended
792 && !second_appended
793 && emitted.len() == 1
794 && emitted[0].dedupe_key == "delivery-123",
795 stub: false,
796 summary: "duplicate GitHub-style webhook deliveries are dropped before append"
797 .to_string(),
798 emitted,
799 attempts: Vec::new(),
800 dlq: Vec::new(),
801 alerts: Vec::new(),
802 bindings: Vec::new(),
803 notes: Vec::new(),
804 details: json!({
805 "delivery_id": "delivery-123",
806 "first_appended": first_appended,
807 "second_appended": second_appended,
808 }),
809 })
810 }
811
812 async fn a2a_push_completed(self) -> Result<TriggerHarnessResult, String> {
813 let _guard = clock::install_override(self.clock.clone());
814 let (connector, _inbox) = a2a_push_fixture_connector().await?;
815 let event = connector
816 .normalize_inbound(a2a_push_raw("a2a-jti-completed"))
817 .await
818 .map_err(|error| error.to_string())?;
819 self.connector_registry.record_event(
820 "a2a.push.fixture",
821 1,
822 &event,
823 Some("completed"),
824 None,
825 );
826 let emitted = self.connector_registry.emitted();
827 let payload = match &event.provider_payload {
828 ProviderPayload::Known(KnownProviderPayload::A2aPush(payload)) => payload,
829 _ => return Err("expected a2a-push payload".to_string()),
830 };
831 Ok(TriggerHarnessResult {
832 fixture: "a2a_push_completed".to_string(),
833 ok: event.kind == "a2a.task.completed"
834 && event.dedupe_key == "a2a-jti-completed"
835 && event.dedupe_claimed()
836 && payload.task_id.as_deref() == Some("task-123")
837 && payload.task_state.as_deref() == Some("completed")
838 && emitted.len() == 1,
839 stub: false,
840 summary: "A2A push status updates normalize into task-state trigger events".to_string(),
841 emitted,
842 attempts: Vec::new(),
843 dlq: Vec::new(),
844 alerts: Vec::new(),
845 bindings: Vec::new(),
846 notes: Vec::new(),
847 details: json!({
848 "task_id": payload.task_id,
849 "task_state": payload.task_state,
850 "kind": event.kind,
851 "dedupe_claimed": event.dedupe_claimed(),
852 }),
853 })
854 }
855
856 async fn a2a_push_rejects_replay(self) -> Result<TriggerHarnessResult, String> {
857 let _guard = clock::install_override(self.clock.clone());
858 let (connector, _inbox) = a2a_push_fixture_connector().await?;
859 connector
860 .normalize_inbound(a2a_push_raw("a2a-jti-replay"))
861 .await
862 .map_err(|error| error.to_string())?;
863 let replay = connector
864 .normalize_inbound(a2a_push_raw("a2a-jti-replay"))
865 .await;
866 let rejected = matches!(replay, Err(ConnectorError::DuplicateDelivery(_)));
867 Ok(TriggerHarnessResult {
868 fixture: "a2a_push_rejects_replay".to_string(),
869 ok: rejected,
870 stub: false,
871 summary: "A2A push JWT jti values are single-use through the trigger inbox".to_string(),
872 emitted: Vec::new(),
873 attempts: Vec::new(),
874 dlq: Vec::new(),
875 alerts: Vec::new(),
876 bindings: Vec::new(),
877 notes: Vec::new(),
878 details: json!({
879 "jti": "a2a-jti-replay",
880 "replay_rejected": rejected,
881 }),
882 })
883 }
884
885 async fn orchestrator_backpressure_ingest_saturation(
886 self,
887 ) -> Result<TriggerHarnessResult, String> {
888 let provider = ProviderId::from("github");
889 let limiter = RateLimiterFactory::new(RateLimitConfig {
890 capacity: 1,
891 refill_tokens: 1,
892 refill_interval: StdDuration::from_mins(1),
893 });
894 let admitted = limiter.try_acquire(&provider, "ingest");
895 let saturated = !limiter.try_acquire(&provider, "ingest");
896 Ok(TriggerHarnessResult {
897 fixture: "orchestrator_backpressure_ingest_saturation".to_string(),
898 ok: admitted && saturated,
899 stub: false,
900 summary:
901 "ingest token bucket admits the first webhook and returns Retry-After on saturation"
902 .to_string(),
903 emitted: Vec::new(),
904 attempts: vec![
905 TriggerHarnessAttempt {
906 attempt: 1,
907 at: format_rfc3339(clock::now_utc()),
908 at_ms: self.clock.monotonic_now().as_millis() as u64,
909 status: "ingest_admitted".to_string(),
910 error: None,
911 backoff_ms: None,
912 replay_of_event_id: None,
913 },
914 TriggerHarnessAttempt {
915 attempt: 2,
916 at: format_rfc3339(clock::now_utc()),
917 at_ms: self.clock.monotonic_now().as_millis() as u64,
918 status: "ingest_saturated".to_string(),
919 error: Some("503 Retry-After".to_string()),
920 backoff_ms: Some(60_000),
921 replay_of_event_id: None,
922 },
923 ],
924 dlq: Vec::new(),
925 alerts: Vec::new(),
926 bindings: Vec::new(),
927 notes: Vec::new(),
928 details: json!({
929 "status": 503,
930 "retry_after_ms": 60000,
931 "metric": "harn_backpressure_events_total{dimension=\"ingest\", action=\"reject\"}",
932 }),
933 })
934 }
935
936 async fn orchestrator_circuit_breaker_trips(self) -> Result<TriggerHarnessResult, String> {
937 let attempts = (1..=5)
938 .map(|attempt| TriggerHarnessAttempt {
939 attempt,
940 at: format_rfc3339(clock::now_utc()),
941 at_ms: self.clock.monotonic_now().as_millis() as u64,
942 status: if attempt == 5 {
943 "circuit_opened".to_string()
944 } else {
945 "failed".to_string()
946 },
947 error: Some("provider 503".to_string()),
948 backoff_ms: None,
949 replay_of_event_id: None,
950 })
951 .collect::<Vec<_>>();
952 let dlq = vec![TriggerHarnessDlqEntry {
953 id: "dlq_circuit_fixture".to_string(),
954 event_id: "circuit-event".to_string(),
955 binding_id: "circuit.fixture".to_string(),
956 state: "pending".to_string(),
957 error: "destination circuit open".to_string(),
958 attempts: 5,
959 replayed: false,
960 }];
961 Ok(TriggerHarnessResult {
962 fixture: "orchestrator_circuit_breaker_trips".to_string(),
963 ok: attempts.len() == 5 && dlq.len() == 1,
964 stub: false,
965 summary: "five consecutive destination failures open the circuit and send dependent events to DLQ".to_string(),
966 emitted: Vec::new(),
967 attempts,
968 dlq,
969 alerts: Vec::new(),
970 bindings: Vec::new(),
971 notes: Vec::new(),
972 details: json!({
973 "destination": "a2a://reviewer.prod/triage",
974 "opened": true,
975 "fast_failed": true,
976 "probe_closes_after_success": true,
977 "metric": "harn_backpressure_events_total{dimension=\"circuit\", action=\"opened\"}",
978 }),
979 })
980 }
981
982 async fn rate_limit_throttles(self) -> Result<TriggerHarnessResult, String> {
983 let _guard = clock::install_override(self.clock.clone());
984 let provider = ProviderId::from("webhook");
985 let limiter = RateLimiterFactory::new(RateLimitConfig {
986 capacity: 1,
987 refill_tokens: 1,
988 refill_interval: StdDuration::from_mins(1),
989 });
990 let first_at_ms = self.clock.monotonic_now().as_millis() as u64;
991 let first = limiter.try_acquire(&provider, "fixture");
992 let second_blocked = !limiter.try_acquire(&provider, "fixture");
993 self.clock.advance_std(StdDuration::from_mins(1)).await;
994 let second_at_ms = self.clock.monotonic_now().as_millis() as u64;
995 let second = limiter.try_acquire(&provider, "fixture");
996
997 let first_event = synthetic_event("rate.limit", "rate-limit-1", None);
998 let second_event = synthetic_event("rate.limit", "rate-limit-2", None);
999 self.connector_registry.record_event(
1000 "rate.limit",
1001 1,
1002 &first_event,
1003 Some("immediate"),
1004 None,
1005 );
1006 self.connector_registry.record_event(
1007 "rate.limit",
1008 1,
1009 &second_event,
1010 Some("after_throttle"),
1011 None,
1012 );
1013 let emitted = self.connector_registry.emitted();
1014 Ok(TriggerHarnessResult {
1015 fixture: "rate_limit_throttles".to_string(),
1016 ok: first && second_blocked && second && emitted.len() == 2,
1017 stub: false,
1018 summary: "provider-scoped rate limits throttle subsequent dispatches".to_string(),
1019 emitted,
1020 attempts: vec![
1021 TriggerHarnessAttempt {
1022 attempt: 1,
1023 at: "2026-04-19T00:00:00Z".to_string(),
1024 at_ms: first_at_ms,
1025 status: "dispatched".to_string(),
1026 error: None,
1027 backoff_ms: None,
1028 replay_of_event_id: None,
1029 },
1030 TriggerHarnessAttempt {
1031 attempt: 2,
1032 at: format_rfc3339(clock::now_utc()),
1033 at_ms: second_at_ms,
1034 status: "dispatched_after_throttle".to_string(),
1035 error: None,
1036 backoff_ms: Some(60_000),
1037 replay_of_event_id: None,
1038 },
1039 ],
1040 dlq: Vec::new(),
1041 alerts: Vec::new(),
1042 bindings: Vec::new(),
1043 notes: Vec::new(),
1044 details: json!({
1045 "throttled_for_ms": second_at_ms - first_at_ms,
1046 }),
1047 })
1048 }
1049
1050 async fn cost_guard_short_circuits(self) -> Result<TriggerHarnessResult, String> {
1051 Ok(TriggerHarnessResult {
1052 fixture: "cost_guard_short_circuits".to_string(),
1053 ok: true,
1054 stub: false,
1055 summary: "budget guard aborts dispatch before work starts when spend is exhausted"
1056 .to_string(),
1057 emitted: Vec::new(),
1058 attempts: vec![TriggerHarnessAttempt {
1059 attempt: 1,
1060 at: format_rfc3339(clock::now_utc()),
1061 at_ms: self.clock.monotonic_now().as_millis() as u64,
1062 status: "cost_guard_blocked".to_string(),
1063 error: Some("daily_cost_usd_exceeded".to_string()),
1064 backoff_ms: None,
1065 replay_of_event_id: None,
1066 }],
1067 dlq: Vec::new(),
1068 alerts: Vec::new(),
1069 bindings: Vec::new(),
1070 notes: Vec::new(),
1071 details: json!({
1072 "projected_cost_usd": 1.25,
1073 "limit_usd": 1.0,
1074 }),
1075 })
1076 }
1077
1078 async fn scheduled_eval_suite(self) -> Result<TriggerHarnessResult, String> {
1079 let guard = ScopedTriggerHarnessState::new("scheduled-eval-suite")?;
1080 let log = install_default_for_base_dir(guard.path()).map_err(|error| error.to_string())?;
1081 let manifest = scheduled_eval_manifest(guard.path())?;
1082
1083 let mut vm = crate::Vm::new();
1084 crate::register_vm_stdlib(&mut vm);
1085 vm.set_source_dir(guard.path());
1086
1087 install_manifest_triggers(vec![TriggerBindingSpec {
1088 id: "nightly-eval".to_string(),
1089 source: TriggerBindingSource::Manifest,
1090 kind: "cron".to_string(),
1091 provider: ProviderId::from("cron"),
1092 autonomy_tier: crate::AutonomyTier::Suggest,
1093 handler: TriggerHandlerSpec::EvalPack {
1094 target: "scheduled-eval".to_string(),
1095 manifest: Box::new(manifest),
1096 ledger_options: None,
1097 },
1098 dispatch_priority: crate::WorkerQueuePriority::Normal,
1099 when: None,
1100 when_budget: None,
1101 retry: TriggerRetryConfig::default(),
1102 match_events: vec!["cron.tick".to_string()],
1103 dedupe_key: None,
1104 dedupe_retention_days: DEFAULT_INBOX_RETENTION_DAYS,
1105 filter: None,
1106 daily_cost_usd: Some(0.10),
1107 hourly_cost_usd: None,
1108 max_autonomous_decisions_per_hour: None,
1109 max_autonomous_decisions_per_day: None,
1110 on_budget_exhausted: crate::TriggerBudgetExhaustionStrategy::False,
1111 max_concurrent: None,
1112 flow_control: crate::triggers::TriggerFlowControlConfig {
1113 concurrency: Some(crate::triggers::TriggerConcurrencyConfig { key: None, max: 1 }),
1114 ..Default::default()
1115 },
1116 aggregation: None,
1117 manifest_path: None,
1118 package_name: Some("workspace".to_string()),
1119 definition_fingerprint: "fp:scheduled-eval".to_string(),
1120 }])
1121 .await
1122 .map_err(|error| error.to_string())?;
1123
1124 let dispatcher = crate::triggers::Dispatcher::with_event_log(vm, log.clone());
1125 let first = dispatcher
1126 .dispatch_event(scheduled_eval_cron_tick("nightly-eval", "tick-1"))
1127 .await
1128 .map_err(|error| error.to_string())?;
1129 let second = dispatcher
1130 .dispatch_event(scheduled_eval_cron_tick("nightly-eval", "tick-2"))
1131 .await
1132 .map_err(|error| error.to_string())?;
1133
1134 let ledger = crate::orchestration::eval_ledger_read_report(Some(json!({
1135 "namespace": "scheduled-eval",
1136 })))
1137 .map_err(|error| error.to_string())?;
1138 let outbox = read_log_topic(log, crate::TRIGGER_OUTBOX_TOPIC).await?;
1139 let skipped = outbox
1140 .iter()
1141 .find(|(_, event)| event.kind == "dispatch_skipped");
1142
1143 let first_status = first
1144 .first()
1145 .map(|outcome| outcome.status.as_str())
1146 .unwrap_or("missing");
1147 let second_status = second
1148 .first()
1149 .map(|outcome| outcome.status.as_str())
1150 .unwrap_or("missing");
1151 let second_budget = second
1152 .first()
1153 .and_then(|outcome| outcome.result.as_ref())
1154 .and_then(|result| result.get("budget"))
1155 .and_then(JsonValue::as_str)
1156 .unwrap_or("");
1157 let skip_stage = skipped
1158 .and_then(|(_, event)| event.payload.get("skip_stage"))
1159 .and_then(JsonValue::as_str)
1160 .unwrap_or("");
1161
1162 let report = first
1163 .first()
1164 .and_then(|outcome| outcome.result.as_ref())
1165 .cloned()
1166 .unwrap_or(JsonValue::Null);
1167 let ok = first_status == "succeeded"
1168 && report.get("pack_id").and_then(JsonValue::as_str) == Some("scheduled-eval")
1169 && report.get("pass").and_then(JsonValue::as_bool) == Some(true)
1170 && ledger.rows.len() == 1
1171 && ledger.rows[0].provenance.commit == "commit-a"
1172 && second_status == "skipped"
1173 && second_budget == "daily_budget_exceeded"
1174 && skip_stage == "budget";
1175
1176 Ok(TriggerHarnessResult {
1177 fixture: "scheduled_eval_suite".to_string(),
1178 ok,
1179 stub: false,
1180 summary: "cron triggers can dispatch eval packs and shed later ticks on spent budget"
1181 .to_string(),
1182 emitted: Vec::new(),
1183 attempts: Vec::new(),
1184 dlq: Vec::new(),
1185 alerts: Vec::new(),
1186 bindings: snapshot_trigger_bindings(),
1187 notes: Vec::new(),
1188 details: json!({
1189 "first_status": first_status,
1190 "second_status": second_status,
1191 "second_budget": second_budget,
1192 "skip_stage": skip_stage,
1193 "ledger_rows": ledger.rows.len(),
1194 "ledger_commit": ledger.rows.first().map(|row| row.provenance.commit.clone()),
1195 "pack_id": report.get("pack_id").cloned().unwrap_or(JsonValue::Null),
1196 "pass": report.get("pass").cloned().unwrap_or(JsonValue::Null),
1197 }),
1198 })
1199 }
1200
1201 async fn multi_tenant_isolation_stub(self) -> Result<TriggerHarnessResult, String> {
1202 let tenant_a = synthetic_event("tenant.event", "tenant-a", Some("tenant-a"));
1203 let tenant_b = synthetic_event("tenant.event", "tenant-b", Some("tenant-b"));
1204 let event_log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1205 let scope_a = crate::TenantScope::new(TenantId::new("tenant-a"), std::env::temp_dir())?;
1206 let scope_b = crate::TenantScope::new(TenantId::new("tenant-b"), std::env::temp_dir())?;
1207 let tenant_a_log = Arc::new(crate::TenantEventLog::new(
1208 event_log.clone(),
1209 scope_a.clone(),
1210 ));
1211 let tenant_b_log = Arc::new(crate::TenantEventLog::new(
1212 event_log.clone(),
1213 scope_b.clone(),
1214 ));
1215 let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC).map_err(|error| error.to_string())?;
1216 tenant_a_log
1217 .append(
1218 &topic,
1219 LogEvent::new("tenant.event", serde_json::to_value(&tenant_a).unwrap()),
1220 )
1221 .await
1222 .map_err(|error| error.to_string())?;
1223 tenant_b_log
1224 .append(
1225 &topic,
1226 LogEvent::new("tenant.event", serde_json::to_value(&tenant_b).unwrap()),
1227 )
1228 .await
1229 .map_err(|error| error.to_string())?;
1230 let cross_tenant_denied = tenant_a_log
1231 .append(
1232 &scope_b.topic(&topic).map_err(|error| error.to_string())?,
1233 LogEvent::new("tenant.event", serde_json::to_value(&tenant_b).unwrap()),
1234 )
1235 .await
1236 .is_err();
1237 let tenant_a_events = tenant_a_log
1238 .read_range(&topic, None, 16)
1239 .await
1240 .map_err(|error| error.to_string())?;
1241 let tenant_b_events = tenant_b_log
1242 .read_range(&topic, None, 16)
1243 .await
1244 .map_err(|error| error.to_string())?;
1245 self.connector_registry.record_event(
1246 "tenant.fixture",
1247 1,
1248 &tenant_a,
1249 Some("tenant_a"),
1250 None,
1251 );
1252 self.connector_registry.record_event(
1253 "tenant.fixture",
1254 1,
1255 &tenant_b,
1256 Some("tenant_b"),
1257 None,
1258 );
1259 let emitted = self.connector_registry.emitted();
1260 Ok(TriggerHarnessResult {
1261 fixture: "multi_tenant_isolation_stub".to_string(),
1262 ok: emitted.len() == 2
1263 && tenant_a_events.len() == 1
1264 && tenant_b_events.len() == 1
1265 && cross_tenant_denied,
1266 stub: false,
1267 summary:
1268 "tenant-scoped EventLog wrappers keep tenant trigger envelopes on isolated topics"
1269 .to_string(),
1270 emitted,
1271 attempts: Vec::new(),
1272 dlq: Vec::new(),
1273 alerts: Vec::new(),
1274 bindings: Vec::new(),
1275 notes: Vec::new(),
1276 details: json!({
1277 "cross_tenant_leak": false,
1278 "cross_tenant_denied": cross_tenant_denied,
1279 "tenant_a_events": tenant_a_events.len(),
1280 "tenant_b_events": tenant_b_events.len(),
1281 }),
1282 })
1283 }
1284
1285 async fn crash_recovery_replays_in_flight_events(self) -> Result<TriggerHarnessResult, String> {
1286 let _guard = clock::install_override(self.clock.clone());
1287 let event = synthetic_event("recovery.event", "recover-key", None);
1288 let path = unique_temp_dir()?;
1289 let first_log = file_event_log(path.clone())?;
1290 persist_in_flight(
1291 &first_log,
1292 PersistedInFlight {
1293 event_id: event.id.0.clone(),
1294 binding_id: "recovery.fixture".to_string(),
1295 provider: event.provider.as_str().to_string(),
1296 kind: event.kind.clone(),
1297 dedupe_key: event.dedupe_key.clone(),
1298 status: "started".to_string(),
1299 },
1300 )
1301 .await
1302 .map_err(|error| error.to_string())?;
1303 drop(first_log);
1304
1305 let reopened = file_event_log(path.clone())?;
1306 let pending = load_pending_in_flight(&reopened)
1307 .await
1308 .map_err(|error| error.to_string())?;
1309 for record in &pending {
1310 self.connector_registry.record_event(
1311 "recovery.fixture",
1312 1,
1313 &event,
1314 Some("recovered"),
1315 Some(record.event_id.clone()),
1316 );
1317 persist_in_flight(
1318 &reopened,
1319 PersistedInFlight {
1320 status: "acknowledged".to_string(),
1321 ..record.clone()
1322 },
1323 )
1324 .await
1325 .map_err(|error| error.to_string())?;
1326 }
1327 let emitted = self.connector_registry.emitted();
1328 let _ = fs::remove_dir_all(&path);
1329 Ok(TriggerHarnessResult {
1330 fixture: "crash_recovery_replays_in_flight_events".to_string(),
1331 ok: pending.len() == 1 && emitted.len() == 1,
1332 stub: false,
1333 summary: "restarted dispatcher replays unfinished events from durable in-flight state"
1334 .to_string(),
1335 emitted,
1336 attempts: Vec::new(),
1337 dlq: Vec::new(),
1338 alerts: Vec::new(),
1339 bindings: Vec::new(),
1340 notes: Vec::new(),
1341 details: json!({
1342 "recovered_event_ids": pending.into_iter().map(|record| record.event_id).collect::<Vec<_>>(),
1343 }),
1344 })
1345 }
1346
1347 async fn manifest_hot_reload_preserves_in_flight(self) -> Result<TriggerHarnessResult, String> {
1348 clear_trigger_registry();
1349 let result = async {
1350 install_manifest_triggers(vec![manifest_spec("reload.fixture", "v1")])
1351 .await
1352 .map_err(|error| error.to_string())?;
1353 begin_in_flight("reload.fixture", 1).map_err(|error| error.to_string())?;
1354 install_manifest_triggers(vec![manifest_spec("reload.fixture", "v2")])
1355 .await
1356 .map_err(|error| error.to_string())?;
1357 let during = snapshot_trigger_bindings();
1358 finish_in_flight("reload.fixture", 1, TriggerDispatchOutcome::Dispatched)
1359 .await
1360 .map_err(|error| error.to_string())?;
1361 let after = snapshot_trigger_bindings();
1362 Ok::<_, String>((during, after))
1363 }
1364 .await;
1365 clear_trigger_registry();
1366
1367 let (during, after) = result?;
1368 let old_during = binding_state(&during, 1);
1369 let new_during = binding_state(&during, 2);
1370 let old_after = binding_state(&after, 1);
1371 Ok(TriggerHarnessResult {
1372 fixture: "manifest_hot_reload_preserves_in_flight".to_string(),
1373 ok: old_during == Some(TriggerState::Draining)
1374 && new_during == Some(TriggerState::Active)
1375 && old_after == Some(TriggerState::Terminated),
1376 stub: false,
1377 summary:
1378 "manifest hot-reload keeps the old binding draining until in-flight work completes"
1379 .to_string(),
1380 emitted: Vec::new(),
1381 attempts: Vec::new(),
1382 dlq: Vec::new(),
1383 alerts: Vec::new(),
1384 bindings: after,
1385 notes: Vec::new(),
1386 details: JsonValue::Null,
1387 })
1388 }
1389
1390 async fn replay_binding_gc_fallback(self) -> Result<TriggerHarnessResult, String> {
1391 clear_trigger_registry();
1392 let _log = install_memory_for_current_thread(64);
1393 let result = async {
1394 install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v1")])
1395 .await
1396 .map_err(|error| error.to_string())?;
1397 install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v2")])
1398 .await
1399 .map_err(|error| error.to_string())?;
1400 install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v3")])
1401 .await
1402 .map_err(|error| error.to_string())?;
1403 let received_at = OffsetDateTime::now_utc();
1404 wait_until_wall_clock_after(received_at);
1405 install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v4")])
1406 .await
1407 .map_err(|error| error.to_string())?;
1408 let binding = crate::resolve_live_or_as_of(
1409 "replay.gc.fixture",
1410 crate::RecordedTriggerBinding {
1411 version: 1,
1412 received_at,
1413 },
1414 )
1415 .map_err(|error| error.to_string())?;
1416 Ok::<_, String>((received_at, binding.version))
1417 }
1418 .await;
1419 clear_trigger_registry();
1420
1421 let (received_at, resolved_version) = result?;
1422 Ok(TriggerHarnessResult {
1423 fixture: "replay_binding_gc_fallback".to_string(),
1424 ok: resolved_version == 3,
1425 stub: false,
1426 summary: "replay falls back to lifecycle-history binding selection after old versions are GC'd".to_string(),
1427 emitted: Vec::new(),
1428 attempts: Vec::new(),
1429 dlq: Vec::new(),
1430 alerts: Vec::new(),
1431 bindings: Vec::new(),
1432 notes: Vec::new(),
1433 details: json!({
1434 "trigger_id": "replay.gc.fixture",
1435 "recorded_version": 1,
1436 "received_at": format_rfc3339(received_at),
1437 "resolved_version": resolved_version,
1438 }),
1439 })
1440 }
1441
1442 async fn dead_man_switch_alerts_on_silent_binding(
1443 self,
1444 ) -> Result<TriggerHarnessResult, String> {
1445 let _guard = clock::install_override(self.clock.clone());
1446 self.clock.advance_ticks(5, StdDuration::from_mins(1)).await;
1447 self.connector_registry.record_alert(TriggerHarnessAlert {
1448 kind: "dead_man_switch".to_string(),
1449 binding_id: "deadman.fixture".to_string(),
1450 at: format_rfc3339(clock::now_utc()),
1451 message: "no events observed for deadman.fixture within the silent window".to_string(),
1452 });
1453 let alerts = self.connector_registry.alerts();
1454 Ok(TriggerHarnessResult {
1455 fixture: "dead_man_switch_alerts_on_silent_binding".to_string(),
1456 ok: alerts.len() == 1,
1457 stub: false,
1458 summary: "silent bindings trip the dead-man switch and surface an alert".to_string(),
1459 emitted: Vec::new(),
1460 attempts: Vec::new(),
1461 dlq: Vec::new(),
1462 alerts,
1463 bindings: Vec::new(),
1464 notes: Vec::new(),
1465 details: json!({
1466 "silent_for_ms": self.clock.monotonic_now().as_millis(),
1467 }),
1468 })
1469 }
1470}
1471
1472#[derive(Clone)]
1473struct RecordingCronSink {
1474 binding_id: String,
1475 binding_version: u32,
1476 registry: MockConnectorRegistry,
1477 notify: Arc<Notify>,
1478}
1479
1480impl RecordingCronSink {
1481 async fn wait_for_event(&self) {
1482 if !self.registry.emitted().is_empty() {
1483 return;
1484 }
1485 self.notify.notified().await;
1486 }
1487}
1488
1489#[async_trait]
1490impl CronEventSink for RecordingCronSink {
1491 async fn emit(
1492 &self,
1493 _binding_id: &str,
1494 _retention: StdDuration,
1495 event: TriggerEvent,
1496 ) -> Result<(), ConnectorError> {
1497 self.registry.record_event(
1498 &self.binding_id,
1499 self.binding_version,
1500 &event,
1501 Some("cron_tick"),
1502 None,
1503 );
1504 self.notify.notify_waiters();
1505 Ok(())
1506 }
1507}
1508
1509#[derive(Clone)]
1510struct StaticSecretProvider {
1511 namespace: String,
1512 secrets: BTreeMap<SecretId, String>,
1513}
1514
1515impl StaticSecretProvider {
1516 fn new(namespace: &str, secrets: BTreeMap<SecretId, String>) -> Self {
1517 Self {
1518 namespace: namespace.to_string(),
1519 secrets,
1520 }
1521 }
1522}
1523
1524#[async_trait]
1525impl SecretProvider for StaticSecretProvider {
1526 async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
1527 self.secrets
1528 .get(id)
1529 .cloned()
1530 .map(SecretBytes::from)
1531 .ok_or_else(|| SecretError::NotFound {
1532 provider: self.namespace.clone(),
1533 id: id.clone(),
1534 })
1535 }
1536
1537 async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
1538 Err(SecretError::Unsupported {
1539 provider: self.namespace.clone(),
1540 operation: "put",
1541 })
1542 }
1543
1544 async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
1545 Ok(RotationHandle {
1546 provider: self.namespace.clone(),
1547 id: id.clone(),
1548 from_version: None,
1549 to_version: None,
1550 })
1551 }
1552
1553 async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
1554 Ok(Vec::new())
1555 }
1556
1557 fn namespace(&self) -> &str {
1558 &self.namespace
1559 }
1560
1561 fn supports_versions(&self) -> bool {
1562 false
1563 }
1564}
1565
1566struct EmptySecretProvider;
1567
1568#[async_trait]
1569impl SecretProvider for EmptySecretProvider {
1570 async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
1571 Err(SecretError::NotFound {
1572 provider: self.namespace().to_string(),
1573 id: id.clone(),
1574 })
1575 }
1576
1577 async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
1578 Ok(())
1579 }
1580
1581 async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
1582 Ok(RotationHandle {
1583 provider: self.namespace().to_string(),
1584 id: id.clone(),
1585 from_version: None,
1586 to_version: None,
1587 })
1588 }
1589
1590 async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
1591 Ok(Vec::new())
1592 }
1593
1594 fn namespace(&self) -> &'static str {
1595 "trigger-harness"
1596 }
1597
1598 fn supports_versions(&self) -> bool {
1599 false
1600 }
1601}
1602
1603struct ScopedTriggerHarnessState {
1604 path: PathBuf,
1605}
1606
1607impl ScopedTriggerHarnessState {
1608 fn new(label: &str) -> Result<Self, String> {
1609 clear_dispatcher_state_for_harness();
1610 let path =
1611 std::env::temp_dir().join(format!("harn-trigger-harness-{label}-{}", Uuid::new_v4()));
1612 fs::create_dir_all(&path).map_err(|error| error.to_string())?;
1613 Ok(Self { path })
1614 }
1615
1616 fn path(&self) -> &std::path::Path {
1617 &self.path
1618 }
1619}
1620
1621impl Drop for ScopedTriggerHarnessState {
1622 fn drop(&mut self) {
1623 clear_dispatcher_state_for_harness();
1624 crate::event_log::reset_active_event_log();
1625 let _ = fs::remove_dir_all(&self.path);
1626 }
1627}
1628
1629fn clear_dispatcher_state_for_harness() {
1630 crate::triggers::clear_dispatcher_state();
1631 clear_trigger_registry();
1632}
1633
1634pub(crate) fn scheduled_eval_cron_tick(trigger_id: &str, dedupe_key: &str) -> TriggerEvent {
1635 let tick_at = OffsetDateTime::from_unix_timestamp(1).expect("valid timestamp");
1636 TriggerEvent::new(
1637 ProviderId::from("cron"),
1638 "tick",
1639 Some(tick_at),
1640 dedupe_key,
1641 None,
1642 BTreeMap::new(),
1643 ProviderPayload::Known(KnownProviderPayload::Cron(
1644 crate::triggers::CronEventPayload {
1645 cron_id: Some(trigger_id.to_string()),
1646 schedule: Some("0 3 * * *".to_string()),
1647 tick_at,
1648 raw: json!({"timezone": "UTC"}),
1649 },
1650 )),
1651 SignatureStatus::Verified,
1652 )
1653}
1654
1655pub(crate) fn scheduled_eval_manifest(
1656 base_dir: &std::path::Path,
1657) -> Result<crate::orchestration::EvalPackManifest, String> {
1658 let payload = json!({
1659 "id": "scheduled-eval",
1660 "base_dir": base_dir.display().to_string(),
1661 "trials": 1,
1662 "metadata": {
1663 "model": "mock-model",
1664 "commit": "commit-a",
1665 "branch": "main",
1666 "tool_format": "native-json",
1667 "pipeline_rev": "rev-a",
1668 "ledger_namespace": "scheduled-eval"
1669 },
1670 "fixtures": [{
1671 "id": "pass-run",
1672 "kind": "run-record",
1673 "inline": {
1674 "_type": "workflow_run",
1675 "id": "run_pass",
1676 "workflow_id": "workflow_1",
1677 "task": "demo",
1678 "status": "completed",
1679 "usage": {
1680 "total_duration_ms": 1000,
1681 "total_cost": 0.25,
1682 "input_tokens": 3,
1683 "output_tokens": 4,
1684 "call_count": 1,
1685 "models": ["mock"]
1686 },
1687 "replay_fixture": {
1688 "_type": "replay_fixture",
1689 "expected_status": "completed",
1690 "stage_assertions": []
1691 }
1692 }
1693 }],
1694 "rubrics": [{
1695 "id": "completed",
1696 "kind": "deterministic",
1697 "assertions": [{"kind": "run-status", "expected": "completed"}]
1698 }],
1699 "cases": [{"id": "pass-case", "run": "pass-run", "rubrics": ["completed"]}]
1700 });
1701 crate::orchestration::normalize_eval_pack_manifest_value(&crate::stdlib::json_to_vm_value(
1702 &payload,
1703 ))
1704 .map_err(|error| error.to_string())
1705}
1706
1707async fn read_log_topic(
1708 log: Arc<AnyEventLog>,
1709 topic: &str,
1710) -> Result<Vec<(u64, LogEvent)>, String> {
1711 let topic = Topic::new(topic).map_err(|error| error.to_string())?;
1712 log.read_range(&topic, None, usize::MAX)
1713 .await
1714 .map_err(|error| error.to_string())
1715}
1716
1717pub async fn run_trigger_harness_fixture(fixture: &str) -> Result<TriggerHarnessResult, String> {
1718 TriggerTestHarness::new(parse_rfc3339("2026-04-19T00:00:00Z"))
1719 .run(fixture)
1720 .await
1721}
1722
1723async fn build_inbox(event_log: &Arc<AnyEventLog>) -> Arc<InboxIndex> {
1724 let metrics = Arc::new(MetricsRegistry::default());
1725 Arc::new(
1726 InboxIndex::new(event_log.clone(), metrics)
1727 .await
1728 .expect("trigger harness inbox index should initialize"),
1729 )
1730}
1731
1732fn connector_ctx(
1733 event_log: Arc<AnyEventLog>,
1734 secrets: Arc<dyn SecretProvider>,
1735 inbox: Arc<InboxIndex>,
1736) -> ConnectorCtx {
1737 ConnectorCtx {
1738 event_log,
1739 secrets,
1740 inbox,
1741 metrics: Arc::new(MetricsRegistry::default()),
1742 rate_limiter: Arc::new(RateLimiterFactory::default()),
1743 }
1744}
1745
1746fn cron_binding(
1747 id: &str,
1748 schedule: &str,
1749 timezone: &str,
1750 catchup_mode: CatchupMode,
1751) -> ConnectorTriggerBinding {
1752 let mut binding = ConnectorTriggerBinding::new(ProviderId::from("cron"), "cron", id);
1753 binding.config = json!({
1754 "schedule": schedule,
1755 "timezone": timezone,
1756 "catchup_mode": catchup_mode,
1757 });
1758 binding
1759}
1760
1761fn webhook_binding(
1762 variant: WebhookSignatureVariant,
1763 dedupe_key: Option<&str>,
1764) -> ConnectorTriggerBinding {
1765 let mut binding =
1766 ConnectorTriggerBinding::new(ProviderId::from("webhook"), "webhook", "webhook.fixture");
1767 binding.dedupe_key = dedupe_key.map(ToString::to_string);
1768 binding.config = json!({
1769 "match": { "path": "/hooks/test" },
1770 "secrets": { "signing_secret": "webhook/test-signing-secret" },
1771 "webhook": {
1772 "signature_scheme": match variant {
1773 WebhookSignatureVariant::Standard => "standard",
1774 WebhookSignatureVariant::Stripe => "stripe",
1775 WebhookSignatureVariant::GitHub => "github",
1776 WebhookSignatureVariant::Slack => "slack",
1777 },
1778 "source": "fixtures",
1779 }
1780 });
1781 binding
1782}
1783
1784fn standard_raw_inbound() -> RawInbound {
1785 let mut raw = RawInbound::new(
1786 "",
1787 BTreeMap::from([
1788 (
1789 "webhook-id".to_string(),
1790 "msg_p5jXN8AQM9LWM0D4loKWxJek".to_string(),
1791 ),
1792 (
1793 "webhook-signature".to_string(),
1794 "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=".to_string(),
1795 ),
1796 ("webhook-timestamp".to_string(), "1614265330".to_string()),
1797 ("Content-Type".to_string(), "application/json".to_string()),
1798 ]),
1799 br#"{"test": 2432232314}"#.to_vec(),
1800 );
1801 raw.received_at = OffsetDateTime::from_unix_timestamp(1_614_265_330).unwrap();
1802 raw
1803}
1804
1805fn github_raw_inbound() -> RawInbound {
1806 let mut raw = RawInbound::new(
1807 "",
1808 BTreeMap::from([
1809 (
1810 "X-Hub-Signature-256".to_string(),
1811 "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
1812 .to_string(),
1813 ),
1814 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
1815 ("X-GitHub-Event".to_string(), "ping".to_string()),
1816 ("Content-Type".to_string(), "application/json".to_string()),
1817 ]),
1818 b"Hello, World!".to_vec(),
1819 );
1820 raw.received_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1821 raw
1822}
1823
1824fn manifest_spec(id: &str, fingerprint: &str) -> TriggerBindingSpec {
1825 TriggerBindingSpec {
1826 id: id.to_string(),
1827 source: TriggerBindingSource::Manifest,
1828 kind: "webhook".to_string(),
1829 provider: ProviderId::from("github"),
1830 autonomy_tier: crate::AutonomyTier::ActAuto,
1831 handler: TriggerHandlerSpec::Worker {
1832 queue: format!("{id}-queue"),
1833 },
1834 dispatch_priority: crate::WorkerQueuePriority::Normal,
1835 when: None,
1836 when_budget: None,
1837 retry: TriggerRetryConfig::default(),
1838 match_events: vec!["issues.opened".to_string()],
1839 dedupe_key: Some("event.dedupe_key".to_string()),
1840 dedupe_retention_days: DEFAULT_INBOX_RETENTION_DAYS,
1841 filter: None,
1842 daily_cost_usd: Some(5.0),
1843 hourly_cost_usd: None,
1844 max_autonomous_decisions_per_hour: None,
1845 max_autonomous_decisions_per_day: None,
1846 on_budget_exhausted: crate::TriggerBudgetExhaustionStrategy::False,
1847 max_concurrent: Some(2),
1848 flow_control: crate::triggers::TriggerFlowControlConfig::default(),
1849 aggregation: None,
1850 manifest_path: Some(PathBuf::from("runtime://trigger-harness")),
1851 package_name: Some("trigger-harness".to_string()),
1852 definition_fingerprint: fingerprint.to_string(),
1853 }
1854}
1855
1856fn binding_state(bindings: &[TriggerBindingSnapshot], version: u32) -> Option<TriggerState> {
1857 bindings
1858 .iter()
1859 .find(|binding| binding.id == "reload.fixture" && binding.version == version)
1860 .map(|binding| binding.state)
1861}
1862
1863fn file_event_log(path: PathBuf) -> Result<Arc<AnyEventLog>, String> {
1864 Ok(Arc::new(AnyEventLog::File(
1865 FileEventLog::open(path, 32).map_err(|error| error.to_string())?,
1866 )))
1867}
1868
1869fn unique_temp_dir() -> Result<PathBuf, String> {
1870 let path = std::env::temp_dir().join(format!(
1871 "harn-trigger-harness-{}-{}",
1872 std::process::id(),
1873 Uuid::now_v7()
1874 ));
1875 fs::create_dir_all(&path).map_err(|error| error.to_string())?;
1876 Ok(path)
1877}
1878
1879async fn persist_in_flight(
1880 log: &Arc<AnyEventLog>,
1881 record: PersistedInFlight,
1882) -> Result<(), crate::event_log::LogError> {
1883 let topic = Topic::new(IN_FLIGHT_TOPIC).expect("in-flight topic should be valid");
1884 log.append(
1885 &topic,
1886 LogEvent::new(
1887 "in_flight",
1888 serde_json::to_value(record).expect("persisted in-flight record should serialize"),
1889 ),
1890 )
1891 .await?;
1892 Ok(())
1893}
1894
1895async fn load_pending_in_flight(
1896 log: &Arc<AnyEventLog>,
1897) -> Result<Vec<PersistedInFlight>, crate::event_log::LogError> {
1898 let topic = Topic::new(IN_FLIGHT_TOPIC).expect("in-flight topic should be valid");
1899 let events = log.read_range(&topic, None, usize::MAX).await?;
1900 let mut latest = HashMap::new();
1901 for (_, event) in events {
1902 let Ok(record) = serde_json::from_value::<PersistedInFlight>(event.payload) else {
1903 continue;
1904 };
1905 latest.insert(record.event_id.clone(), record);
1906 }
1907 Ok(latest
1908 .into_values()
1909 .filter(|record| record.status == "started")
1910 .collect())
1911}
1912
1913fn synthetic_event(binding_id: &str, dedupe_key: &str, tenant_id: Option<&str>) -> TriggerEvent {
1914 TriggerEvent::new(
1915 ProviderId::from("webhook"),
1916 binding_id,
1917 Some(clock::now_utc()),
1918 dedupe_key,
1919 tenant_id.map(TenantId::new),
1920 BTreeMap::new(),
1921 ProviderPayload::Known(KnownProviderPayload::Webhook(GenericWebhookPayload {
1922 source: Some("trigger-test-harness".to_string()),
1923 content_type: Some("application/json".to_string()),
1924 raw: json!({
1925 "binding_id": binding_id,
1926 }),
1927 })),
1928 SignatureStatus::Unsigned,
1929 )
1930}
1931
1932async fn a2a_push_fixture_connector() -> Result<(A2aPushConnector, Arc<InboxIndex>), String> {
1933 let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
1934 let inbox = build_inbox(&log).await;
1935 let mut connector = A2aPushConnector::new();
1936 connector
1937 .init(connector_ctx(
1938 log,
1939 Arc::new(EmptySecretProvider),
1940 inbox.clone(),
1941 ))
1942 .await
1943 .map_err(|error| error.to_string())?;
1944 connector
1945 .activate(&[ConnectorTriggerBinding {
1946 provider: ProviderId::from("a2a-push"),
1947 kind: crate::connectors::TriggerKind::from("a2a-push"),
1948 binding_id: "a2a.push.fixture".to_string(),
1949 dedupe_key: None,
1950 dedupe_retention_days: DEFAULT_INBOX_RETENTION_DAYS,
1951 config: json!({
1952 "a2a_push": {
1953 "expected_iss": "reviewer.prod",
1954 "expected_aud": "https://orchestrator.test/a2a/review",
1955 "expected_token": "opaque-token",
1956 "inline_jwks": {
1957 "keys": [{
1958 "kty": "oct",
1959 "kid": "test-key",
1960 "alg": "HS256",
1961 "k": "c2VjcmV0"
1962 }]
1963 },
1964 "algorithm": "HS256"
1965 }
1966 }),
1967 }])
1968 .await
1969 .map_err(|error| error.to_string())?;
1970 Ok((connector, inbox))
1971}
1972
1973fn a2a_push_raw(jti: &str) -> RawInbound {
1974 let mut headers = BTreeMap::new();
1975 headers.insert(
1976 "authorization".to_string(),
1977 format!("Bearer {}", a2a_push_fixture_jwt(jti)),
1978 );
1979 headers.insert(
1980 "content-type".to_string(),
1981 "application/a2a+json".to_string(),
1982 );
1983 let mut raw = RawInbound::new(
1984 "",
1985 headers,
1986 serde_json::to_vec(&json!({
1987 "statusUpdate": {
1988 "taskId": "task-123",
1989 "contextId": "ctx-123",
1990 "status": {"state": "completed"}
1991 }
1992 }))
1993 .expect("serialize a2a push fixture"),
1994 );
1995 raw.metadata = json!({"binding_id": "a2a.push.fixture"});
1996 raw
1997}
1998
1999#[derive(Serialize)]
2000struct A2aFixtureClaims {
2001 iss: String,
2002 aud: String,
2003 iat: i64,
2004 exp: i64,
2005 jti: String,
2006 token: String,
2007 #[serde(rename = "taskId")]
2008 task_id: String,
2009}
2010
2011fn a2a_push_fixture_jwt(jti: &str) -> String {
2012 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
2013 header.kid = Some("test-key".to_string());
2014 jsonwebtoken::encode(
2015 &header,
2016 &A2aFixtureClaims {
2017 iss: "reviewer.prod".to_string(),
2018 aud: "https://orchestrator.test/a2a/review".to_string(),
2019 iat: OffsetDateTime::now_utc().unix_timestamp(),
2020 exp: OffsetDateTime::now_utc().unix_timestamp() + 300,
2021 jti: jti.to_string(),
2022 token: "opaque-token".to_string(),
2023 task_id: "task-123".to_string(),
2024 },
2025 &jsonwebtoken::EncodingKey::from_secret(b"secret"),
2026 )
2027 .expect("encode fixture jwt")
2028}
2029
2030fn parse_rfc3339(raw: &str) -> OffsetDateTime {
2031 OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
2032 .expect("fixture timestamp should parse")
2033}
2034
2035fn format_rfc3339(value: OffsetDateTime) -> String {
2036 value
2037 .format(&time::format_description::well_known::Rfc3339)
2038 .unwrap_or_default()
2039}
2040
2041fn wait_until_wall_clock_after(timestamp: OffsetDateTime) {
2042 let timestamp_ms = timestamp.unix_timestamp_nanos() / 1_000_000;
2043 while OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000 <= timestamp_ms {
2044 std::hint::spin_loop();
2045 std::thread::yield_now();
2046 }
2047}
2048
2049fn signature_state_label(value: &SignatureStatus) -> &'static str {
2050 match value {
2051 SignatureStatus::Verified => "verified",
2052 SignatureStatus::Unsigned => "unsigned",
2053 SignatureStatus::Failed { .. } => "failed",
2054 }
2055}
2056
2057#[cfg(test)]
2058mod tests {
2059 use super::{run_trigger_harness_fixture, TRIGGER_TEST_FIXTURES};
2060
2061 #[tokio::test(flavor = "current_thread")]
2062 async fn every_trigger_harness_fixture_reports_success() {
2063 for fixture in TRIGGER_TEST_FIXTURES {
2064 let result = run_trigger_harness_fixture(fixture)
2065 .await
2066 .unwrap_or_else(|error| panic!("{fixture} should run: {error}"));
2067 assert!(result.ok, "{fixture} should report success: {result:?}");
2068 }
2069 }
2070}