Skip to main content

actionqueue_core/task/
constraints.rs

1//! Task constraints that enforce execution limits and behavior.
2
3use super::safety::SafetyLevel;
4
5/// Typed validation errors for [`TaskConstraints`].
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum TaskConstraintsError {
8    /// The configured attempt cap is invalid.
9    InvalidMaxAttempts {
10        /// The rejected `max_attempts` value.
11        max_attempts: u32,
12    },
13    /// An empty string was provided as a concurrency key.
14    EmptyConcurrencyKey,
15    /// A timeout of zero seconds was provided.
16    ZeroTimeout,
17    /// An empty list was provided as required capabilities.
18    EmptyCapabilities,
19    /// An individual entry in required_capabilities is an empty string.
20    EmptyCapabilityEntry,
21}
22
23impl std::fmt::Display for TaskConstraintsError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            TaskConstraintsError::InvalidMaxAttempts { max_attempts } => {
27                write!(f, "invalid max_attempts value: {max_attempts} (must be >= 1)")
28            }
29            TaskConstraintsError::EmptyConcurrencyKey => {
30                write!(f, "concurrency key must not be an empty string")
31            }
32            TaskConstraintsError::ZeroTimeout => {
33                write!(f, "timeout_secs must not be zero")
34            }
35            TaskConstraintsError::EmptyCapabilities => {
36                write!(f, "required capabilities list must not be empty")
37            }
38            TaskConstraintsError::EmptyCapabilityEntry => {
39                write!(f, "required_capabilities contains an empty string")
40            }
41        }
42    }
43}
44
45impl std::error::Error for TaskConstraintsError {}
46
47/// Policy for concurrency key behavior during retry wait.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub enum ConcurrencyKeyHoldPolicy {
51    /// Hold the concurrency key during retry (default - current behavior).
52    #[default]
53    HoldDuringRetry,
54    /// Release the concurrency key when entering RetryWait.
55    ReleaseOnRetry,
56}
57
58/// Constraints that control how a task's runs are executed.
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize))]
61pub struct TaskConstraints {
62    /// Maximum number of attempts for each run.
63    /// Default: 1
64    max_attempts: u32,
65    /// Execution timeout in seconds. If `None`, no timeout.
66    timeout_secs: Option<u64>,
67    /// Optional concurrency key. Runs with the same key never run concurrently.
68    concurrency_key: Option<String>,
69    /// Policy for concurrency key behavior during retry wait.
70    #[cfg_attr(feature = "serde", serde(default))]
71    concurrency_key_hold_policy: ConcurrencyKeyHoldPolicy,
72    /// Safety level classification for the task's side-effect characteristics.
73    #[cfg_attr(feature = "serde", serde(default))]
74    safety_level: SafetyLevel,
75    /// Required executor capabilities for dispatching this task.
76    ///
77    /// In v0.x the local executor handles all tasks (this field is ignored at
78    /// dispatch time). In Sprint 4 (v1.0), remote actors declare capabilities
79    /// and only actors with matching capabilities will be offered this task.
80    ///
81    /// When `Some`, the list must be non-empty. When `None`, any executor can
82    /// handle the task.
83    #[cfg_attr(feature = "serde", serde(default))]
84    required_capabilities: Option<Vec<String>>,
85}
86
87impl TaskConstraints {
88    /// Creates constraints with validation of invariant-sensitive fields.
89    pub fn new(
90        max_attempts: u32,
91        timeout_secs: Option<u64>,
92        concurrency_key: Option<String>,
93    ) -> Result<Self, TaskConstraintsError> {
94        let constraints = Self {
95            max_attempts,
96            timeout_secs,
97            concurrency_key,
98            concurrency_key_hold_policy: ConcurrencyKeyHoldPolicy::default(),
99            safety_level: SafetyLevel::default(),
100            required_capabilities: None,
101        };
102        constraints.validate()?;
103        Ok(constraints)
104    }
105
106    /// Validates this constraints object against core invariants.
107    pub fn validate(&self) -> Result<(), TaskConstraintsError> {
108        if self.max_attempts == 0 {
109            return Err(TaskConstraintsError::InvalidMaxAttempts { max_attempts: 0 });
110        }
111        if self.timeout_secs == Some(0) {
112            return Err(TaskConstraintsError::ZeroTimeout);
113        }
114        if let Some(ref key) = self.concurrency_key {
115            if key.is_empty() {
116                return Err(TaskConstraintsError::EmptyConcurrencyKey);
117            }
118        }
119        if let Some(ref caps) = self.required_capabilities {
120            if caps.is_empty() {
121                return Err(TaskConstraintsError::EmptyCapabilities);
122            }
123            if caps.iter().any(String::is_empty) {
124                return Err(TaskConstraintsError::EmptyCapabilityEntry);
125            }
126        }
127        Ok(())
128    }
129
130    /// Returns the maximum number of attempts allowed for each run.
131    pub fn max_attempts(&self) -> u32 {
132        self.max_attempts
133    }
134
135    /// Returns the optional per-attempt timeout in seconds.
136    pub fn timeout_secs(&self) -> Option<u64> {
137        self.timeout_secs
138    }
139
140    /// Returns the optional concurrency key.
141    pub fn concurrency_key(&self) -> Option<&str> {
142        self.concurrency_key.as_deref()
143    }
144
145    /// Sets the attempt cap with invariant validation.
146    pub fn set_max_attempts(&mut self, max_attempts: u32) -> Result<(), TaskConstraintsError> {
147        if max_attempts == 0 {
148            return Err(TaskConstraintsError::InvalidMaxAttempts { max_attempts });
149        }
150
151        self.max_attempts = max_attempts;
152        Ok(())
153    }
154
155    /// Sets the optional timeout in seconds.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`TaskConstraintsError::ZeroTimeout`] if `timeout_secs` is `Some(0)`.
160    pub fn set_timeout_secs(
161        &mut self,
162        timeout_secs: Option<u64>,
163    ) -> Result<(), TaskConstraintsError> {
164        if timeout_secs == Some(0) {
165            return Err(TaskConstraintsError::ZeroTimeout);
166        }
167        self.timeout_secs = timeout_secs;
168        Ok(())
169    }
170
171    /// Sets the optional concurrency key.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`TaskConstraintsError::EmptyConcurrencyKey`] if the key is `Some("")`.
176    pub fn set_concurrency_key(
177        &mut self,
178        concurrency_key: Option<String>,
179    ) -> Result<(), TaskConstraintsError> {
180        if let Some(ref key) = concurrency_key {
181            if key.is_empty() {
182                return Err(TaskConstraintsError::EmptyConcurrencyKey);
183            }
184        }
185        self.concurrency_key = concurrency_key;
186        Ok(())
187    }
188
189    /// Returns the concurrency key hold policy.
190    pub fn concurrency_key_hold_policy(&self) -> ConcurrencyKeyHoldPolicy {
191        self.concurrency_key_hold_policy
192    }
193
194    /// Sets the concurrency key hold policy.
195    pub fn set_concurrency_key_hold_policy(&mut self, policy: ConcurrencyKeyHoldPolicy) {
196        self.concurrency_key_hold_policy = policy;
197    }
198
199    /// Returns the safety level classification for this task.
200    pub fn safety_level(&self) -> SafetyLevel {
201        self.safety_level
202    }
203
204    /// Sets the safety level classification.
205    pub fn set_safety_level(&mut self, safety_level: SafetyLevel) {
206        self.safety_level = safety_level;
207    }
208
209    /// Returns the required executor capabilities, if any.
210    ///
211    /// In v0.x the local executor handles all tasks (this field is ignored).
212    /// In Sprint 4 (v1.0), only remote actors declaring all listed capabilities
213    /// will be offered this task.
214    pub fn required_capabilities(&self) -> Option<&[String]> {
215        self.required_capabilities.as_deref()
216    }
217
218    /// Attaches required capabilities, returning the modified constraints.
219    ///
220    /// The list must be non-empty; an empty vec is rejected at validation.
221    pub fn with_capabilities(mut self, caps: Vec<String>) -> Result<Self, TaskConstraintsError> {
222        if caps.is_empty() {
223            return Err(TaskConstraintsError::EmptyCapabilities);
224        }
225        if caps.iter().any(String::is_empty) {
226            return Err(TaskConstraintsError::EmptyCapabilityEntry);
227        }
228        self.required_capabilities = Some(caps);
229        Ok(self)
230    }
231}
232
233#[cfg(feature = "serde")]
234impl<'de> serde::Deserialize<'de> for TaskConstraints {
235    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
236    where
237        D: serde::Deserializer<'de>,
238    {
239        #[derive(serde::Deserialize)]
240        struct TaskConstraintsWire {
241            max_attempts: u32,
242            timeout_secs: Option<u64>,
243            concurrency_key: Option<String>,
244            #[serde(default)]
245            concurrency_key_hold_policy: ConcurrencyKeyHoldPolicy,
246            #[serde(default)]
247            safety_level: SafetyLevel,
248            #[serde(default)]
249            required_capabilities: Option<Vec<String>>,
250        }
251
252        let wire = TaskConstraintsWire::deserialize(deserializer)?;
253        let constraints = TaskConstraints {
254            max_attempts: wire.max_attempts,
255            timeout_secs: wire.timeout_secs,
256            concurrency_key: wire.concurrency_key,
257            concurrency_key_hold_policy: wire.concurrency_key_hold_policy,
258            safety_level: wire.safety_level,
259            required_capabilities: wire.required_capabilities,
260        };
261        constraints.validate().map_err(serde::de::Error::custom)?;
262        Ok(constraints)
263    }
264}
265
266#[cfg(feature = "testing")]
267impl TaskConstraints {
268    /// Creates constraints bypassing validation — for testing only.
269    ///
270    /// This allows constructing constraints with values (e.g., zero timeout)
271    /// that are useful in test fixtures but rejected by production validation.
272    pub fn new_for_testing(
273        max_attempts: u32,
274        timeout_secs: Option<u64>,
275        concurrency_key: Option<String>,
276    ) -> Self {
277        Self {
278            max_attempts,
279            timeout_secs,
280            concurrency_key,
281            concurrency_key_hold_policy: ConcurrencyKeyHoldPolicy::default(),
282            safety_level: SafetyLevel::default(),
283            required_capabilities: None,
284        }
285    }
286}
287
288impl Default for TaskConstraints {
289    fn default() -> Self {
290        Self::new(1, None, None).expect("default TaskConstraints must be valid")
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::{TaskConstraints, TaskConstraintsError};
297
298    #[test]
299    fn rejects_zero_max_attempts_at_construction() {
300        let result = TaskConstraints::new(0, Some(10), None);
301        assert_eq!(result, Err(TaskConstraintsError::InvalidMaxAttempts { max_attempts: 0 }));
302    }
303
304    #[test]
305    fn set_max_attempts_rejects_zero_without_mutation() {
306        let mut constraints = TaskConstraints::default();
307        let original = constraints.clone();
308
309        let result = constraints.set_max_attempts(0);
310        assert_eq!(result, Err(TaskConstraintsError::InvalidMaxAttempts { max_attempts: 0 }));
311        assert_eq!(constraints, original);
312    }
313
314    #[test]
315    fn rejects_empty_concurrency_key_at_construction() {
316        let result = TaskConstraints::new(1, None, Some(String::new()));
317        assert_eq!(result, Err(TaskConstraintsError::EmptyConcurrencyKey));
318    }
319
320    #[test]
321    fn accepts_none_concurrency_key() {
322        let result = TaskConstraints::new(1, None, None);
323        assert!(result.is_ok());
324    }
325
326    #[test]
327    fn accepts_valid_concurrency_key() {
328        let result = TaskConstraints::new(1, None, Some("valid-key".to_string()));
329        assert!(result.is_ok());
330        assert_eq!(result.unwrap().concurrency_key(), Some("valid-key"));
331    }
332
333    #[test]
334    fn set_concurrency_key_rejects_empty() {
335        let mut constraints = TaskConstraints::default();
336        let result = constraints.set_concurrency_key(Some(String::new()));
337        assert_eq!(result, Err(TaskConstraintsError::EmptyConcurrencyKey));
338        assert_eq!(constraints.concurrency_key(), None); // unchanged
339    }
340
341    #[test]
342    fn set_concurrency_key_accepts_valid() {
343        let mut constraints = TaskConstraints::default();
344        constraints.set_concurrency_key(Some("key".to_string())).unwrap();
345        assert_eq!(constraints.concurrency_key(), Some("key"));
346    }
347
348    #[test]
349    fn set_concurrency_key_accepts_none() {
350        let mut constraints = TaskConstraints::new(1, None, Some("key".to_string())).unwrap();
351        constraints.set_concurrency_key(None).unwrap();
352        assert_eq!(constraints.concurrency_key(), None);
353    }
354
355    #[test]
356    fn rejects_zero_timeout_at_construction() {
357        let result = TaskConstraints::new(1, Some(0), None);
358        assert_eq!(result, Err(TaskConstraintsError::ZeroTimeout));
359    }
360
361    #[test]
362    fn set_timeout_secs_rejects_zero() {
363        let mut constraints = TaskConstraints::default();
364        let result = constraints.set_timeout_secs(Some(0));
365        assert_eq!(result, Err(TaskConstraintsError::ZeroTimeout));
366        assert_eq!(constraints.timeout_secs(), None); // unchanged
367    }
368
369    #[test]
370    fn set_timeout_secs_accepts_none() {
371        let mut constraints = TaskConstraints::new(1, Some(30), None).unwrap();
372        constraints.set_timeout_secs(None).unwrap();
373        assert_eq!(constraints.timeout_secs(), None);
374    }
375
376    #[test]
377    fn set_timeout_secs_accepts_positive_value() {
378        let mut constraints = TaskConstraints::default();
379        constraints.set_timeout_secs(Some(60)).unwrap();
380        assert_eq!(constraints.timeout_secs(), Some(60));
381    }
382
383    #[test]
384    fn accepts_valid_timeout_at_construction() {
385        let result = TaskConstraints::new(1, Some(30), None);
386        assert!(result.is_ok());
387        assert_eq!(result.unwrap().timeout_secs(), Some(30));
388    }
389
390    #[test]
391    fn with_capabilities_rejects_empty_vec() {
392        let constraints = TaskConstraints::default();
393        let result = constraints.with_capabilities(vec![]);
394        assert_eq!(result, Err(TaskConstraintsError::EmptyCapabilities));
395    }
396
397    #[test]
398    fn with_capabilities_accepts_non_empty_vec() {
399        let constraints = TaskConstraints::default();
400        let result = constraints.with_capabilities(vec!["gpu".to_string()]);
401        assert!(result.is_ok());
402        assert_eq!(result.unwrap().required_capabilities(), Some(&["gpu".to_string()][..]));
403    }
404
405    #[test]
406    fn validate_passes_for_default_and_valid_capabilities() {
407        // Verify validate() passes on default constraints (no capabilities)
408        // and on constraints with valid non-empty capability entries.
409        let mut constraints = TaskConstraints::default();
410        assert!(constraints.validate().is_ok());
411        constraints = constraints.with_capabilities(vec!["cap1".to_string()]).unwrap();
412        assert!(constraints.validate().is_ok());
413    }
414
415    #[test]
416    fn with_capabilities_rejects_empty_string_entry() {
417        let constraints = TaskConstraints::default();
418        let result = constraints.with_capabilities(vec!["gpu".to_string(), String::new()]);
419        assert_eq!(result, Err(TaskConstraintsError::EmptyCapabilityEntry));
420    }
421}