Skip to main content

rust_supervisor/policy/
task_role_defaults.rs

1//! Task role defaults for supervised children.
2//!
3//! This module owns role classification, default policy bundles, effective
4//! policy attribution, and semantic conflict diagnostics.
5
6use crate::id::types::ChildId;
7use crate::spec::child::{BackoffPolicy, RestartPolicy};
8use crate::spec::supervisor::{EscalationPolicy, RestartLimit};
9use confique::Config;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::fmt::{Display, Formatter};
13use std::time::Duration;
14
15/// Task role classification for supervised children.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum TaskRole {
19    /// Long-running service that should stay online.
20    Service,
21    /// Background worker with bounded retry semantics.
22    Worker,
23    /// One-shot job that must not auto-restart on success.
24    Job,
25    /// Auxiliary sidecar process attached to a primary service.
26    Sidecar,
27    /// Nested supervisor tree treated as a single unit.
28    Supervisor,
29}
30
31impl TaskRole {
32    /// Returns a stable low-cardinality role label.
33    ///
34    /// # Arguments
35    ///
36    /// This function has no arguments.
37    ///
38    /// # Returns
39    ///
40    /// Returns a snake_case static role label.
41    pub const fn as_str(self) -> &'static str {
42        match self {
43            Self::Service => "service",
44            Self::Worker => "worker",
45            Self::Job => "job",
46            Self::Sidecar => "sidecar",
47            Self::Supervisor => "supervisor",
48        }
49    }
50}
51
52impl Display for TaskRole {
53    /// Formats the role as a stable label.
54    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
55        formatter.write_str(self.as_str())
56    }
57}
58
59/// Configuration for sidecar attachment to a primary service.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Config, JsonSchema)]
61pub struct SidecarConfig {
62    /// Child ID of the primary service this sidecar attaches to.
63    #[config(nested)]
64    pub primary_child_id: ChildId,
65    /// Whether lifecycle events are linked.
66    #[config(default = false)]
67    #[serde(default)]
68    pub linked_lifecycle: bool,
69}
70
71impl SidecarConfig {
72    /// Creates a sidecar binding configuration.
73    ///
74    /// # Arguments
75    ///
76    /// - `primary_child_id`: Child ID of the primary service.
77    /// - `linked_lifecycle`: Whether lifecycle operations are linked.
78    ///
79    /// # Returns
80    ///
81    /// Returns a [`SidecarConfig`] value.
82    pub fn new(primary_child_id: ChildId, linked_lifecycle: bool) -> Self {
83        Self {
84            primary_child_id,
85            linked_lifecycle,
86        }
87    }
88}
89
90/// Action taken when a child exits successfully.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum OnSuccessAction {
94    /// Restart the child to keep it online.
95    Restart,
96    /// Stop the child permanently.
97    Stop,
98    /// Take no automatic action.
99    NoOp,
100}
101
102/// Action taken when a child exits with failure.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(rename_all = "snake_case")]
105pub enum OnFailureAction {
106    /// Restart with backoff policy applied.
107    RestartWithBackoff,
108    /// Restart indefinitely.
109    RestartPermanent,
110    /// Stop and escalate to parent or shutdown tree.
111    StopAndEscalate,
112}
113
114/// Action taken when a child receives an explicit stop request.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
116#[serde(rename_all = "snake_case")]
117pub enum OnManualStopAction {
118    /// Stop permanently until explicitly restarted.
119    StopForever,
120    /// Stop but allow a future explicit restart.
121    StopUntilExplicitRestart,
122}
123
124/// Action taken when a child exceeds its execution timeout.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(rename_all = "snake_case")]
127pub enum OnTimeoutAction {
128    /// Restart with backoff policy applied.
129    RestartWithBackoff,
130    /// Stop and escalate to parent or shutdown tree.
131    StopAndEscalate,
132}
133
134/// Action taken when restart budget is exhausted.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum OnBudgetExhaustedAction {
138    /// Stop and escalate to parent or shutdown tree.
139    StopAndEscalate,
140    /// Quarantine the child or scope without escalating.
141    Quarantine,
142}
143
144/// Default policy bundle bound to a specific task role.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
146pub struct RoleDefaultPolicy {
147    /// Action on successful exit.
148    pub on_success_exit: OnSuccessAction,
149    /// Action on failure exit.
150    pub on_failure_exit: OnFailureAction,
151    /// Action on explicit manual stop.
152    pub on_manual_stop: OnManualStopAction,
153    /// Action on execution timeout.
154    pub on_timeout: OnTimeoutAction,
155    /// Action when restart budget is exhausted.
156    pub on_budget_exhausted: OnBudgetExhaustedAction,
157    /// Default restart limit.
158    pub default_restart_limit: Option<RestartLimit>,
159    /// Default escalation policy.
160    pub default_escalation_policy: Option<EscalationPolicy>,
161    /// Default backoff policy.
162    pub default_backoff_policy: Option<BackoffPolicy>,
163    /// Exit codes considered successful.
164    #[serde(default = "default_success_exit_codes")]
165    pub success_exit_codes: Vec<i32>,
166}
167
168/// Role-specific differences used to build a default policy.
169struct RoleDefaultPolicyDifferences {
170    /// Action on successful exit.
171    on_success_exit: OnSuccessAction,
172    /// Action on execution timeout.
173    on_timeout: OnTimeoutAction,
174    /// Maximum restart count inside the default restart limit window.
175    max_restarts: u32,
176}
177
178impl From<RoleDefaultPolicyDifferences> for RoleDefaultPolicy {
179    /// Converts role-specific differences into a complete default policy.
180    ///
181    /// # Arguments
182    ///
183    /// - `differences`: Role-specific policy fields.
184    ///
185    /// # Returns
186    ///
187    /// Returns a complete [`RoleDefaultPolicy`] with shared defaults applied.
188    fn from(differences: RoleDefaultPolicyDifferences) -> Self {
189        Self {
190            on_success_exit: differences.on_success_exit,
191            on_failure_exit: OnFailureAction::RestartWithBackoff,
192            on_manual_stop: OnManualStopAction::StopForever,
193            on_timeout: differences.on_timeout,
194            on_budget_exhausted: OnBudgetExhaustedAction::StopAndEscalate,
195            default_restart_limit: Some(bounded_restart_limit(differences.max_restarts)),
196            default_escalation_policy: Some(EscalationPolicy::EscalateToParent),
197            default_backoff_policy: Some(default_backoff_policy()),
198            success_exit_codes: default_success_exit_codes(),
199        }
200    }
201}
202
203impl RoleDefaultPolicy {
204    /// Returns the default policy pack for a task role.
205    ///
206    /// # Arguments
207    ///
208    /// - `role`: Task role used to select defaults.
209    ///
210    /// # Returns
211    ///
212    /// Returns a role-specific [`RoleDefaultPolicy`].
213    pub fn for_role(role: TaskRole) -> Self {
214        match role {
215            TaskRole::Service => service_default(),
216            TaskRole::Worker => worker_default(),
217            TaskRole::Job => job_default(),
218            TaskRole::Sidecar => sidecar_default(),
219            TaskRole::Supervisor => supervisor_default(),
220        }
221    }
222}
223
224/// Source used to build an effective policy.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
226#[serde(rename_all = "snake_case")]
227pub enum PolicySource {
228    /// Policy came from an explicit role default.
229    RoleDefault,
230    /// Policy contains user overrides.
231    UserOverride,
232    /// Policy used the conservative fallback role.
233    FallbackDefault,
234}
235
236impl Display for PolicySource {
237    /// Formats the policy source as a stable label.
238    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
239        let label = match self {
240            Self::RoleDefault => "role_default",
241            Self::UserOverride => "user_override",
242            Self::FallbackDefault => "fallback_default",
243        };
244        formatter.write_str(label)
245    }
246}
247
248/// Severity classification for failure escalation bifurcation.
249///
250/// Ordering: Critical > Standard > Optional (highest to lowest severity).
251#[derive(
252    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
253)]
254pub enum SeverityClass {
255    /// Optional: failure follows noise-reduction path (no alert upgrade).
256    Optional,
257    /// Standard: follows the default TaskRole behavior.
258    Standard,
259    /// Critical: failure must trigger escalation path.
260    Critical,
261}
262
263/// Effective policy selected for one child.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
265pub struct EffectivePolicy {
266    /// Effective task role after fallback handling.
267    pub task_role: TaskRole,
268    /// Policy pack selected for the effective role.
269    pub policy_pack: RoleDefaultPolicy,
270    /// Source of the effective policy.
271    pub source: PolicySource,
272    /// Whether the worker fallback default was used.
273    pub used_fallback: bool,
274    /// Fields explicitly overridden by the user.
275    pub overridden_fields: Vec<String>,
276    /// Severity classification for escalation bifurcation.
277    pub severity: SeverityClass,
278    /// Group name for group isolation (None = not grouped).
279    pub group_name: Option<String>,
280}
281
282impl EffectivePolicy {
283    /// Merges task role defaults with known user override markers.
284    ///
285    /// # Arguments
286    ///
287    /// - `role`: Optional declared task role.
288    /// - `overridden_fields`: Fields explicitly set by the user.
289    ///
290    /// # Returns
291    ///
292    /// Returns an [`EffectivePolicy`] with fallback attribution.
293    pub fn merge(role: Option<TaskRole>, overridden_fields: Vec<String>) -> Self {
294        let used_fallback = role.is_none();
295        let task_role = role.unwrap_or(TaskRole::Worker);
296        let source = if used_fallback {
297            PolicySource::FallbackDefault
298        } else if overridden_fields.is_empty() {
299            PolicySource::RoleDefault
300        } else {
301            PolicySource::UserOverride
302        };
303        let severity = Self::default_severity(task_role);
304        Self {
305            task_role,
306            policy_pack: RoleDefaultPolicy::for_role(task_role),
307            source,
308            used_fallback,
309            overridden_fields,
310            severity,
311            group_name: None,
312        }
313    }
314
315    /// Returns the default [`SeverityClass`] for a given [`TaskRole`].
316    fn default_severity(role: TaskRole) -> SeverityClass {
317        match role {
318            TaskRole::Service => SeverityClass::Critical,
319            TaskRole::Supervisor => SeverityClass::Critical,
320            TaskRole::Worker => SeverityClass::Standard,
321            TaskRole::Job => SeverityClass::Optional,
322            TaskRole::Sidecar => SeverityClass::Standard,
323        }
324    }
325
326    /// Builds an effective policy for a child specification.
327    ///
328    /// # Arguments
329    ///
330    /// - `child`: Child specification to inspect.
331    ///
332    /// # Returns
333    ///
334    /// Returns the effective role policy for the child.
335    pub fn for_child(child: &crate::spec::child::ChildSpec) -> Self {
336        let mut overridden = Vec::new();
337        if child.restart_policy != RestartPolicy::Transient {
338            overridden.push("restart_policy".to_string());
339        }
340        let effective_policy = Self::merge(child.task_role, overridden);
341        if child.task_role.is_none() {
342            tracing::warn!(
343                child_id = %child.id,
344                task_role = %effective_policy.task_role,
345                used_fallback_default = effective_policy.used_fallback,
346                effective_policy_source = %effective_policy.source,
347                "task role missing, falling back to worker default"
348            );
349        }
350        effective_policy
351    }
352}
353
354/// Describes one role semantic conflict.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct RoleSemanticConflict {
357    /// Child that owns the conflict.
358    pub child_id: ChildId,
359    /// Declared task role.
360    pub task_role: TaskRole,
361    /// Conflicting field name.
362    pub conflicting_field: String,
363    /// User-provided value.
364    pub user_value: String,
365    /// Role default expectation.
366    pub expected_semantic: String,
367    /// Human-readable reason.
368    pub reason: String,
369}
370
371/// Returns semantic conflicts for one child.
372///
373/// # Arguments
374///
375/// - `child`: Child specification to inspect.
376///
377/// # Returns
378///
379/// Returns a list of role semantic conflicts.
380pub fn semantic_conflicts_for_child(
381    child: &crate::spec::child::ChildSpec,
382) -> Vec<RoleSemanticConflict> {
383    let mut conflicts = Vec::new();
384    if child.task_role == Some(TaskRole::Job) && child.restart_policy == RestartPolicy::Permanent {
385        conflicts.push(RoleSemanticConflict {
386            child_id: child.id.clone(),
387            task_role: TaskRole::Job,
388            conflicting_field: "restart_policy".to_string(),
389            user_value: "permanent".to_string(),
390            expected_semantic: "job success should stop".to_string(),
391            reason: "Job role must not silently use permanent restart semantics".to_string(),
392        });
393    }
394    conflicts
395}
396
397/// Returns default success exit codes.
398///
399/// # Arguments
400///
401/// This function has no arguments.
402///
403/// # Returns
404///
405/// Returns a vector containing exit code zero.
406fn default_success_exit_codes() -> Vec<i32> {
407    vec![0]
408}
409
410/// Returns a bounded restart limit used by task role defaults.
411fn bounded_restart_limit(max_restarts: u32) -> RestartLimit {
412    RestartLimit::new(max_restarts, Duration::from_secs(60))
413}
414
415/// Returns a default backoff policy used by task role defaults.
416fn default_backoff_policy() -> BackoffPolicy {
417    BackoffPolicy::new(Duration::from_millis(50), Duration::from_secs(5), 0.2)
418}
419
420/// Returns service task role defaults.
421fn service_default() -> RoleDefaultPolicy {
422    RoleDefaultPolicyDifferences {
423        on_success_exit: OnSuccessAction::Restart,
424        on_timeout: OnTimeoutAction::RestartWithBackoff,
425        max_restarts: 10,
426    }
427    .into()
428}
429
430/// Returns worker task role defaults.
431fn worker_default() -> RoleDefaultPolicy {
432    RoleDefaultPolicyDifferences {
433        on_success_exit: OnSuccessAction::Stop,
434        on_timeout: OnTimeoutAction::RestartWithBackoff,
435        max_restarts: 3,
436    }
437    .into()
438}
439
440/// Returns job task role defaults.
441fn job_default() -> RoleDefaultPolicy {
442    RoleDefaultPolicyDifferences {
443        on_success_exit: OnSuccessAction::Stop,
444        on_timeout: OnTimeoutAction::StopAndEscalate,
445        max_restarts: 1,
446    }
447    .into()
448}
449
450/// Returns sidecar task role defaults.
451fn sidecar_default() -> RoleDefaultPolicy {
452    RoleDefaultPolicyDifferences {
453        on_success_exit: OnSuccessAction::Restart,
454        on_timeout: OnTimeoutAction::RestartWithBackoff,
455        max_restarts: 5,
456    }
457    .into()
458}
459
460/// Returns nested supervisor task role defaults.
461fn supervisor_default() -> RoleDefaultPolicy {
462    RoleDefaultPolicyDifferences {
463        on_success_exit: OnSuccessAction::Restart,
464        on_timeout: OnTimeoutAction::RestartWithBackoff,
465        max_restarts: 3,
466    }
467    .into()
468}