yarli_cli/yarli-core/src/entities/
task.rs1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::yarli_core::domain::{CommandClass, CorrelationId, EventId, RunId, TaskId};
12use crate::yarli_core::error::TransitionError;
13use crate::yarli_core::fsm::task::TaskState;
14
15use super::transition::Transition;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BlockerCode {
21 DependencyPending,
23 MergeConflict,
25 PolicyDenial,
27 GateFailure,
29 ManualHold,
31 Custom(String),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Task {
38 pub id: TaskId,
40 pub run_id: RunId,
42 pub task_key: String,
44 pub description: String,
46 pub state: TaskState,
48 pub command_class: CommandClass,
50 pub depends_on: Vec<TaskId>,
52 pub evidence_ids: Vec<Uuid>,
54 pub attempt_no: u32,
56 pub max_attempts: u32,
58 pub blocker: Option<BlockerCode>,
60 pub last_error: Option<String>,
63 pub blocker_detail: Option<String>,
66 pub correlation_id: CorrelationId,
68 pub created_at: DateTime<Utc>,
70 pub updated_at: DateTime<Utc>,
72 pub priority: u32,
74}
75
76fn clamp_task_priority(priority: u32) -> u32 {
77 priority.min(100)
78}
79
80impl Task {
81 pub fn new(
83 run_id: RunId,
84 task_key: impl Into<String>,
85 description: impl Into<String>,
86 command_class: CommandClass,
87 correlation_id: CorrelationId,
88 ) -> Self {
89 let now = Utc::now();
90 Self {
91 id: Uuid::now_v7(),
92 run_id,
93 task_key: task_key.into(),
94 description: description.into(),
95 state: TaskState::TaskOpen,
96 command_class,
97 depends_on: Vec::new(),
98 evidence_ids: Vec::new(),
99 attempt_no: 1,
100 max_attempts: 3,
101 blocker: None,
102 last_error: None,
103 blocker_detail: None,
104 correlation_id,
105 created_at: now,
106 updated_at: now,
107 priority: 3,
108 }
109 }
110
111 pub fn depends_on(&mut self, task_id: TaskId) {
113 if !self.depends_on.contains(&task_id) {
114 self.depends_on.push(task_id);
115 }
116 }
117
118 pub fn with_priority(mut self, priority: u32) -> Self {
120 self.priority = clamp_task_priority(priority);
121 self
122 }
123
124 pub fn with_max_attempts(mut self, max: u32) -> Self {
126 self.max_attempts = max;
127 self
128 }
129
130 pub fn set_last_error(&mut self, error: impl Into<String>) {
133 if self.last_error.is_none() {
134 self.last_error = Some(error.into());
135 }
136 }
137
138 pub fn set_blocker_detail(&mut self, detail: impl Into<String>) {
140 self.blocker_detail = Some(detail.into());
141 }
142
143 pub fn clear_blocker_detail(&mut self) {
145 self.blocker_detail = None;
146 }
147
148 pub fn attach_evidence(&mut self, evidence_id: Uuid) {
150 if !self.evidence_ids.contains(&evidence_id) {
151 self.evidence_ids.push(evidence_id);
152 }
153 }
154
155 pub fn transition(
163 &mut self,
164 to: TaskState,
165 reason: impl Into<String>,
166 actor: impl Into<String>,
167 causation_id: Option<EventId>,
168 ) -> Result<Transition, TransitionError> {
169 let from = self.state;
170
171 if from.is_terminal() {
172 if from == TaskState::TaskFailed && to == TaskState::TaskReady {
174 if self.attempt_no >= self.max_attempts {
175 return Err(TransitionError::TerminalState(format!(
176 "{from:?} (max attempts {} reached)",
177 self.max_attempts
178 )));
179 }
180 self.attempt_no += 1;
182 } else {
183 return Err(TransitionError::TerminalState(format!("{from:?}")));
184 }
185 }
186
187 if !from.can_transition_to(to) {
188 return Err(TransitionError::InvalidTaskTransition { from, to });
189 }
190
191 let reason_str = reason.into();
192 let actor_str = actor.into();
193
194 self.state = to;
195 self.updated_at = Utc::now();
196
197 if from == TaskState::TaskBlocked && to != TaskState::TaskBlocked {
199 self.blocker = None;
200 }
201
202 Ok(Transition::new(
203 "task",
204 self.id,
205 format!("{from:?}"),
206 format!("{to:?}"),
207 reason_str,
208 actor_str,
209 self.correlation_id,
210 causation_id,
211 ))
212 }
213
214 pub fn block(
216 &mut self,
217 blocker: BlockerCode,
218 reason: impl Into<String>,
219 actor: impl Into<String>,
220 causation_id: Option<EventId>,
221 ) -> Result<Transition, TransitionError> {
222 self.blocker = Some(blocker);
223 self.transition(TaskState::TaskBlocked, reason, actor, causation_id)
224 }
225
226 pub fn dependencies_satisfied<F>(&self, is_complete: F) -> bool
228 where
229 F: Fn(&TaskId) -> bool,
230 {
231 self.depends_on.iter().all(is_complete)
232 }
233}