Skip to main content

git_internal/internal/object/
task.rs

1//! AI Task snapshot.
2//!
3//! `Task` stores a stable unit of work derived from a plan or created by
4//! Libra as a delegated work item.
5//!
6//! # How to use this object
7//!
8//! - Create a `Task` when a `PlanStep` needs its own durable execution
9//!   unit.
10//! - Fill `parent`, `intent`, `origin_step_id`, and `dependencies`
11//!   before persistence if those provenance links are known.
12//! - Keep the stored object stable; define a new task snapshot only when
13//!   the work definition itself changes.
14//!
15//! # How it works with other objects
16//!
17//! - `Task.origin_step_id` links the task back to the stable
18//!   `PlanStep.step_id`.
19//! - `Run.task` links execution attempts to the task.
20//! - `TaskEvent` records lifecycle changes such as running / blocked /
21//!   done / failed.
22//!
23//! # How Libra should call it
24//!
25//! Libra should derive ready queues, dependency resolution, and current
26//! task status from `Task` plus `TaskEvent` and `Run` history. Those
27//! mutable scheduling views do not belong on the `Task` object itself.
28
29use std::{fmt, str::FromStr};
30
31use serde::{Deserialize, Serialize};
32use uuid::Uuid;
33
34use crate::{
35    errors::GitError,
36    hash::ObjectHash,
37    internal::object::{
38        ObjectTrait,
39        types::{ActorRef, Header, ObjectType},
40    },
41};
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum GoalType {
46    Feature,
47    Bugfix,
48    Refactor,
49    Docs,
50    Perf,
51    Test,
52    Chore,
53    Build,
54    Ci,
55    Style,
56    Other(String),
57}
58
59impl GoalType {
60    /// Return the canonical snake_case storage/display form for the goal
61    /// type.
62    pub fn as_str(&self) -> &str {
63        match self {
64            GoalType::Feature => "feature",
65            GoalType::Bugfix => "bugfix",
66            GoalType::Refactor => "refactor",
67            GoalType::Docs => "docs",
68            GoalType::Perf => "perf",
69            GoalType::Test => "test",
70            GoalType::Chore => "chore",
71            GoalType::Build => "build",
72            GoalType::Ci => "ci",
73            GoalType::Style => "style",
74            GoalType::Other(value) => value.as_str(),
75        }
76    }
77}
78
79impl fmt::Display for GoalType {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}", self.as_str())
82    }
83}
84
85impl FromStr for GoalType {
86    type Err = String;
87
88    fn from_str(value: &str) -> Result<Self, Self::Err> {
89        match value {
90            "feature" => Ok(GoalType::Feature),
91            "bugfix" => Ok(GoalType::Bugfix),
92            "refactor" => Ok(GoalType::Refactor),
93            "docs" => Ok(GoalType::Docs),
94            "perf" => Ok(GoalType::Perf),
95            "test" => Ok(GoalType::Test),
96            "chore" => Ok(GoalType::Chore),
97            "build" => Ok(GoalType::Build),
98            "ci" => Ok(GoalType::Ci),
99            "style" => Ok(GoalType::Style),
100            _ => Ok(GoalType::Other(value.to_string())),
101        }
102    }
103}
104
105/// Stable work definition used by the scheduler and execution layer.
106///
107/// `Task` answers "what work should be done?" rather than "what is the
108/// current runtime status?". Current status is reconstructed from event
109/// history in Libra.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct Task {
113    /// Common object header carrying the immutable object id, type,
114    /// creator, and timestamps.
115    #[serde(flatten)]
116    header: Header,
117    /// Short task title suitable for queues and summaries.
118    title: String,
119    /// Optional longer-form explanation of the work item.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    description: Option<String>,
122    /// Optional coarse work classification used by Libra and UI layers.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    goal: Option<GoalType>,
125    /// Explicit constraints that the executor must respect.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    constraints: Vec<String>,
128    /// Concrete acceptance criteria that define task completion.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    acceptance_criteria: Vec<String>,
131    /// Optional actor on whose behalf the task was requested.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    requester: Option<ActorRef>,
134    /// Optional parent task id when the task was decomposed from another
135    /// durable task.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    parent: Option<Uuid>,
138    /// Optional originating intent id for cross-object provenance.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    intent: Option<Uuid>,
141    /// Optional stable plan-step id that originally spawned this task.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    origin_step_id: Option<Uuid>,
144    /// Other task ids that must complete before this task becomes ready.
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    dependencies: Vec<Uuid>,
147}
148
149impl Task {
150    /// Create a new task definition with the given title and optional
151    /// goal type.
152    pub fn new(
153        created_by: ActorRef,
154        title: impl Into<String>,
155        goal: Option<GoalType>,
156    ) -> Result<Self, String> {
157        Ok(Self {
158            header: Header::new(ObjectType::Task, created_by)?,
159            title: title.into(),
160            description: None,
161            goal,
162            constraints: Vec::new(),
163            acceptance_criteria: Vec::new(),
164            requester: None,
165            parent: None,
166            intent: None,
167            origin_step_id: None,
168            dependencies: Vec::new(),
169        })
170    }
171
172    /// Return the immutable header for this task definition.
173    pub fn header(&self) -> &Header {
174        &self.header
175    }
176
177    /// Return the short task title.
178    pub fn title(&self) -> &str {
179        &self.title
180    }
181
182    /// Return the longer-form task description, if present.
183    pub fn description(&self) -> Option<&str> {
184        self.description.as_deref()
185    }
186
187    /// Return the coarse work classification, if present.
188    pub fn goal(&self) -> Option<&GoalType> {
189        self.goal.as_ref()
190    }
191
192    /// Return the explicit task constraints.
193    pub fn constraints(&self) -> &[String] {
194        &self.constraints
195    }
196
197    /// Return the acceptance criteria for task completion.
198    pub fn acceptance_criteria(&self) -> &[String] {
199        &self.acceptance_criteria
200    }
201
202    /// Return the requesting actor, if one was stored.
203    pub fn requester(&self) -> Option<&ActorRef> {
204        self.requester.as_ref()
205    }
206
207    /// Return the parent task id, if this task was derived from another
208    /// task.
209    pub fn parent(&self) -> Option<Uuid> {
210        self.parent
211    }
212
213    /// Return the originating intent id, if present.
214    pub fn intent(&self) -> Option<Uuid> {
215        self.intent
216    }
217
218    /// Return the stable plan-step id that originally spawned this task,
219    /// if present.
220    pub fn origin_step_id(&self) -> Option<Uuid> {
221        self.origin_step_id
222    }
223
224    /// Return the task dependency list.
225    pub fn dependencies(&self) -> &[Uuid] {
226        &self.dependencies
227    }
228
229    /// Set or clear the long-form task description.
230    pub fn set_description(&mut self, description: Option<String>) {
231        self.description = description;
232    }
233
234    /// Append one execution constraint to this task definition.
235    pub fn add_constraint(&mut self, constraint: impl Into<String>) {
236        self.constraints.push(constraint.into());
237    }
238
239    /// Append one acceptance criterion to this task definition.
240    pub fn add_acceptance_criterion(&mut self, criterion: impl Into<String>) {
241        self.acceptance_criteria.push(criterion.into());
242    }
243
244    /// Set or clear the requesting actor for this task.
245    pub fn set_requester(&mut self, requester: Option<ActorRef>) {
246        self.requester = requester;
247    }
248
249    /// Set or clear the parent task link.
250    pub fn set_parent(&mut self, parent: Option<Uuid>) {
251        self.parent = parent;
252    }
253
254    /// Set or clear the originating intent link.
255    pub fn set_intent(&mut self, intent: Option<Uuid>) {
256        self.intent = intent;
257    }
258
259    /// Set or clear the originating stable plan-step link.
260    pub fn set_origin_step_id(&mut self, origin_step_id: Option<Uuid>) {
261        self.origin_step_id = origin_step_id;
262    }
263
264    /// Append one prerequisite task id to the dependency list.
265    pub fn add_dependency(&mut self, task_id: Uuid) {
266        self.dependencies.push(task_id);
267    }
268}
269
270impl fmt::Display for Task {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        write!(f, "Task: {}", self.header.object_id())
273    }
274}
275
276impl ObjectTrait for Task {
277    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
278    where
279        Self: Sized,
280    {
281        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
282    }
283
284    fn get_type(&self) -> ObjectType {
285        ObjectType::Task
286    }
287
288    fn get_size(&self) -> usize {
289        match serde_json::to_vec(self) {
290            Ok(v) => v.len(),
291            Err(e) => {
292                tracing::warn!("failed to compute Task size: {}", e);
293                0
294            }
295        }
296    }
297
298    fn to_data(&self) -> Result<Vec<u8>, GitError> {
299        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_task_creation() {
309        let actor = ActorRef::agent("worker").expect("actor");
310        let task = Task::new(actor, "Implement pagination", Some(GoalType::Feature)).expect("task");
311
312        assert_eq!(task.title(), "Implement pagination");
313        assert_eq!(task.goal(), Some(&GoalType::Feature));
314        assert!(task.origin_step_id().is_none());
315    }
316
317    #[test]
318    fn test_task_requester() {
319        let actor = ActorRef::agent("worker").expect("actor");
320        let requester = ActorRef::human("alice").expect("requester");
321        let mut task = Task::new(actor, "Implement pagination", None).expect("task");
322        task.set_requester(Some(requester.clone()));
323        assert_eq!(task.requester(), Some(&requester));
324    }
325
326    #[test]
327    fn test_task_goal_optional() {
328        let actor = ActorRef::agent("worker").expect("actor");
329        let task = Task::new(actor, "Investigate", None).expect("task");
330        assert!(task.goal().is_none());
331    }
332
333    #[test]
334    fn test_task_origin_step_id() {
335        let actor = ActorRef::agent("worker").expect("actor");
336        let mut task = Task::new(actor, "Implement pagination", None).expect("task");
337        let step_id = Uuid::from_u128(0x1234);
338        task.set_origin_step_id(Some(step_id));
339        assert_eq!(task.origin_step_id(), Some(step_id));
340    }
341
342    #[test]
343    fn test_task_dependencies() {
344        let actor = ActorRef::agent("worker").expect("actor");
345        let mut task = Task::new(actor, "Implement pagination", None).expect("task");
346        let dep = Uuid::from_u128(0xAA);
347        task.add_dependency(dep);
348        assert_eq!(task.dependencies(), &[dep]);
349    }
350}