1use crate::id::types::ChildId;
7use crate::spec::child::{BackoffPolicy, RestartPolicy};
8use crate::spec::supervisor::{EscalationPolicy, RestartLimit};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::fmt::{Display, Formatter};
12use std::time::Duration;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum TaskRole {
18 Service,
20 Worker,
22 Job,
24 Sidecar,
26 Supervisor,
28}
29
30impl TaskRole {
31 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::Service => "service",
43 Self::Worker => "worker",
44 Self::Job => "job",
45 Self::Sidecar => "sidecar",
46 Self::Supervisor => "supervisor",
47 }
48 }
49}
50
51impl Display for TaskRole {
52 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
54 formatter.write_str(self.as_str())
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct SidecarConfig {
61 pub primary_child_id: ChildId,
63 #[serde(default)]
65 pub linked_lifecycle: bool,
66}
67
68impl SidecarConfig {
69 pub fn new(primary_child_id: ChildId, linked_lifecycle: bool) -> Self {
80 Self {
81 primary_child_id,
82 linked_lifecycle,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(rename_all = "snake_case")]
90pub enum OnSuccessAction {
91 Restart,
93 Stop,
95 NoOp,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "snake_case")]
102pub enum OnFailureAction {
103 RestartWithBackoff,
105 RestartPermanent,
107 StopAndEscalate,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum OnManualStopAction {
115 StopForever,
117 StopUntilExplicitRestart,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123#[serde(rename_all = "snake_case")]
124pub enum OnTimeoutAction {
125 RestartWithBackoff,
127 StopAndEscalate,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
133#[serde(rename_all = "snake_case")]
134pub enum OnBudgetExhaustedAction {
135 StopAndEscalate,
137 Quarantine,
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
143pub struct RoleDefaultPolicy {
144 pub on_success_exit: OnSuccessAction,
146 pub on_failure_exit: OnFailureAction,
148 pub on_manual_stop: OnManualStopAction,
150 pub on_timeout: OnTimeoutAction,
152 pub on_budget_exhausted: OnBudgetExhaustedAction,
154 pub default_restart_limit: Option<RestartLimit>,
156 pub default_escalation_policy: Option<EscalationPolicy>,
158 pub default_backoff_policy: Option<BackoffPolicy>,
160 #[serde(default = "default_success_exit_codes")]
162 pub success_exit_codes: Vec<i32>,
163}
164
165struct RoleDefaultPolicyDifferences {
167 on_success_exit: OnSuccessAction,
169 on_timeout: OnTimeoutAction,
171 max_restarts: u32,
173}
174
175impl From<RoleDefaultPolicyDifferences> for RoleDefaultPolicy {
176 fn from(differences: RoleDefaultPolicyDifferences) -> Self {
186 Self {
187 on_success_exit: differences.on_success_exit,
188 on_failure_exit: OnFailureAction::RestartWithBackoff,
189 on_manual_stop: OnManualStopAction::StopForever,
190 on_timeout: differences.on_timeout,
191 on_budget_exhausted: OnBudgetExhaustedAction::StopAndEscalate,
192 default_restart_limit: Some(bounded_restart_limit(differences.max_restarts)),
193 default_escalation_policy: Some(EscalationPolicy::EscalateToParent),
194 default_backoff_policy: Some(default_backoff_policy()),
195 success_exit_codes: default_success_exit_codes(),
196 }
197 }
198}
199
200impl RoleDefaultPolicy {
201 pub fn for_role(role: TaskRole) -> Self {
211 match role {
212 TaskRole::Service => service_default(),
213 TaskRole::Worker => worker_default(),
214 TaskRole::Job => job_default(),
215 TaskRole::Sidecar => sidecar_default(),
216 TaskRole::Supervisor => supervisor_default(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223#[serde(rename_all = "snake_case")]
224pub enum PolicySource {
225 RoleDefault,
227 UserOverride,
229 FallbackDefault,
231}
232
233impl Display for PolicySource {
234 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
236 let label = match self {
237 Self::RoleDefault => "role_default",
238 Self::UserOverride => "user_override",
239 Self::FallbackDefault => "fallback_default",
240 };
241 formatter.write_str(label)
242 }
243}
244
245#[derive(
249 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
250)]
251pub enum SeverityClass {
252 Optional,
254 Standard,
256 Critical,
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
262pub struct EffectivePolicy {
263 pub task_role: TaskRole,
265 pub policy_pack: RoleDefaultPolicy,
267 pub source: PolicySource,
269 pub used_fallback: bool,
271 pub overridden_fields: Vec<String>,
273 pub severity: SeverityClass,
275 pub group_name: Option<String>,
277}
278
279impl EffectivePolicy {
280 pub fn merge(role: Option<TaskRole>, overridden_fields: Vec<String>) -> Self {
291 let used_fallback = role.is_none();
292 let task_role = role.unwrap_or(TaskRole::Worker);
293 let source = if used_fallback {
294 PolicySource::FallbackDefault
295 } else if overridden_fields.is_empty() {
296 PolicySource::RoleDefault
297 } else {
298 PolicySource::UserOverride
299 };
300 let severity = Self::default_severity(task_role);
301 Self {
302 task_role,
303 policy_pack: RoleDefaultPolicy::for_role(task_role),
304 source,
305 used_fallback,
306 overridden_fields,
307 severity,
308 group_name: None,
309 }
310 }
311
312 fn default_severity(role: TaskRole) -> SeverityClass {
314 match role {
315 TaskRole::Service => SeverityClass::Critical,
316 TaskRole::Supervisor => SeverityClass::Critical,
317 TaskRole::Worker => SeverityClass::Standard,
318 TaskRole::Job => SeverityClass::Optional,
319 TaskRole::Sidecar => SeverityClass::Standard,
320 }
321 }
322
323 pub fn for_child(child: &crate::spec::child::ChildSpec) -> Self {
333 let mut overridden = Vec::new();
334 if child.restart_policy != RestartPolicy::Transient {
335 overridden.push("restart_policy".to_string());
336 }
337 let effective_policy = Self::merge(child.task_role, overridden);
338 if child.task_role.is_none() {
339 tracing::warn!(
340 child_id = %child.id,
341 task_role = %effective_policy.task_role,
342 used_fallback_default = effective_policy.used_fallback,
343 effective_policy_source = %effective_policy.source,
344 "task role missing, falling back to worker default"
345 );
346 }
347 effective_policy
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct RoleSemanticConflict {
354 pub child_id: ChildId,
356 pub task_role: TaskRole,
358 pub conflicting_field: String,
360 pub user_value: String,
362 pub expected_semantic: String,
364 pub reason: String,
366}
367
368pub fn semantic_conflicts_for_child(
378 child: &crate::spec::child::ChildSpec,
379) -> Vec<RoleSemanticConflict> {
380 let mut conflicts = Vec::new();
381 if child.task_role == Some(TaskRole::Job) && child.restart_policy == RestartPolicy::Permanent {
382 conflicts.push(RoleSemanticConflict {
383 child_id: child.id.clone(),
384 task_role: TaskRole::Job,
385 conflicting_field: "restart_policy".to_string(),
386 user_value: "permanent".to_string(),
387 expected_semantic: "job success should stop".to_string(),
388 reason: "Job role must not silently use permanent restart semantics".to_string(),
389 });
390 }
391 conflicts
392}
393
394fn default_success_exit_codes() -> Vec<i32> {
404 vec![0]
405}
406
407fn bounded_restart_limit(max_restarts: u32) -> RestartLimit {
409 RestartLimit::new(max_restarts, Duration::from_secs(60))
410}
411
412fn default_backoff_policy() -> BackoffPolicy {
414 BackoffPolicy::new(Duration::from_millis(50), Duration::from_secs(5), 0.2)
415}
416
417fn service_default() -> RoleDefaultPolicy {
419 RoleDefaultPolicyDifferences {
420 on_success_exit: OnSuccessAction::Restart,
421 on_timeout: OnTimeoutAction::RestartWithBackoff,
422 max_restarts: 10,
423 }
424 .into()
425}
426
427fn worker_default() -> RoleDefaultPolicy {
429 RoleDefaultPolicyDifferences {
430 on_success_exit: OnSuccessAction::Stop,
431 on_timeout: OnTimeoutAction::RestartWithBackoff,
432 max_restarts: 3,
433 }
434 .into()
435}
436
437fn job_default() -> RoleDefaultPolicy {
439 RoleDefaultPolicyDifferences {
440 on_success_exit: OnSuccessAction::Stop,
441 on_timeout: OnTimeoutAction::StopAndEscalate,
442 max_restarts: 1,
443 }
444 .into()
445}
446
447fn sidecar_default() -> RoleDefaultPolicy {
449 RoleDefaultPolicyDifferences {
450 on_success_exit: OnSuccessAction::Restart,
451 on_timeout: OnTimeoutAction::RestartWithBackoff,
452 max_restarts: 5,
453 }
454 .into()
455}
456
457fn supervisor_default() -> RoleDefaultPolicy {
459 RoleDefaultPolicyDifferences {
460 on_success_exit: OnSuccessAction::Restart,
461 on_timeout: OnTimeoutAction::RestartWithBackoff,
462 max_restarts: 3,
463 }
464 .into()
465}