1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum TaskRole {
19 Service,
21 Worker,
23 Job,
25 Sidecar,
27 Supervisor,
29}
30
31impl TaskRole {
32 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 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
55 formatter.write_str(self.as_str())
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Config, JsonSchema)]
61pub struct SidecarConfig {
62 #[config(nested)]
64 pub primary_child_id: ChildId,
65 #[config(default = false)]
67 #[serde(default)]
68 pub linked_lifecycle: bool,
69}
70
71impl SidecarConfig {
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum OnSuccessAction {
94 Restart,
96 Stop,
98 NoOp,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(rename_all = "snake_case")]
105pub enum OnFailureAction {
106 RestartWithBackoff,
108 RestartPermanent,
110 StopAndEscalate,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
116#[serde(rename_all = "snake_case")]
117pub enum OnManualStopAction {
118 StopForever,
120 StopUntilExplicitRestart,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(rename_all = "snake_case")]
127pub enum OnTimeoutAction {
128 RestartWithBackoff,
130 StopAndEscalate,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum OnBudgetExhaustedAction {
138 StopAndEscalate,
140 Quarantine,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
146pub struct RoleDefaultPolicy {
147 pub on_success_exit: OnSuccessAction,
149 pub on_failure_exit: OnFailureAction,
151 pub on_manual_stop: OnManualStopAction,
153 pub on_timeout: OnTimeoutAction,
155 pub on_budget_exhausted: OnBudgetExhaustedAction,
157 pub default_restart_limit: Option<RestartLimit>,
159 pub default_escalation_policy: Option<EscalationPolicy>,
161 pub default_backoff_policy: Option<BackoffPolicy>,
163 #[serde(default = "default_success_exit_codes")]
165 pub success_exit_codes: Vec<i32>,
166}
167
168struct RoleDefaultPolicyDifferences {
170 on_success_exit: OnSuccessAction,
172 on_timeout: OnTimeoutAction,
174 max_restarts: u32,
176}
177
178impl From<RoleDefaultPolicyDifferences> for RoleDefaultPolicy {
179 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
226#[serde(rename_all = "snake_case")]
227pub enum PolicySource {
228 RoleDefault,
230 UserOverride,
232 FallbackDefault,
234}
235
236impl Display for PolicySource {
237 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#[derive(
252 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
253)]
254pub enum SeverityClass {
255 Optional,
257 Standard,
259 Critical,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
265pub struct EffectivePolicy {
266 pub task_role: TaskRole,
268 pub policy_pack: RoleDefaultPolicy,
270 pub source: PolicySource,
272 pub used_fallback: bool,
274 pub overridden_fields: Vec<String>,
276 pub severity: SeverityClass,
278 pub group_name: Option<String>,
280}
281
282impl EffectivePolicy {
283 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct RoleSemanticConflict {
357 pub child_id: ChildId,
359 pub task_role: TaskRole,
361 pub conflicting_field: String,
363 pub user_value: String,
365 pub expected_semantic: String,
367 pub reason: String,
369}
370
371pub 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
397fn default_success_exit_codes() -> Vec<i32> {
407 vec![0]
408}
409
410fn bounded_restart_limit(max_restarts: u32) -> RestartLimit {
412 RestartLimit::new(max_restarts, Duration::from_secs(60))
413}
414
415fn default_backoff_policy() -> BackoffPolicy {
417 BackoffPolicy::new(Duration::from_millis(50), Duration::from_secs(5), 0.2)
418}
419
420fn 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
430fn 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
440fn 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
450fn 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
460fn supervisor_default() -> RoleDefaultPolicy {
462 RoleDefaultPolicyDifferences {
463 on_success_exit: OnSuccessAction::Restart,
464 on_timeout: OnTimeoutAction::RestartWithBackoff,
465 max_restarts: 3,
466 }
467 .into()
468}