Skip to main content

ralph/contracts/
task.rs

1//! Task contracts for Ralph queue entries.
2//!
3//! Responsibilities:
4//! - Define task payloads, enums, and schema helpers.
5//! - Provide ordering/cycling helpers for task priority.
6//!
7//! Not handled here:
8//! - Queue ordering or persistence logic (see `crate::queue`).
9//! - Config contract definitions (see `super::config`).
10//!
11//! Invariants/assumptions:
12//! - Serde/schemars attributes define the task wire contract.
13//! - Task priority ordering is critical > high > medium > low.
14
15use anyhow::{Result, bail};
16use schemars::JsonSchema;
17use serde::de::{self, Deserializer};
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use std::collections::HashMap;
21use std::str::FromStr;
22
23use super::RunnerCliOptionsPatch;
24use super::{Model, ModelEffort, PhaseOverrides, ReasoningEffort, Runner};
25
26/* ------------------------------ Task (JSON) ------------------------------ */
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
29#[serde(deny_unknown_fields)]
30pub struct Task {
31    pub id: String,
32
33    #[serde(default)]
34    pub status: TaskStatus,
35
36    pub title: String,
37
38    /// Detailed description of the task's context, goal, purpose, and desired outcome.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41
42    #[serde(default)]
43    pub priority: TaskPriority,
44
45    #[serde(default)]
46    pub tags: Vec<String>,
47
48    #[serde(default)]
49    pub scope: Vec<String>,
50
51    #[serde(default)]
52    pub evidence: Vec<String>,
53
54    #[serde(default)]
55    pub plan: Vec<String>,
56
57    #[serde(default)]
58    pub notes: Vec<String>,
59
60    /// Original human request that created the task (Task Builder / Scan).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub request: Option<String>,
63
64    /// Optional per-task agent override (runner/model/model_effort/phases/iterations/phase_overrides).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub agent: Option<TaskAgent>,
67
68    /// RFC3339 UTC timestamps as strings to keep the contract tool-agnostic.
69    #[schemars(required)]
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub created_at: Option<String>,
72    #[schemars(required)]
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub updated_at: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub completed_at: Option<String>,
77
78    /// RFC3339 UTC timestamp when work on this task actually started.
79    ///
80    /// Invariants:
81    /// - Must be RFC3339 UTC (Z) if set.
82    /// - Should be set when transitioning into `doing` (see status policy).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub started_at: Option<String>,
85
86    /// Estimated time to complete this task in minutes.
87    /// Optional; used for planning and estimation accuracy tracking.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub estimated_minutes: Option<u32>,
90
91    /// Actual time spent on this task in minutes.
92    /// Optional; set manually or computed from started_at to completed_at.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub actual_minutes: Option<u32>,
95
96    /// RFC3339 timestamp when the task should become runnable (optional scheduling).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub scheduled_start: Option<String>,
99
100    /// Task IDs that this task depends on (must be Done or Rejected before this task can run).
101    #[serde(default)]
102    pub depends_on: Vec<String>,
103
104    /// Task IDs that this task blocks (must be Done/Rejected before blocked tasks can run).
105    /// Semantically different from depends_on: blocks is "I prevent X" vs depends_on "I need X".
106    #[serde(default)]
107    pub blocks: Vec<String>,
108
109    /// Task IDs that this task relates to (loose coupling, no execution constraint).
110    /// Bidirectional awareness but no execution constraint.
111    #[serde(default)]
112    pub relates_to: Vec<String>,
113
114    /// Task ID that this task duplicates (if any).
115    /// Singular reference, not a list.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub duplicates: Option<String>,
118
119    /// Custom user-defined fields (key-value pairs for extensibility).
120    /// Values may be written as string/number/boolean; Ralph coerces them to strings when loading.
121    #[serde(default, deserialize_with = "deserialize_custom_fields")]
122    #[schemars(schema_with = "custom_fields_schema")]
123    pub custom_fields: HashMap<String, String>,
124
125    /// Parent task ID if this is a subtask (child-to-parent reference).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub parent_id: Option<String>,
128}
129
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default, JsonSchema)]
131#[serde(rename_all = "snake_case")]
132pub enum TaskStatus {
133    Draft,
134    #[default]
135    Todo,
136    Doing,
137    Done,
138    Rejected,
139}
140
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default, JsonSchema)]
142#[serde(rename_all = "snake_case")]
143pub enum TaskPriority {
144    Critical,
145    High,
146    #[default]
147    Medium,
148    Low,
149}
150
151// Custom PartialOrd implementation: Critical > High > Medium > Low
152impl PartialOrd for TaskPriority {
153    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
154        Some(self.cmp(other))
155    }
156}
157
158// Custom Ord implementation: Critical > High > Medium > Low (semantically)
159// Higher priority = Greater in comparison, so Critical > High > Medium > Low
160impl Ord for TaskPriority {
161    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
162        // Compare by weight: higher weight = higher priority = Greater
163        self.weight().cmp(&other.weight())
164    }
165}
166
167impl TaskPriority {
168    pub fn as_str(self) -> &'static str {
169        match self {
170            TaskPriority::Critical => "critical",
171            TaskPriority::High => "high",
172            TaskPriority::Medium => "medium",
173            TaskPriority::Low => "low",
174        }
175    }
176
177    pub fn weight(self) -> u8 {
178        match self {
179            TaskPriority::Critical => 3,
180            TaskPriority::High => 2,
181            TaskPriority::Medium => 1,
182            TaskPriority::Low => 0,
183        }
184    }
185
186    /// Cycle to the next priority in ascending order, wrapping after Critical.
187    pub fn cycle(self) -> Self {
188        match self {
189            TaskPriority::Low => TaskPriority::Medium,
190            TaskPriority::Medium => TaskPriority::High,
191            TaskPriority::High => TaskPriority::Critical,
192            TaskPriority::Critical => TaskPriority::Low,
193        }
194    }
195}
196
197impl std::fmt::Display for TaskPriority {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.write_str(self.as_str())
200    }
201}
202
203impl FromStr for TaskPriority {
204    type Err = anyhow::Error;
205
206    fn from_str(value: &str) -> Result<Self> {
207        let token = value.trim();
208
209        if token.eq_ignore_ascii_case("critical") {
210            return Ok(TaskPriority::Critical);
211        }
212        if token.eq_ignore_ascii_case("high") {
213            return Ok(TaskPriority::High);
214        }
215        if token.eq_ignore_ascii_case("medium") {
216            return Ok(TaskPriority::Medium);
217        }
218        if token.eq_ignore_ascii_case("low") {
219            return Ok(TaskPriority::Low);
220        }
221
222        bail!(
223            "Invalid priority: '{}'. Expected one of: critical, high, medium, low.",
224            token
225        )
226    }
227}
228
229impl TaskStatus {
230    pub fn as_str(self) -> &'static str {
231        match self {
232            TaskStatus::Draft => "draft",
233            TaskStatus::Todo => "todo",
234            TaskStatus::Doing => "doing",
235            TaskStatus::Done => "done",
236            TaskStatus::Rejected => "rejected",
237        }
238    }
239}
240
241impl std::fmt::Display for TaskStatus {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.write_str(self.as_str())
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
248#[serde(deny_unknown_fields)]
249pub struct TaskAgent {
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub runner: Option<Runner>,
252
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub model: Option<Model>,
255
256    /// Per-task reasoning effort override for Codex models. Default falls back to config.
257    #[serde(default, skip_serializing_if = "model_effort_is_default")]
258    #[schemars(schema_with = "model_effort_schema")]
259    pub model_effort: ModelEffort,
260
261    /// Number of execution phases for this task (1, 2, or 3), overriding config defaults.
262    #[schemars(range(min = 1, max = 3))]
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub phases: Option<u8>,
265
266    /// Number of iterations to run for this task (overrides config).
267    #[schemars(range(min = 1))]
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub iterations: Option<u8>,
270
271    /// Reasoning effort override for follow-up iterations (iterations > 1).
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub followup_reasoning_effort: Option<ReasoningEffort>,
274
275    /// Optional normalized runner CLI overrides for this task.
276    ///
277    /// This is intended to express runner behavior intent (output/approval/sandbox/etc)
278    /// without embedding runner-specific flag syntax into the queue.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub runner_cli: Option<RunnerCliOptionsPatch>,
281
282    /// Optional per-phase runner/model/effort overrides for this task.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub phase_overrides: Option<PhaseOverrides>,
285}
286
287fn model_effort_is_default(value: &ModelEffort) -> bool {
288    matches!(value, ModelEffort::Default)
289}
290
291fn model_effort_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
292    let mut schema = <ModelEffort as JsonSchema>::json_schema(generator);
293    schema
294        .ensure_object()
295        .insert("default".to_string(), json!("default"));
296    schema
297}
298
299/// Custom deserializer for `custom_fields` that coerces scalar values (string/number/bool)
300/// to strings, while rejecting null, arrays, and objects with descriptive errors.
301fn deserialize_custom_fields<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
302where
303    D: Deserializer<'de>,
304{
305    let value: serde_json::Value = serde_json::Value::deserialize(deserializer)?;
306    let raw = match value {
307        serde_json::Value::Object(map) => map,
308        serde_json::Value::Null => {
309            return Err(de::Error::custom(
310                "custom_fields must be an object (map); null is not allowed",
311            ));
312        }
313        other => {
314            return Err(de::Error::custom(format!(
315                "custom_fields must be an object (map); got {}",
316                other
317            )));
318        }
319    };
320
321    raw.into_iter()
322        .map(|(k, v)| {
323            let s = match v {
324                serde_json::Value::String(s) => s,
325                serde_json::Value::Number(n) => n.to_string(),
326                serde_json::Value::Bool(b) => b.to_string(),
327                serde_json::Value::Null => {
328                    return Err(de::Error::custom(format!(
329                        "custom_fields['{}'] must be a string/number/boolean (null is not allowed)",
330                        k
331                    )));
332                }
333                serde_json::Value::Array(_) => {
334                    return Err(de::Error::custom(format!(
335                        "custom_fields['{}'] must be a scalar (string/number/boolean); arrays are not allowed",
336                        k
337                    )));
338                }
339                serde_json::Value::Object(_) => {
340                    return Err(de::Error::custom(format!(
341                        "custom_fields['{}'] must be a scalar (string/number/boolean); objects are not allowed",
342                        k
343                    )));
344                }
345            };
346            Ok((k, s))
347        })
348        .collect()
349}
350
351/// Schema generator for `custom_fields` that accepts string/number/boolean values.
352fn custom_fields_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
353    schemars::json_schema!({
354        "type": "object",
355        "description": "Custom user-defined fields. Values may be written as string/number/boolean; Ralph coerces them to strings when loading the queue.",
356        "additionalProperties": {
357            "anyOf": [
358                {"type": "string"},
359                {"type": "number"},
360                {"type": "boolean"}
361            ]
362        }
363    })
364}
365
366#[cfg(test)]
367mod tests {
368    use super::{Task, TaskPriority};
369    use crate::contracts::{Model, PhaseOverrideConfig, PhaseOverrides, ReasoningEffort, Runner};
370    use std::collections::HashMap;
371
372    #[test]
373    fn task_priority_cycle_wraps_through_all_values() {
374        assert_eq!(TaskPriority::Low.cycle(), TaskPriority::Medium);
375        assert_eq!(TaskPriority::Medium.cycle(), TaskPriority::High);
376        assert_eq!(TaskPriority::High.cycle(), TaskPriority::Critical);
377        assert_eq!(TaskPriority::Critical.cycle(), TaskPriority::Low);
378    }
379
380    #[test]
381    fn task_priority_from_str_is_case_insensitive_and_trims() {
382        assert_eq!("HIGH".parse::<TaskPriority>().unwrap(), TaskPriority::High);
383        assert_eq!(
384            "Medium".parse::<TaskPriority>().unwrap(),
385            TaskPriority::Medium
386        );
387        assert_eq!(" low ".parse::<TaskPriority>().unwrap(), TaskPriority::Low);
388        assert_eq!(
389            "CRITICAL".parse::<TaskPriority>().unwrap(),
390            TaskPriority::Critical
391        );
392    }
393
394    #[test]
395    fn task_priority_from_str_invalid_has_canonical_error_message() {
396        let err = "nope".parse::<TaskPriority>().unwrap_err();
397        assert_eq!(
398            err.to_string(),
399            "Invalid priority: 'nope'. Expected one of: critical, high, medium, low."
400        );
401    }
402
403    #[test]
404    fn task_priority_from_str_empty_string_errors() {
405        let err = "".parse::<TaskPriority>().unwrap_err();
406        assert_eq!(
407            err.to_string(),
408            "Invalid priority: ''. Expected one of: critical, high, medium, low."
409        );
410    }
411
412    #[test]
413    fn task_custom_fields_deserialize_coerces_scalars_to_strings() {
414        let raw = r#"{
415            "id": "RQ-0001",
416            "title": "t",
417            "custom_fields": {
418                "guide_line_count": 1411,
419                "enabled": true,
420                "owner": "ralph"
421            }
422        }"#;
423
424        let task: Task = serde_json::from_str(raw).expect("deserialize");
425        assert_eq!(
426            task.custom_fields
427                .get("guide_line_count")
428                .map(String::as_str),
429            Some("1411")
430        );
431        assert_eq!(
432            task.custom_fields.get("enabled").map(String::as_str),
433            Some("true")
434        );
435        assert_eq!(
436            task.custom_fields.get("owner").map(String::as_str),
437            Some("ralph")
438        );
439    }
440
441    #[test]
442    fn task_custom_fields_deserialize_rejects_null() {
443        let raw = r#"{"id":"RQ-0001","title":"t","custom_fields":{"x":null}}"#;
444        let err = serde_json::from_str::<Task>(raw).unwrap_err();
445        let err_msg = err.to_string().to_lowercase();
446        assert!(
447            err_msg.contains("custom_fields"),
448            "error should mention custom_fields: {}",
449            err_msg
450        );
451        assert!(
452            err_msg.contains("null"),
453            "error should mention null: {}",
454            err_msg
455        );
456    }
457
458    #[test]
459    fn task_custom_fields_deserialize_rejects_custom_fields_null() {
460        let raw = r#"{"id":"RQ-0001","title":"t","custom_fields":null}"#;
461        let err = serde_json::from_str::<Task>(raw).unwrap_err();
462        let err_msg = err.to_string().to_lowercase();
463        assert!(
464            err_msg.contains("custom_fields"),
465            "error should mention custom_fields: {}",
466            err_msg
467        );
468        assert!(
469            err_msg.contains("null"),
470            "error should mention null: {}",
471            err_msg
472        );
473    }
474
475    #[test]
476    fn task_custom_fields_deserialize_rejects_custom_fields_non_object() {
477        let raw = r#"{"id":"RQ-0001","title":"t","custom_fields":123}"#;
478        let err = serde_json::from_str::<Task>(raw).unwrap_err();
479        let err_msg = err.to_string().to_lowercase();
480        assert!(
481            err_msg.contains("custom_fields"),
482            "error should mention custom_fields: {}",
483            err_msg
484        );
485        assert!(
486            err_msg.contains("object") || err_msg.contains("map"),
487            "error should mention object/map: {}",
488            err_msg
489        );
490    }
491
492    #[test]
493    fn task_custom_fields_deserialize_rejects_object_and_array_values() {
494        let raw_obj = r#"{"id":"RQ-0001","title":"t","custom_fields":{"x":{"a":1}}}"#;
495        let raw_arr = r#"{"id":"RQ-0001","title":"t","custom_fields":{"x":[1,2]}}"#;
496
497        let err_obj = serde_json::from_str::<Task>(raw_obj).unwrap_err();
498        let err_arr = serde_json::from_str::<Task>(raw_arr).unwrap_err();
499
500        let err_obj_msg = err_obj.to_string().to_lowercase();
501        let err_arr_msg = err_arr.to_string().to_lowercase();
502
503        assert!(
504            err_obj_msg.contains("custom_fields"),
505            "object error should mention custom_fields: {}",
506            err_obj_msg
507        );
508        assert!(
509            err_arr_msg.contains("custom_fields"),
510            "array error should mention custom_fields: {}",
511            err_arr_msg
512        );
513    }
514
515    #[test]
516    fn task_custom_fields_serializes_as_strings() {
517        let mut custom_fields = HashMap::new();
518        custom_fields.insert("count".to_string(), "42".to_string());
519        custom_fields.insert("enabled".to_string(), "true".to_string());
520
521        let task = Task {
522            id: "RQ-0001".to_string(),
523            title: "Test".to_string(),
524            custom_fields,
525            ..Default::default()
526        };
527
528        let json = serde_json::to_string(&task).expect("serialize");
529        assert!(json.contains("\"count\":\"42\""));
530        assert!(json.contains("\"enabled\":\"true\""));
531    }
532
533    #[test]
534    fn task_agent_deserializes_phases_and_phase_overrides() {
535        let raw = r#"{
536            "id":"RQ-0001",
537            "title":"Task with agent overrides",
538            "agent":{
539                "runner":"codex",
540                "model":"gpt-5.3-codex",
541                "model_effort":"high",
542                "phases":2,
543                "iterations":1,
544                "phase_overrides":{
545                    "phase1":{"runner":"codex","model":"gpt-5.3-codex","reasoning_effort":"high"},
546                    "phase2":{"runner":"kimi","model":"kimi-code/kimi-for-coding"}
547                }
548            }
549        }"#;
550
551        let task: Task = serde_json::from_str(raw).expect("deserialize");
552        let agent = task.agent.expect("agent should be set");
553        assert_eq!(agent.runner, Some(Runner::Codex));
554        assert_eq!(agent.model, Some(Model::Gpt53Codex));
555        assert_eq!(agent.phases, Some(2));
556        assert_eq!(agent.iterations, Some(1));
557
558        let phase_overrides = agent
559            .phase_overrides
560            .expect("phase overrides should be set");
561        let phase1 = phase_overrides.phase1.expect("phase1 should be set");
562        assert_eq!(phase1.runner, Some(Runner::Codex));
563        assert_eq!(phase1.reasoning_effort, Some(ReasoningEffort::High));
564        let phase2 = phase_overrides.phase2.expect("phase2 should be set");
565        assert_eq!(phase2.runner, Some(Runner::Kimi));
566    }
567
568    #[test]
569    fn task_agent_omits_default_phase_and_effort_fields_when_serializing() {
570        let task = Task {
571            id: "RQ-0001".to_string(),
572            title: "Serialize defaults".to_string(),
573            agent: Some(crate::contracts::TaskAgent {
574                runner: Some(Runner::Codex),
575                model: Some(Model::Gpt53Codex),
576                model_effort: crate::contracts::ModelEffort::Default,
577                phases: None,
578                iterations: None,
579                followup_reasoning_effort: None,
580                runner_cli: None,
581                phase_overrides: Some(PhaseOverrides {
582                    phase1: Some(PhaseOverrideConfig {
583                        runner: Some(Runner::Codex),
584                        model: Some(Model::Gpt53Codex),
585                        reasoning_effort: Some(ReasoningEffort::Medium),
586                    }),
587                    ..Default::default()
588                }),
589            }),
590            ..Default::default()
591        };
592
593        let value = serde_json::to_value(task).expect("serialize");
594        let agent = value
595            .get("agent")
596            .and_then(|v| v.as_object())
597            .expect("agent object should exist");
598        assert!(!agent.contains_key("model_effort"));
599        assert!(!agent.contains_key("phases"));
600        assert!(agent.contains_key("phase_overrides"));
601    }
602}