1use std::{
19 collections::{BTreeMap, BTreeSet},
20 sync::Arc,
21 time::Duration,
22};
23
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26use termivar_core::{EntityId, Evidence};
27use thiserror::Error;
28
29use crate::decision_loop::{
30 command_requiring_host_policy_context, execution_command_action_id, ActiveEvidenceSnapshots,
31};
32use crate::planner::ActionSuppressionContext;
33use crate::{
34 DecisionActionOrigin, DecisionLoop, DecisionLoopCommand, DecisionLoopError, DecisionLoopState,
35 DecisionOutcomeReport, DecisionPlanningReport, DecisionReasoningCommitReceipt, DecisionSession,
36 ExperienceStore, KnowledgeBase, KnowledgeBaseError, KnowledgeSnapshot, KnowledgeWrite,
37 PayloadStrategyRef, RuntimeLimitExceeded, VerificationCase,
38};
39
40mod execution;
41mod failures;
42mod receipts;
43mod registry;
44
45pub use execution::{
46 DecisionActionExecutor, DecisionExecutionClass, DecisionExecutionLimits,
47 DecisionExecutionRequest, DecisionExecutionStage,
48};
49pub use failures::{DecisionExecutionFailureKind, DecisionExecutorError, DecisionRunnerError};
50pub use receipts::{DecisionEvidenceReceipt, DecisionExecutionFailureReceipt, DecisionRunnerTurn};
51pub use registry::DecisionExecutorRegistry;
52
53struct ExecutionAuthority<'a> {
54 limits: DecisionExecutionLimits,
55 suppressions: &'a ActionSuppressionContext,
56}
57
58impl<'a> ExecutionAuthority<'a> {
59 const fn new(
60 limits: DecisionExecutionLimits,
61 suppressions: &'a ActionSuppressionContext,
62 ) -> Self {
63 Self {
64 limits,
65 suppressions,
66 }
67 }
68}
69
70pub(crate) struct ContinuationAuthority<'a> {
71 evidence: DecisionEvidenceReceipt,
72 suppressions: &'a ActionSuppressionContext,
73}
74
75impl<'a> ContinuationAuthority<'a> {
76 pub(crate) const fn new(
77 evidence: DecisionEvidenceReceipt,
78 suppressions: &'a ActionSuppressionContext,
79 ) -> Self {
80 Self {
81 evidence,
82 suppressions,
83 }
84 }
85}
86
87pub struct DecisionRunnerAdapter {
89 executors: DecisionExecutorRegistry,
90}
91
92impl DecisionRunnerAdapter {
93 pub fn new(executors: DecisionExecutorRegistry) -> Self {
95 Self { executors }
96 }
97
98 pub fn executors(&self) -> &DecisionExecutorRegistry {
100 &self.executors
101 }
102
103 pub fn execution_class_for_command(
107 &self,
108 command: &DecisionLoopCommand,
109 ) -> Result<DecisionExecutionClass, DecisionRunnerError> {
110 let (stage, action_id, requested_executor) = match command {
111 DecisionLoopCommand::ExecuteAction { case, executor, .. } => (
112 DecisionExecutionStage::Passive,
113 case.action_id(),
114 executor.as_deref(),
115 ),
116 DecisionLoopCommand::CollectActiveEvidence { case } => {
117 (DecisionExecutionStage::Active, case.action_id(), None)
118 },
119 DecisionLoopCommand::Replan => {
120 return Err(DecisionRunnerError::NonExecutionCommand { command: "replan" })
121 },
122 DecisionLoopCommand::Complete { .. } => {
123 return Err(DecisionRunnerError::NonExecutionCommand {
124 command: "complete",
125 })
126 },
127 DecisionLoopCommand::AwaitHumanReview { .. } => {
128 return Err(DecisionRunnerError::NonExecutionCommand {
129 command: "await_human_review",
130 })
131 },
132 DecisionLoopCommand::Halt { .. } => {
133 return Err(DecisionRunnerError::NonExecutionCommand { command: "halt" })
134 },
135 };
136 let (_, executor) = self
137 .executors
138 .resolve(stage, action_id, requested_executor)?;
139 Ok(executor.execution_class())
140 }
141
142 pub(crate) fn validate_command_suppression(
145 &self,
146 command: &DecisionLoopCommand,
147 suppressions: &ActionSuppressionContext,
148 ) -> Result<(), DecisionRunnerError> {
149 let Some(action_id) = execution_command_action_id(command) else {
150 return Ok(());
151 };
152 if suppressions
153 .defense_suppressed_actions()
154 .contains(action_id)
155 {
156 return Err(DecisionRunnerError::ActionSuppressedByDefense {
157 action_id: action_id.to_owned(),
158 });
159 }
160 if suppressions.policy_suppressed_actions().contains(action_id) {
161 return Err(DecisionRunnerError::ActionSuppressedByHostPolicy {
162 action_id: action_id.to_owned(),
163 });
164 }
165 Ok(())
166 }
167
168 pub(crate) fn replan_defense_suppressed_command(
176 &self,
177 command: &DecisionLoopCommand,
178 session: &mut DecisionSession,
179 suppressions: &ActionSuppressionContext,
180 ) -> Result<bool, DecisionRunnerError> {
181 let Some(action_id) = execution_command_action_id(command) else {
182 return Ok(false);
183 };
184 if !suppressions
185 .defense_suppressed_actions()
186 .contains(action_id)
187 {
188 return Ok(false);
189 }
190 match command {
191 DecisionLoopCommand::ExecuteAction { case, .. } => {
192 validate_session_case(session, DecisionExecutionStage::Passive, case)?;
193 },
194 DecisionLoopCommand::CollectActiveEvidence { case } => {
195 validate_session_case(session, DecisionExecutionStage::Active, case)?;
196 },
197 DecisionLoopCommand::Replan
198 | DecisionLoopCommand::Complete { .. }
199 | DecisionLoopCommand::AwaitHumanReview { .. }
200 | DecisionLoopCommand::Halt { .. } => return Ok(false),
201 }
202 session.replan_after_defense_suppression()?;
203 Ok(true)
204 }
205
206 pub async fn execute_command(
212 &self,
213 command: &DecisionLoopCommand,
214 knowledge: &KnowledgeBase,
215 ) -> Result<DecisionEvidenceReceipt, DecisionRunnerError> {
216 self.execute_command_with_limits(command, knowledge, DecisionExecutionLimits::default())
217 .await
218 }
219
220 pub async fn execute_command_with_limits(
222 &self,
223 command: &DecisionLoopCommand,
224 knowledge: &KnowledgeBase,
225 limits: DecisionExecutionLimits,
226 ) -> Result<DecisionEvidenceReceipt, DecisionRunnerError> {
227 let (case, stage, origin, delay_ms, requested_executor) = match command {
228 DecisionLoopCommand::ExecuteAction {
229 case,
230 executor,
231 origin,
232 delay_ms,
233 } => (
234 case,
235 DecisionExecutionStage::Passive,
236 Some(*origin),
237 *delay_ms,
238 executor.as_deref(),
239 ),
240 DecisionLoopCommand::CollectActiveEvidence { case } => {
241 (case, DecisionExecutionStage::Active, None, None, None)
242 },
243 DecisionLoopCommand::Replan => {
244 return Err(DecisionRunnerError::NonExecutionCommand { command: "replan" })
245 },
246 DecisionLoopCommand::Complete { .. } => {
247 return Err(DecisionRunnerError::NonExecutionCommand {
248 command: "complete",
249 })
250 },
251 DecisionLoopCommand::AwaitHumanReview { .. } => {
252 return Err(DecisionRunnerError::NonExecutionCommand {
253 command: "await_human_review",
254 })
255 },
256 DecisionLoopCommand::Halt { .. } => {
257 return Err(DecisionRunnerError::NonExecutionCommand { command: "halt" })
258 },
259 };
260
261 let (executor_id, executor) =
262 self.executors
263 .resolve(stage, case.action_id(), requested_executor)?;
264 if let Some(strategy) = case.payload_strategy() {
265 if !executor.supports_payload_strategy(strategy) {
266 return Err(DecisionRunnerError::UnsupportedPayloadStrategy {
267 executor_id,
268 strategy: strategy.clone(),
269 });
270 }
271 }
272 if let Some(delay_ms) = delay_ms.filter(|delay| *delay > 0) {
273 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
274 }
275
276 let baseline = (stage == DecisionExecutionStage::Active)
277 .then(|| knowledge.snapshot_for_subject(case.subject()));
278 let request = DecisionExecutionRequest::new(case.clone(), stage, origin, delay_ms, limits);
279 let evidence = match executor.execution_class() {
284 DecisionExecutionClass::TransportBound => executor.execute(&request).await,
285 DecisionExecutionClass::LocalKnowledge => {
286 let snapshot = knowledge.snapshot_for_subject(case.subject());
287 executor.execute_with_snapshot(&request, &snapshot).await
288 },
289 }
290 .map_err(|source| {
291 let source = source.with_execution_context(request.clone(), executor_id.clone());
292 DecisionRunnerError::Executor {
293 executor_id: executor_id.clone(),
294 source,
295 }
296 })?;
297 validate_evidence(&evidence, case, &executor_id)?;
298 let receipt_evidence = evidence.clone();
299 let writes = knowledge.insert_evidence_batch(evidence)?;
300 let after_execution = knowledge.snapshot_for_subject(case.subject());
301
302 Ok(DecisionEvidenceReceipt {
303 case: case.clone(),
304 stage,
305 executor_id,
306 evidence: receipt_evidence,
307 writes,
308 baseline,
309 after_execution,
310 })
311 }
312
313 pub async fn drive_command(
321 &self,
322 decision_loop: &DecisionLoop,
323 command: &DecisionLoopCommand,
324 knowledge: &KnowledgeBase,
325 experience: &mut ExperienceStore,
326 session: &mut DecisionSession,
327 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
328 self.drive_command_with_optional_suppressions(
329 decision_loop,
330 command,
331 knowledge,
332 experience,
333 session,
334 DecisionExecutionLimits::default(),
335 None,
336 )
337 .await
338 }
339
340 pub async fn drive_command_with_suppressed_actions(
345 &self,
346 decision_loop: &DecisionLoop,
347 command: &DecisionLoopCommand,
348 knowledge: &KnowledgeBase,
349 experience: &mut ExperienceStore,
350 session: &mut DecisionSession,
351 host_suppressed_actions: &BTreeSet<String>,
352 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
353 let suppressions = ActionSuppressionContext::policy_only(host_suppressed_actions);
354 self.drive_command_with_action_suppressions(
355 decision_loop,
356 command,
357 knowledge,
358 experience,
359 session,
360 &suppressions,
361 )
362 .await
363 }
364
365 pub(crate) async fn drive_command_with_action_suppressions(
366 &self,
367 decision_loop: &DecisionLoop,
368 command: &DecisionLoopCommand,
369 knowledge: &KnowledgeBase,
370 experience: &mut ExperienceStore,
371 session: &mut DecisionSession,
372 suppressions: &ActionSuppressionContext,
373 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
374 self.drive_command_with_optional_suppressions(
375 decision_loop,
376 command,
377 knowledge,
378 experience,
379 session,
380 DecisionExecutionLimits::default(),
381 Some(suppressions),
382 )
383 .await
384 }
385
386 pub async fn drive_command_with_limits(
388 &self,
389 decision_loop: &DecisionLoop,
390 command: &DecisionLoopCommand,
391 knowledge: &KnowledgeBase,
392 experience: &mut ExperienceStore,
393 session: &mut DecisionSession,
394 limits: DecisionExecutionLimits,
395 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
396 self.drive_command_with_optional_suppressions(
397 decision_loop,
398 command,
399 knowledge,
400 experience,
401 session,
402 limits,
403 None,
404 )
405 .await
406 }
407
408 #[allow(clippy::too_many_arguments)]
413 pub async fn drive_command_with_limits_and_suppressed_actions(
414 &self,
415 decision_loop: &DecisionLoop,
416 command: &DecisionLoopCommand,
417 knowledge: &KnowledgeBase,
418 experience: &mut ExperienceStore,
419 session: &mut DecisionSession,
420 limits: DecisionExecutionLimits,
421 host_suppressed_actions: &BTreeSet<String>,
422 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
423 let suppressions = ActionSuppressionContext::policy_only(host_suppressed_actions);
424 self.drive_command_with_limits_and_action_suppressions(
425 decision_loop,
426 command,
427 knowledge,
428 experience,
429 session,
430 ExecutionAuthority::new(limits, &suppressions),
431 )
432 .await
433 }
434
435 async fn drive_command_with_limits_and_action_suppressions(
436 &self,
437 decision_loop: &DecisionLoop,
438 command: &DecisionLoopCommand,
439 knowledge: &KnowledgeBase,
440 experience: &mut ExperienceStore,
441 session: &mut DecisionSession,
442 authority: ExecutionAuthority<'_>,
443 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
444 self.drive_command_with_optional_suppressions(
445 decision_loop,
446 command,
447 knowledge,
448 experience,
449 session,
450 authority.limits,
451 Some(authority.suppressions),
452 )
453 .await
454 }
455
456 #[allow(clippy::too_many_arguments)]
457 async fn drive_command_with_optional_suppressions(
458 &self,
459 decision_loop: &DecisionLoop,
460 command: &DecisionLoopCommand,
461 knowledge: &KnowledgeBase,
462 experience: &mut ExperienceStore,
463 session: &mut DecisionSession,
464 limits: DecisionExecutionLimits,
465 host_suppressions: Option<&ActionSuppressionContext>,
466 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
467 if host_suppressions.is_none() {
468 if let Some(command) = command_requiring_host_policy_context(command) {
469 return Err(DecisionRunnerError::HostPolicyContextRequired { command });
470 }
471 }
472 if let Some(suppressions) = host_suppressions {
473 match self.validate_command_suppression(command, suppressions) {
474 Err(source @ DecisionRunnerError::ActionSuppressedByDefense { .. }) => {
475 decision_loop.validate_execution_command_authority(knowledge, command)?;
476 if !self.replan_defense_suppressed_command(command, session, suppressions)? {
477 return Err(source);
478 }
479 let planning = decision_loop.plan_next_with_action_suppressions(
480 knowledge,
481 experience,
482 session,
483 suppressions,
484 )?;
485 return Ok(DecisionRunnerTurn::Planning(Box::new(planning)));
486 },
487 Err(source) => return Err(source),
488 Ok(()) => {},
489 }
490 }
491 decision_loop.validate_execution_command_authority(knowledge, command)?;
492 match command {
493 DecisionLoopCommand::ExecuteAction { .. }
494 | DecisionLoopCommand::CollectActiveEvidence { .. } => {
495 let evidence = self
496 .execute_session_command_with_limits(command, knowledge, session, limits)
497 .await?;
498 self.resume_session_command_with_optional_suppressions(
499 decision_loop,
500 command,
501 knowledge,
502 experience,
503 session,
504 evidence,
505 host_suppressions,
506 )
507 },
508 DecisionLoopCommand::Replan => {
509 let planning = match host_suppressions {
510 Some(suppressions) => decision_loop.plan_next_with_action_suppressions(
511 knowledge,
512 experience,
513 session,
514 suppressions,
515 )?,
516 None => decision_loop.plan_next(knowledge, experience, session)?,
517 };
518 Ok(DecisionRunnerTurn::Planning(Box::new(planning)))
519 },
520 DecisionLoopCommand::Complete { .. }
521 | DecisionLoopCommand::AwaitHumanReview { .. }
522 | DecisionLoopCommand::Halt { .. } => Ok(DecisionRunnerTurn::Terminal(command.clone())),
523 }
524 }
525
526 pub(crate) async fn execute_session_command_with_limits(
527 &self,
528 command: &DecisionLoopCommand,
529 knowledge: &KnowledgeBase,
530 session: &DecisionSession,
531 limits: DecisionExecutionLimits,
532 ) -> Result<DecisionEvidenceReceipt, DecisionRunnerError> {
533 match command {
534 DecisionLoopCommand::ExecuteAction { case, .. } => {
535 validate_session_case(session, DecisionExecutionStage::Passive, case)?;
536 },
537 DecisionLoopCommand::CollectActiveEvidence { case } => {
538 validate_session_case(session, DecisionExecutionStage::Active, case)?;
539 },
540 DecisionLoopCommand::Replan => {
541 return Err(DecisionRunnerError::NonExecutionCommand { command: "replan" });
542 },
543 DecisionLoopCommand::Complete { .. } => {
544 return Err(DecisionRunnerError::NonExecutionCommand {
545 command: "complete",
546 });
547 },
548 DecisionLoopCommand::AwaitHumanReview { .. } => {
549 return Err(DecisionRunnerError::NonExecutionCommand {
550 command: "await_human_review",
551 });
552 },
553 DecisionLoopCommand::Halt { .. } => {
554 return Err(DecisionRunnerError::NonExecutionCommand { command: "halt" });
555 },
556 }
557 self.execute_command_with_limits(command, knowledge, limits)
558 .await
559 }
560
561 #[cfg(test)]
562 pub(crate) fn resume_session_command(
563 &self,
564 decision_loop: &DecisionLoop,
565 command: &DecisionLoopCommand,
566 knowledge: &KnowledgeBase,
567 experience: &mut ExperienceStore,
568 session: &mut DecisionSession,
569 evidence: DecisionEvidenceReceipt,
570 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
571 self.resume_session_command_with_optional_suppressions(
572 decision_loop,
573 command,
574 knowledge,
575 experience,
576 session,
577 evidence,
578 None,
579 )
580 }
581
582 pub(crate) fn resume_session_command_with_action_suppressions(
583 &self,
584 decision_loop: &DecisionLoop,
585 command: &DecisionLoopCommand,
586 knowledge: &KnowledgeBase,
587 experience: &mut ExperienceStore,
588 session: &mut DecisionSession,
589 continuation: ContinuationAuthority<'_>,
590 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
591 self.resume_session_command_with_optional_suppressions(
592 decision_loop,
593 command,
594 knowledge,
595 experience,
596 session,
597 continuation.evidence,
598 Some(continuation.suppressions),
599 )
600 }
601
602 #[allow(clippy::too_many_arguments)]
603 fn resume_session_command_with_optional_suppressions(
604 &self,
605 decision_loop: &DecisionLoop,
606 command: &DecisionLoopCommand,
607 knowledge: &KnowledgeBase,
608 experience: &mut ExperienceStore,
609 session: &mut DecisionSession,
610 evidence: DecisionEvidenceReceipt,
611 host_suppressions: Option<&ActionSuppressionContext>,
612 ) -> Result<DecisionRunnerTurn, DecisionRunnerError> {
613 let decision = (|| -> Result<Box<DecisionOutcomeReport>, DecisionRunnerError> {
614 match command {
615 DecisionLoopCommand::ExecuteAction { case, .. } => {
616 validate_session_case(session, DecisionExecutionStage::Passive, case)?;
617 let report = match host_suppressions {
618 Some(suppressions) => decision_loop
619 .submit_passive_with_action_suppressions(
620 knowledge,
621 experience,
622 session,
623 suppressions,
624 ),
625 None => decision_loop.submit_passive(knowledge, experience, session),
626 }?;
627 Ok(Box::new(report))
628 },
629 DecisionLoopCommand::CollectActiveEvidence { case } => {
630 validate_session_case(session, DecisionExecutionStage::Active, case)?;
631 let baseline = evidence
632 .baseline()
633 .ok_or(DecisionRunnerError::MissingActiveBaseline)?;
634 let report = match host_suppressions {
635 Some(suppressions) => decision_loop.submit_active_with_action_suppressions(
636 knowledge,
637 experience,
638 session,
639 ActiveEvidenceSnapshots::new(baseline, evidence.after_execution()),
640 suppressions,
641 ),
642 None => decision_loop.submit_active(
643 knowledge,
644 experience,
645 session,
646 baseline,
647 evidence.after_execution(),
648 ),
649 }?;
650 Ok(Box::new(report))
651 },
652 DecisionLoopCommand::Replan => {
653 Err(DecisionRunnerError::NonExecutionCommand { command: "replan" })
654 },
655 DecisionLoopCommand::Complete { .. } => {
656 Err(DecisionRunnerError::NonExecutionCommand {
657 command: "complete",
658 })
659 },
660 DecisionLoopCommand::AwaitHumanReview { .. } => {
661 Err(DecisionRunnerError::NonExecutionCommand {
662 command: "await_human_review",
663 })
664 },
665 DecisionLoopCommand::Halt { .. } => {
666 Err(DecisionRunnerError::NonExecutionCommand { command: "halt" })
667 },
668 }
669 })();
670
671 match decision {
672 Ok(decision) => Ok(DecisionRunnerTurn::Outcome {
673 evidence: Box::new(evidence),
674 decision,
675 }),
676 Err(source) => Err(DecisionRunnerError::OutcomeAfterEvidenceCommit {
677 receipt: Box::new(evidence),
678 source: Box::new(source),
679 }),
680 }
681 }
682}
683
684fn validate_session_case(
685 session: &DecisionSession,
686 stage: DecisionExecutionStage,
687 command_case: &VerificationCase,
688) -> Result<(), DecisionRunnerError> {
689 let outstanding = match (stage, session.state()) {
690 (DecisionExecutionStage::Passive, DecisionLoopState::AwaitingPassive { case })
691 | (DecisionExecutionStage::Active, DecisionLoopState::AwaitingActive { case }) => case,
692 (_, state) => {
693 return Err(DecisionRunnerError::UnexpectedSessionState {
694 expected: stage,
695 actual: session_state_name(state),
696 })
697 },
698 };
699 if outstanding != command_case {
700 return Err(DecisionRunnerError::CommandCaseMismatch {
701 expected: outstanding.id().to_owned(),
702 actual: command_case.id().to_owned(),
703 });
704 }
705 Ok(())
706}
707
708fn session_state_name(state: &DecisionLoopState) -> &'static str {
709 match state {
710 DecisionLoopState::Ready => "ready",
711 DecisionLoopState::AwaitingPassive { .. } => "awaiting_passive",
712 DecisionLoopState::AwaitingActive { .. } => "awaiting_active",
713 DecisionLoopState::Completed => "completed",
714 DecisionLoopState::Halted { .. } => "halted",
715 }
716}
717
718fn validate_evidence(
719 evidence: &[Evidence],
720 case: &VerificationCase,
721 executor_id: &str,
722) -> Result<(), DecisionRunnerError> {
723 for observation in evidence {
724 if observation.subject() != case.subject() {
725 return Err(DecisionRunnerError::EvidenceSubjectMismatch {
726 evidence_id: observation.id().to_string(),
727 expected: case.subject().clone(),
728 actual: observation.subject().clone(),
729 });
730 }
731 if observation.source().component() != executor_id {
732 return Err(DecisionRunnerError::EvidenceSourceMismatch {
733 evidence_id: observation.id().to_string(),
734 expected: executor_id.to_owned(),
735 actual: observation.source().component().to_owned(),
736 });
737 }
738 if observation.source().correlation_id() != Some(case.id()) {
739 return Err(DecisionRunnerError::EvidenceCorrelationMismatch {
740 evidence_id: observation.id().to_string(),
741 expected: case.id().to_owned(),
742 actual: observation.source().correlation_id().map(str::to_owned),
743 });
744 }
745 }
746 Ok(())
747}
748
749#[cfg(feature = "plugins")]
756pub trait PluginExecutionRequestProvider: Send + Sync {
757 fn request_for(
759 &self,
760 request: &DecisionExecutionRequest,
761 ) -> Result<crate::PluginExecutionRequest, DecisionExecutorError>;
762}
763
764#[cfg(feature = "plugins")]
765impl<F> PluginExecutionRequestProvider for F
766where
767 F: Fn(
768 &DecisionExecutionRequest,
769 ) -> Result<crate::PluginExecutionRequest, DecisionExecutorError>
770 + Send
771 + Sync,
772{
773 fn request_for(
774 &self,
775 request: &DecisionExecutionRequest,
776 ) -> Result<crate::PluginExecutionRequest, DecisionExecutorError> {
777 self(request)
778 }
779}
780
781#[cfg(feature = "plugins")]
789pub struct PluginDecisionExecutor {
790 registry: Arc<crate::PluginRegistry>,
791 plugin_id: String,
792 requests: Arc<dyn PluginExecutionRequestProvider>,
793}
794
795#[cfg(feature = "plugins")]
796impl PluginDecisionExecutor {
797 pub fn new(
799 registry: Arc<crate::PluginRegistry>,
800 plugin_id: impl Into<String>,
801 requests: Arc<dyn PluginExecutionRequestProvider>,
802 ) -> Result<Self, DecisionExecutorError> {
803 let plugin_id = plugin_id.into();
804 if plugin_id.trim().is_empty() {
805 return Err(DecisionExecutorError::new("plugin id must not be empty"));
806 }
807 Ok(Self {
808 registry,
809 plugin_id,
810 requests,
811 })
812 }
813}
814
815#[cfg(feature = "plugins")]
816#[async_trait]
817impl DecisionActionExecutor for PluginDecisionExecutor {
818 fn id(&self) -> &str {
819 &self.plugin_id
820 }
821
822 async fn execute(
823 &self,
824 request: &DecisionExecutionRequest,
825 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
826 let mut plugin_request = self.requests.request_for(request)?;
827 if plugin_request.subject() != request.case().subject() {
828 return Err(DecisionExecutorError::with_kind(
829 DecisionExecutionFailureKind::BlockedByPolicy,
830 "plugin request subject does not match the decision case",
831 ));
832 }
833 if plugin_request.case_id() != request.case().id() {
834 return Err(DecisionExecutorError::with_kind(
835 DecisionExecutionFailureKind::BlockedByPolicy,
836 "plugin request correlation does not match the decision case",
837 ));
838 }
839 if let Some(maximum) = request.limits().max_response_body_bytes() {
840 plugin_request = plugin_request.restrict_response_body_bytes(maximum);
841 }
842 self.registry
843 .execute(&self.plugin_id, plugin_request)
844 .await
845 .map(crate::PluginExecutionResult::into_observations)
846 .map_err(plugin_executor_error)
847 }
848}
849
850#[cfg(feature = "plugins")]
851fn plugin_executor_error(error: crate::PluginError) -> DecisionExecutorError {
852 use crate::PluginError;
853 let kind = match &error {
854 PluginError::Disabled
855 | PluginError::Cancelled
856 | PluginError::InputBudgetExceeded { .. }
857 | PluginError::RequestBudgetExceeded
858 | PluginError::ResponseBodyBudgetExceeded { .. }
859 | PluginError::ResponseBodyBudgetUnavailable
860 | PluginError::CumulativeBodyBudgetExceeded
861 | PluginError::ObservationBudgetExceeded
862 | PluginError::ObservationBytesBudgetExceeded
863 | PluginError::ScopeViolation
864 | PluginError::ContextSealed => DecisionExecutionFailureKind::BlockedByPolicy,
865 PluginError::BrokerFailure(_) => DecisionExecutionFailureKind::TransportFailure,
866 PluginError::RequestTimeout | PluginError::WallTimeExceeded => {
867 DecisionExecutionFailureKind::RequestTimeout
868 },
869 _ => DecisionExecutionFailureKind::ExecutorFailure,
870 };
871 DecisionExecutorError::with_kind(kind, error.to_string())
872}
873
874#[cfg(test)]
875#[path = "decision_runner/decision_runner_tests.rs"]
876mod tests;