Skip to main content

greentic_dw_engine/
lib.rs

1//! Engine abstraction for Digital Worker runtime decisions.
2//!
3//! Engines decide *what* should happen next, while runtime remains responsible
4//! for applying operations and mediating side effects.
5
6use greentic_dw_core::RuntimeOperation;
7use greentic_dw_types::TaskEnvelope;
8use thiserror::Error;
9
10/// Context provided to the engine when requesting the next decision.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct EngineContext {
13    pub envelope: TaskEnvelope,
14}
15
16/// Structured decisions returned by engines.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum EngineDecision {
19    /// No state change requested.
20    Noop,
21    /// Apply a single runtime operation.
22    Operation(RuntimeOperation),
23    /// Apply multiple operations in order.
24    Batch(Vec<RuntimeOperation>),
25}
26
27#[derive(Debug, Error, PartialEq, Eq)]
28pub enum EngineError {
29    #[error("engine rejected empty operation batch")]
30    EmptyBatch,
31    #[error("engine returned invalid decision: {0}")]
32    InvalidDecision(String),
33}
34
35/// Decision interface for runtime engine implementations.
36pub trait DwEngine {
37    fn decide(&self, context: &EngineContext) -> Result<EngineDecision, EngineError>;
38
39    /// Fast path that lets runtimes avoid cloning envelopes when asking
40    /// for an engine decision.
41    fn decide_with_envelope(&self, envelope: &TaskEnvelope) -> Result<EngineDecision, EngineError> {
42        self.decide(&EngineContext {
43            envelope: envelope.clone(),
44        })
45    }
46}
47
48/// Basic engine implementation useful for tests and deterministic workflows.
49#[derive(Debug, Clone)]
50pub struct StaticEngine {
51    decision: EngineDecision,
52}
53
54impl StaticEngine {
55    pub fn new(decision: EngineDecision) -> Self {
56        Self { decision }
57    }
58}
59
60impl DwEngine for StaticEngine {
61    fn decide(&self, _context: &EngineContext) -> Result<EngineDecision, EngineError> {
62        Self::validate_decision(&self.decision)?;
63        Ok(self.decision.clone())
64    }
65
66    fn decide_with_envelope(
67        &self,
68        _envelope: &TaskEnvelope,
69    ) -> Result<EngineDecision, EngineError> {
70        Self::validate_decision(&self.decision)?;
71        Ok(self.decision.clone())
72    }
73}
74
75impl StaticEngine {
76    fn validate_decision(decision: &EngineDecision) -> Result<(), EngineError> {
77        if let EngineDecision::Batch(ops) = decision
78            && ops.is_empty()
79        {
80            return Err(EngineError::EmptyBatch);
81        }
82        Ok(())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use greentic_dw_types::{
90        LocaleContext, LocalePropagation, OutputLocaleGuidance, TaskLifecycleState, TenantScope,
91        WorkerLocalePolicy,
92    };
93
94    fn sample_envelope() -> TaskEnvelope {
95        TaskEnvelope {
96            task_id: "task-1".to_string(),
97            worker_id: "worker-1".to_string(),
98            state: TaskLifecycleState::Created,
99            scope: TenantScope {
100                tenant: "tenant-a".to_string(),
101                team: None,
102            },
103            locale: LocaleContext {
104                worker_default_locale: "en-US".to_string(),
105                requested_locale: None,
106                human_locale: None,
107                policy: WorkerLocalePolicy::WorkerDefault,
108                propagation: LocalePropagation::CurrentTaskOnly,
109                output: OutputLocaleGuidance::WorkerDefault,
110            },
111        }
112    }
113
114    #[test]
115    fn static_engine_returns_configured_operation() {
116        let engine = StaticEngine::new(EngineDecision::Operation(RuntimeOperation::Start));
117        let context = EngineContext {
118            envelope: sample_envelope(),
119        };
120
121        let decision = engine.decide(&context).expect("decision should succeed");
122        assert_eq!(decision, EngineDecision::Operation(RuntimeOperation::Start));
123    }
124
125    #[test]
126    fn static_engine_rejects_empty_batch() {
127        let engine = StaticEngine::new(EngineDecision::Batch(vec![]));
128        let context = EngineContext {
129            envelope: sample_envelope(),
130        };
131
132        let err = engine
133            .decide(&context)
134            .expect_err("empty batch should be rejected");
135        assert_eq!(err, EngineError::EmptyBatch);
136    }
137}