Skip to main content

ralph/contracts/task/
types.rs

1//! Purpose: Define serialized task and task-agent data models.
2//!
3//! Responsibilities:
4//! - Define `Task`, `TaskStatus`, and `TaskAgent`.
5//! - Attach serde and schemars annotations that define the task wire contract.
6//!
7//! Scope:
8//! - Data models only; task priority behavior and serde/schema helper hooks
9//!   live in sibling modules.
10//!
11//! Usage:
12//! - Used across queue, CLI, app, and machine surfaces via `crate::contracts`.
13//!
14//! Invariants/Assumptions:
15//! - Serde/schemars attributes are the source of truth for on-disk and
16//!   machine-facing task contracts.
17//! - Optional timestamps remain RFC3339 UTC strings when present.
18
19use std::collections::HashMap;
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::contracts::{
25    Model, ModelEffort, PhaseOverrides, ReasoningEffort, Runner, RunnerCliOptionsPatch,
26};
27
28use super::priority::TaskPriority;
29use super::serde_helpers::{
30    custom_fields_schema, deserialize_custom_fields, model_effort_is_default, model_effort_schema,
31};
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
34#[serde(deny_unknown_fields)]
35pub struct Task {
36    pub id: String,
37
38    #[serde(default)]
39    pub status: TaskStatus,
40
41    pub title: String,
42
43    /// Detailed description of the task's context, goal, purpose, and desired outcome.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub description: Option<String>,
46
47    #[serde(default)]
48    pub priority: TaskPriority,
49
50    #[serde(default)]
51    pub tags: Vec<String>,
52
53    #[serde(default)]
54    pub scope: Vec<String>,
55
56    #[serde(default)]
57    pub evidence: Vec<String>,
58
59    #[serde(default)]
60    pub plan: Vec<String>,
61
62    #[serde(default)]
63    pub notes: Vec<String>,
64
65    /// Original human request that created the task (Task Builder / Scan).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub request: Option<String>,
68
69    /// Optional per-task agent override (runner/model/model_effort/phases/iterations/phase_overrides).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub agent: Option<TaskAgent>,
72
73    /// RFC3339 UTC timestamps as strings to keep the contract tool-agnostic.
74    #[schemars(required)]
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub created_at: Option<String>,
77    #[schemars(required)]
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub updated_at: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub completed_at: Option<String>,
82
83    /// RFC3339 UTC timestamp when work on this task actually started.
84    ///
85    /// Invariants:
86    /// - Must be RFC3339 UTC (Z) if set.
87    /// - Should be set when transitioning into `doing` (see status policy).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub started_at: Option<String>,
90
91    /// Estimated time to complete this task in minutes.
92    /// Optional; used for planning and estimation accuracy tracking.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub estimated_minutes: Option<u32>,
95
96    /// Actual time spent on this task in minutes.
97    /// Optional; set manually or computed from started_at to completed_at.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub actual_minutes: Option<u32>,
100
101    /// RFC3339 timestamp when the task should become runnable (optional scheduling).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub scheduled_start: Option<String>,
104
105    /// Task IDs that this task depends on (must be Done or Rejected before this task can run).
106    #[serde(default)]
107    pub depends_on: Vec<String>,
108
109    /// Task IDs that this task blocks (must be Done/Rejected before blocked tasks can run).
110    /// Semantically different from depends_on: blocks is "I prevent X" vs depends_on "I need X".
111    #[serde(default)]
112    pub blocks: Vec<String>,
113
114    /// Task IDs that this task relates to (loose coupling, no execution constraint).
115    /// Bidirectional awareness but no execution constraint.
116    #[serde(default)]
117    pub relates_to: Vec<String>,
118
119    /// Task ID that this task duplicates (if any).
120    /// Singular reference, not a list.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub duplicates: Option<String>,
123
124    /// Custom user-defined fields (key-value pairs for extensibility).
125    /// Values may be written as string/number/boolean; Ralph coerces them to strings when loading.
126    #[serde(default, deserialize_with = "deserialize_custom_fields")]
127    #[schemars(schema_with = "custom_fields_schema")]
128    pub custom_fields: HashMap<String, String>,
129
130    /// Parent task ID if this is a subtask (child-to-parent reference).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub parent_id: Option<String>,
133}
134
135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum TaskStatus {
138    Draft,
139    #[default]
140    Todo,
141    Doing,
142    Done,
143    Rejected,
144}
145
146impl TaskStatus {
147    pub fn as_str(self) -> &'static str {
148        match self {
149            TaskStatus::Draft => "draft",
150            TaskStatus::Todo => "todo",
151            TaskStatus::Doing => "doing",
152            TaskStatus::Done => "done",
153            TaskStatus::Rejected => "rejected",
154        }
155    }
156}
157
158impl std::fmt::Display for TaskStatus {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.write_str(self.as_str())
161    }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
165#[serde(deny_unknown_fields)]
166pub struct TaskAgent {
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub runner: Option<Runner>,
169
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub model: Option<Model>,
172
173    /// Per-task reasoning effort override for Codex models. Default falls back to config.
174    #[serde(default, skip_serializing_if = "model_effort_is_default")]
175    #[schemars(schema_with = "model_effort_schema")]
176    pub model_effort: ModelEffort,
177
178    /// Number of execution phases for this task (1, 2, or 3), overriding config defaults.
179    #[schemars(range(min = 1, max = 3))]
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub phases: Option<u8>,
182
183    /// Number of iterations to run for this task (overrides config).
184    #[schemars(range(min = 1))]
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub iterations: Option<u8>,
187
188    /// Reasoning effort override for follow-up iterations (iterations > 1).
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub followup_reasoning_effort: Option<ReasoningEffort>,
191
192    /// Optional normalized runner CLI overrides for this task.
193    ///
194    /// This is intended to express runner behavior intent (output/approval/sandbox/etc)
195    /// without embedding runner-specific flag syntax into the queue.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub runner_cli: Option<RunnerCliOptionsPatch>,
198
199    /// Optional per-phase runner/model/effort overrides for this task.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub phase_overrides: Option<PhaseOverrides>,
202}