theway_daemon/trigger_engine/execution/
utils.rs1use std::sync::Arc;
6
7use parking_lot::Mutex;
8
9use crate::trigger_engine::event::{TriggerEvent, TriggerListener};
10use crate::trigger_engine::types::Trigger;
11
12use super::promotion::{PROMOTION_BODY_CAP_BYTES, sha256_hex, truncate_on_char_boundary};
13use super::types::TriggerPromptRequest;
14
15const CONTROL_PLANE_PROMPT_LABEL_CAP_CHARS: usize = 200;
16
17pub(super) fn cap_control_plane_audit_label(label: &str) -> String {
18 if label.chars().count() <= CONTROL_PLANE_PROMPT_LABEL_CAP_CHARS {
19 return label.to_string();
20 }
21 let mut out: String = label
22 .chars()
23 .take(CONTROL_PLANE_PROMPT_LABEL_CAP_CHARS.saturating_sub(1))
24 .collect();
25 out.push('…');
26 out
27}
28
29pub(super) fn build_trigger_prompt_request(
30 trigger: &Trigger,
31 reason: String,
32) -> TriggerPromptRequest {
33 let receiver_agent_id = validated_payload_agent_id(trigger, &["_meta", "receiver_agent_id"])
34 .or_else(|| validated_payload_agent_id(trigger, &["receiver_agent_id"]));
35 let sender_agent_id = validated_payload_agent_id(trigger, &["_meta", "sender_agent_id"])
36 .or_else(|| validated_payload_agent_id(trigger, &["sender_agent_id"]))
37 .or_else(|| validated_payload_agent_id(trigger, &["agent_id"]))
38 .unwrap_or_else(|| cap_control_plane_audit_label(&trigger.authority.principal_id));
39 let action_class = validated_payload_action_class(trigger, &["_meta", "action_class"])
40 .or_else(|| validated_payload_action_class(trigger, &["action_class"]))
41 .unwrap_or_else(|| cap_control_plane_audit_label(&trigger.event_label));
42 let trigger_summary = trigger
43 .payload_summary
44 .clone()
45 .map(|summary| truncate_on_char_boundary(summary, PROMOTION_BODY_CAP_BYTES).0);
46 let payload = serde_json::json!({
47 "source_kind": trigger.source_kind,
48 "source_label": cap_control_plane_audit_label(&trigger.source_label),
49 "event_label": cap_control_plane_audit_label(&trigger.event_label),
50 "payload_visibility": trigger.payload_visibility,
51 "payload_summary": trigger_summary,
52 "authority": {
53 "principal_id": trigger.authority.principal_id.clone(),
54 "principal_label": cap_control_plane_audit_label(&trigger.authority.principal_label),
55 "credential_scope": trigger.authority.credential_scope,
56 "allowed_source_actions": trigger.authority.allowed_source_actions.clone(),
57 }
58 });
59 let binding = serde_json::json!([
60 "trigger_prompt:v1",
61 trigger.idempotency_key.clone(),
62 trigger.trace_id.clone(),
63 trigger.source_kind,
64 trigger.source_label.clone(),
65 trigger.event_label.clone(),
66 receiver_agent_id.clone(),
67 sender_agent_id.clone(),
68 action_class.clone(),
69 ]);
70 let trigger_prompt_id = sha256_hex(&binding.to_string());
71 TriggerPromptRequest {
72 trigger_prompt_id,
73 trace_id: trigger.trace_id.clone(),
74 source_label: cap_control_plane_audit_label(&trigger.source_label),
75 receiver_agent_id,
76 sender_agent_id,
77 action_class,
78 trigger_summary,
79 payload,
80 reason: cap_trigger_prompt_reason(&reason),
81 }
82}
83
84fn validated_payload_agent_id(trigger: &Trigger, path: &[&str]) -> Option<String> {
85 let value = trigger_json_string(trigger, path)?;
86 uuid::Uuid::parse_str(&value).ok()?;
87 Some(value)
88}
89
90fn validated_payload_action_class(trigger: &Trigger, path: &[&str]) -> Option<String> {
91 let value = trigger_json_string(trigger, path)?;
92 is_valid_action_class(&value).then_some(value)
93}
94
95fn trigger_json_string(trigger: &Trigger, path: &[&str]) -> Option<String> {
96 let mut value = trigger.payload.as_ref()?;
97 for key in path {
98 value = value.get(*key)?;
99 }
100 value.as_str().map(str::to_string)
101}
102
103fn is_valid_action_class(value: &str) -> bool {
104 let mut chars = value.chars();
105 let Some(first) = chars.next() else {
106 return false;
107 };
108 let lower = value.to_ascii_lowercase();
109 if lower.starts_with("sk-") || lower.contains("bearer") || lower.contains("token") {
110 return false;
111 }
112 value.len() <= 64
113 && first.is_ascii_lowercase()
114 && chars.all(|ch| {
115 ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '_' | '-' | '.' | ':')
116 })
117}
118
119const TRIGGER_PROMPT_REASON_CAP_CHARS: usize = 512;
120
121pub(super) fn cap_trigger_prompt_reason(reason: &str) -> String {
122 if reason.chars().count() <= TRIGGER_PROMPT_REASON_CAP_CHARS {
123 return reason.to_string();
124 }
125 let mut out: String = reason
126 .chars()
127 .take(TRIGGER_PROMPT_REASON_CAP_CHARS.saturating_sub(1))
128 .collect();
129 out.push('…');
130 out
131}
132
133pub(super) fn emit_from_listeners(
138 listeners: &Arc<Mutex<Vec<TriggerListener>>>,
139 event: TriggerEvent,
140) {
141 let snapshot: Vec<TriggerListener> = listeners.lock().clone();
142 for listener in snapshot {
143 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| listener(event.clone())));
144 }
145}
146
147pub(super) fn preview_for_banner(text: &str, max_chars: usize) -> String {
150 if text.chars().count() <= max_chars {
151 return text.to_string();
152 }
153 let mut out: String = text.chars().take(max_chars).collect();
154 out.push('…');
155 out
156}
157
158#[cfg(test)]
159mod coverage_gap {
160 use super::*;
161 use crate::trigger_engine::types::{
162 CredentialScope, PayloadVisibility, ReplacementPolicy, SourceKind, TriggerAuthority,
163 TriggerSource,
164 };
165
166 fn sample_trigger(payload: Option<serde_json::Value>) -> Trigger {
167 Trigger {
168 source: TriggerSource::Mcp {
169 server_name: "github".into(),
170 method: "notifications/pr.merged".into(),
171 },
172 source_kind: SourceKind::Mcp,
173 source_label: "mcp:github".into(),
174 event_label: "pr_merged".into(),
175 payload_visibility: PayloadVisibility::Local,
176 payload_summary: None,
177 payload,
178 idempotency_key: "k".into(),
179 replacement_policy: ReplacementPolicy::Drop,
180 trace_id: "trace-utils".into(),
181 authority: TriggerAuthority {
182 principal_id: "mcp:github".into(),
183 principal_label: "github".into(),
184 credential_scope: CredentialScope::User,
185 allowed_source_actions: vec![],
186 expires_at: None,
187 },
188 received_at: chrono::Utc::now(),
189 }
190 }
191
192 #[test]
193 fn validated_payload_agent_id_rejects_non_string_and_bad_paths() {
194 let trigger = sample_trigger(Some(serde_json::json!({
195 "_meta": {"receiver_agent_id": 42},
196 "receiver_agent_id": "not-a-uuid",
197 })));
198 assert_eq!(
199 validated_payload_agent_id(&trigger, &["_meta", "receiver_agent_id"]),
200 None
201 );
202 assert_eq!(validated_payload_agent_id(&trigger, &["missing"]), None);
203 }
204
205 #[test]
206 fn validated_payload_action_class_rejects_invalid_shapes() {
207 let trigger = sample_trigger(Some(serde_json::json!({
208 "_meta": {"action_class": "Sk-secret"},
209 "action_class": "valid.class",
210 })));
211 assert_eq!(
212 validated_payload_action_class(&trigger, &["_meta", "action_class"]),
213 None
214 );
215 assert_eq!(
216 validated_payload_action_class(&trigger, &["action_class"]).as_deref(),
217 Some("valid.class")
218 );
219
220 let empty = sample_trigger(Some(serde_json::json!({"action_class": ""})));
221 assert_eq!(
222 validated_payload_action_class(&empty, &["action_class"]),
223 None
224 );
225 }
226
227 #[test]
228 fn is_valid_action_class_rejects_uppercase_length_and_bad_chars() {
229 assert!(!is_valid_action_class("Uppercase"));
230 assert!(!is_valid_action_class("a".repeat(65).as_str()));
231 assert!(!is_valid_action_class("bad class"));
232 assert!(!is_valid_action_class("contains-token-secret"));
233 assert!(!is_valid_action_class("sk-prefixed"));
234 assert!(is_valid_action_class("valid_action.class:with-all"));
235 }
236
237 #[test]
238 fn trigger_json_string_handles_missing_paths_and_scalars() {
239 let trigger = sample_trigger(Some(serde_json::json!({
240 "nested": {"value": 1}
241 })));
242 assert_eq!(trigger_json_string(&trigger, &["nested"]), None);
243 assert_eq!(trigger_json_string(&trigger, &["nested", "value"]), None);
244 assert_eq!(trigger_json_string(&trigger, &["nested", "missing"]), None);
245 }
246
247 #[test]
248 fn cap_control_plane_audit_label_truncates_long_labels() {
249 let label = "x".repeat(201);
250 let capped = cap_control_plane_audit_label(&label);
251 assert_eq!(capped.chars().count(), 200);
252 assert!(capped.ends_with('…'));
253 assert_eq!(cap_control_plane_audit_label("short"), "short");
254 }
255
256 #[test]
257 fn cap_trigger_prompt_reason_preserves_short_and_truncates_long() {
258 let short = "a short reason";
259 assert_eq!(cap_trigger_prompt_reason(short), short);
260 let long = "r".repeat(600);
261 let capped = cap_trigger_prompt_reason(&long);
262 assert_eq!(capped.chars().count(), 512);
263 assert!(capped.ends_with('…'));
264 }
265
266 #[test]
267 fn preview_for_banner_preserves_short_and_truncates_long() {
268 assert_eq!(preview_for_banner("hello", 10), "hello");
269 let capped = preview_for_banner(&"x".repeat(200), 80);
270 assert_eq!(capped.chars().count(), 81);
271 assert!(capped.ends_with('…'));
272 }
273
274 #[test]
275 fn is_valid_action_class_rejects_bearer_and_control_chars() {
276 assert!(!is_valid_action_class("Bearer invalid"));
277 assert!(!is_valid_action_class("bad!char"));
278 assert!(is_valid_action_class("ok.action_class:with-dashes"));
279 }
280
281 #[test]
282 fn build_trigger_prompt_request_resolves_nested_and_top_level_fields() {
283 let trigger = sample_trigger(Some(serde_json::json!({
284 "_meta": {
285 "receiver_agent_id": "11111111-1111-4111-8111-111111111111",
286 "sender_agent_id": "22222222-2222-4222-8222-222222222222",
287 "action_class": "nested.class",
288 },
289 "receiver_agent_id": "33333333-3333-4333-8333-333333333333",
290 "sender_agent_id": "44444444-4444-4444-8444-444444444444",
291 "agent_id": "55555555-5555-4555-8555-555555555555",
292 "action_class": "top.class",
293 "payload_summary": "summary",
294 })));
295 let request = build_trigger_prompt_request(&trigger, "why".into());
296 assert_eq!(
297 request.receiver_agent_id.as_deref(),
298 Some("11111111-1111-4111-8111-111111111111")
299 );
300 assert_eq!(
301 request.sender_agent_id.as_str(),
302 "22222222-2222-4222-8222-222222222222"
303 );
304 assert_eq!(request.action_class, "nested.class");
305 }
306
307 #[test]
308 fn emit_from_listeners_isolates_panicking_listener() {
309 use crate::trigger_engine::event::TriggerEvent;
310 let listeners: Arc<Mutex<Vec<TriggerListener>>> = Arc::new(Mutex::new(Vec::new()));
311 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
312 let calls_sink = calls.clone();
313 listeners.lock().push(Arc::new(move |_event: TriggerEvent| {
314 panic!("listener panic");
315 }));
316 listeners.lock().push(Arc::new(move |event: TriggerEvent| {
317 let _ = event;
318 calls_sink.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
319 }));
320 emit_from_listeners(
321 &listeners,
322 TriggerEvent::TriggerHandlingStart {
323 idempotency_key: "k".into(),
324 source_kind: SourceKind::Mcp,
325 source_label: "src".into(),
326 event_label: "evt".into(),
327 trace_id: "trace".into(),
328 },
329 );
330 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
331 }
332}