Skip to main content

zeph_orchestration/
graph.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10pub use zeph_config::FailureStrategy;
11use zeph_memory::store::graph_store::{GraphSummary, RawGraphStore};
12
13use super::error::OrchestrationError;
14use super::verify_predicate::{PredicateOutcome, VerifyPredicate};
15
16/// Index of a task within a [`TaskGraph::tasks`] `Vec`.
17///
18/// `TaskId` is a dense, zero-based `u32` index. The invariant
19/// `tasks[i].id == TaskId(i as u32)` holds throughout the lifetime of a graph.
20///
21/// # Examples
22///
23/// ```rust
24/// use zeph_orchestration::TaskId;
25///
26/// let id = TaskId(3);
27/// assert_eq!(id.index(), 3);
28/// assert_eq!(id.as_u32(), 3);
29/// assert_eq!(id.to_string(), "3");
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct TaskId(pub u32);
33
34impl TaskId {
35    /// Returns the index for Vec access.
36    #[must_use]
37    pub fn index(self) -> usize {
38        self.0 as usize
39    }
40
41    /// Returns the raw `u32` value.
42    #[must_use]
43    pub fn as_u32(self) -> u32 {
44        self.0
45    }
46}
47
48impl fmt::Display for TaskId {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "{}", self.0)
51    }
52}
53
54/// Unique identifier for a [`TaskGraph`].
55///
56/// Backed by a UUID v4. Implements `FromStr` / `Display` for serialization and
57/// CLI lookup.
58///
59/// # Examples
60///
61/// ```rust
62/// use zeph_orchestration::GraphId;
63///
64/// let id = GraphId::new();
65/// let s = id.to_string();
66/// assert_eq!(s.len(), 36); // UUID string representation
67///
68/// let parsed: GraphId = s.parse().expect("valid UUID");
69/// assert_eq!(id, parsed);
70/// ```
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub struct GraphId(Uuid);
73
74impl GraphId {
75    /// Generate a new random v4 `GraphId`.
76    #[must_use]
77    pub fn new() -> Self {
78        Self(Uuid::new_v4())
79    }
80}
81
82impl Default for GraphId {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl fmt::Display for GraphId {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{}", self.0)
91    }
92}
93
94impl FromStr for GraphId {
95    type Err = OrchestrationError;
96
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        Uuid::parse_str(s)
99            .map(GraphId)
100            .map_err(|e| OrchestrationError::InvalidGraph(format!("invalid graph id '{s}': {e}")))
101    }
102}
103
104/// Lifecycle status of a single task node.
105///
106/// State machine:
107///
108/// ```text
109/// Pending → Ready → Running → Completed  (success)
110///                           → Failed     (error; then failure strategy applies)
111///                           → Skipped    (upstream failed with Skip strategy)
112///                           → Canceled   (graph aborted while task was running)
113/// ```
114///
115/// Only `Completed`, `Failed`, `Skipped`, and `Canceled` are terminal — see
116/// [`TaskStatus::is_terminal`].
117///
118/// # Examples
119///
120/// ```rust
121/// use zeph_orchestration::TaskStatus;
122///
123/// assert!(TaskStatus::Completed.is_terminal());
124/// assert!(!TaskStatus::Running.is_terminal());
125/// assert_eq!(TaskStatus::Pending.to_string(), "pending");
126/// ```
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum TaskStatus {
130    /// Waiting for dependencies to complete.
131    Pending,
132    /// All dependencies completed; ready to be scheduled.
133    Ready,
134    /// A sub-agent is actively executing this task.
135    Running,
136    /// Sub-agent completed successfully.
137    Completed,
138    /// Sub-agent returned an error.
139    Failed,
140    /// Task was skipped because an upstream task failed with [`FailureStrategy::Skip`].
141    Skipped,
142    /// Task was running when the graph was aborted ([`FailureStrategy::Abort`]).
143    Canceled,
144}
145
146impl TaskStatus {
147    /// Returns `true` if the status is a terminal state.
148    #[must_use]
149    pub fn is_terminal(self) -> bool {
150        matches!(
151            self,
152            TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Skipped | TaskStatus::Canceled
153        )
154    }
155}
156
157impl fmt::Display for TaskStatus {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            TaskStatus::Pending => write!(f, "pending"),
161            TaskStatus::Ready => write!(f, "ready"),
162            TaskStatus::Running => write!(f, "running"),
163            TaskStatus::Completed => write!(f, "completed"),
164            TaskStatus::Failed => write!(f, "failed"),
165            TaskStatus::Skipped => write!(f, "skipped"),
166            TaskStatus::Canceled => write!(f, "canceled"),
167        }
168    }
169}
170
171/// Lifecycle status of a [`TaskGraph`].
172///
173/// # Examples
174///
175/// ```rust
176/// use zeph_orchestration::GraphStatus;
177///
178/// assert_eq!(GraphStatus::Running.to_string(), "running");
179/// assert_eq!(GraphStatus::Failed.to_string(), "failed");
180/// ```
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum GraphStatus {
184    /// Graph has been created but the scheduler has not started yet.
185    Created,
186    /// Scheduler is actively dispatching tasks.
187    Running,
188    /// All tasks reached a terminal state successfully.
189    Completed,
190    /// At least one task failed and the `Abort` strategy halted the graph.
191    Failed,
192    /// The graph was canceled by an external caller.
193    Canceled,
194    /// Graph is paused; waiting for user input (triggered by [`FailureStrategy::Ask`]).
195    Paused,
196}
197
198impl fmt::Display for GraphStatus {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            GraphStatus::Created => write!(f, "created"),
202            GraphStatus::Running => write!(f, "running"),
203            GraphStatus::Completed => write!(f, "completed"),
204            GraphStatus::Failed => write!(f, "failed"),
205            GraphStatus::Canceled => write!(f, "canceled"),
206            GraphStatus::Paused => write!(f, "paused"),
207        }
208    }
209}
210
211/// Output produced by a completed task.
212///
213/// Stored in [`TaskNode::result`] after the sub-agent finishes. Used by
214/// [`Aggregator`] to build the final synthesised response.
215///
216/// [`Aggregator`]: crate::aggregator::Aggregator
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct TaskResult {
219    /// Raw text output returned by the sub-agent.
220    pub output: String,
221    /// File-system paths to any artifacts produced (e.g. build outputs, reports).
222    pub artifacts: Vec<PathBuf>,
223    /// Wall-clock execution time in milliseconds.
224    pub duration_ms: u64,
225    /// Handle ID of the sub-agent instance that produced this result.
226    pub agent_id: Option<String>,
227    /// Name of the agent definition used to spawn the sub-agent.
228    pub agent_def: Option<String>,
229}
230
231/// Execution mode annotation emitted by the LLM planner for each task.
232///
233/// Controls how the [`DagScheduler`] dispatches a task relative to its siblings.
234/// The annotation is set by the planner and stored in [`TaskNode::execution_mode`].
235/// Absent or `null` in stored JSON deserialises to the default `Parallel`.
236///
237/// [`DagScheduler`]: crate::scheduler::DagScheduler
238///
239/// # Examples
240///
241/// ```rust
242/// use zeph_orchestration::ExecutionMode;
243///
244/// assert_eq!(ExecutionMode::default(), ExecutionMode::Parallel);
245/// let mode: ExecutionMode = serde_json::from_str("\"sequential\"").unwrap();
246/// assert_eq!(mode, ExecutionMode::Sequential);
247/// ```
248#[derive(
249    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
250)]
251#[serde(rename_all = "snake_case")]
252pub enum ExecutionMode {
253    /// Task can run in parallel with others at the same DAG level.
254    #[default]
255    Parallel,
256    /// Task is globally serialized: at most one `Sequential` task runs at a time across
257    /// the entire graph (e.g. deploy, exclusive-resource access, shared-state mutation).
258    Sequential,
259}
260
261/// A single node in the task DAG.
262///
263/// Constructed by [`Planner`] and stored inside a [`TaskGraph`].  The
264/// scheduler drives each node through its [`TaskStatus`] lifecycle.
265///
266/// [`Planner`]: crate::planner::Planner
267///
268/// # Examples
269///
270/// ```rust
271/// use zeph_orchestration::{TaskNode, TaskStatus, ExecutionMode};
272///
273/// let node = TaskNode::new(0, "fetch data", "Download the dataset from source.");
274/// assert_eq!(node.status, TaskStatus::Pending);
275/// assert!(node.depends_on.is_empty());
276/// assert_eq!(node.execution_mode, ExecutionMode::Parallel);
277/// ```
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct TaskNode {
280    /// Dense zero-based index. Invariant: `tasks[i].id == TaskId(i)`.
281    pub id: TaskId,
282    /// Short, human-readable task title.
283    pub title: String,
284    /// Full task description passed verbatim to the assigned sub-agent as its prompt.
285    pub description: String,
286    /// Preferred agent name suggested by the planner; `None` lets the router decide.
287    pub agent_hint: Option<String>,
288    /// Current lifecycle status.
289    pub status: TaskStatus,
290    /// Indices of tasks this node depends on.
291    pub depends_on: Vec<TaskId>,
292    /// Result populated by the scheduler after the sub-agent finishes.
293    pub result: Option<TaskResult>,
294    /// Agent name actually assigned by the router at dispatch time.
295    pub assigned_agent: Option<String>,
296    /// Number of times this task has been retried so far (execution retries only).
297    pub retry_count: u32,
298    /// Number of predicate-driven re-runs for this task (independent of `retry_count`).
299    #[serde(default)]
300    pub predicate_rerun_count: u32,
301    /// Per-task failure strategy override; `None` means use [`TaskGraph::default_failure_strategy`].
302    pub failure_strategy: Option<FailureStrategy>,
303    /// Maximum retry attempts for this task; `None` means use [`TaskGraph::default_max_retries`].
304    pub max_retries: Option<u32>,
305    /// LLM planner annotation. Old SQLite-stored JSON without this field
306    /// deserialises to the default (`Parallel`).
307    #[serde(default)]
308    pub execution_mode: ExecutionMode,
309    /// Per-subtask verification predicate (predicate gate).
310    ///
311    /// When `Some`, the task's output must satisfy this criterion before downstream
312    /// tasks may consume it. The scheduler emits `SchedulerAction::VerifyPredicate`
313    /// after task completion and blocks downstream dispatch until
314    /// `predicate_outcome.is_some()`.
315    #[serde(default)]
316    pub verify_predicate: Option<VerifyPredicate>,
317    /// Outcome of the most recent predicate evaluation.
318    ///
319    /// `None` means the gate has not been evaluated yet (in-memory only; restart
320    /// re-evaluates any pending predicates). The scheduler re-emits `VerifyPredicate`
321    /// on every tick while this is `None` and `verify_predicate.is_some()`.
322    #[serde(default)]
323    pub predicate_outcome: Option<PredicateOutcome>,
324    /// Named execution environment from `[[execution.environments]]` to use when
325    /// dispatching shell tool calls for this task. `None` inherits the executor default.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub execution_environment: Option<String>,
328
329    /// Per-task cost budget in US cents. `None` = use `OrchestrationConfig::default_task_budget_cents`.
330    ///
331    /// On task completion the scheduler checks whether this budget was exceeded and
332    /// emits a `tracing::warn!`. Hard enforcement is deferred post-v1.0.0.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub token_budget_cents: Option<f64>,
335}
336
337impl TaskNode {
338    /// Create a new pending task with the given index.
339    #[must_use]
340    pub fn new(id: u32, title: impl Into<String>, description: impl Into<String>) -> Self {
341        Self {
342            id: TaskId(id),
343            title: title.into(),
344            description: description.into(),
345            agent_hint: None,
346            status: TaskStatus::Pending,
347            depends_on: Vec::new(),
348            result: None,
349            assigned_agent: None,
350            retry_count: 0,
351            predicate_rerun_count: 0,
352            failure_strategy: None,
353            max_retries: None,
354            execution_mode: ExecutionMode::default(),
355            verify_predicate: None,
356            predicate_outcome: None,
357            execution_environment: None,
358            token_budget_cents: None,
359        }
360    }
361}
362
363/// A directed acyclic graph of tasks to be executed by the orchestrator.
364///
365/// Created by the [`Planner`] and driven to completion by the [`DagScheduler`].
366/// The `tasks` vec is the authoritative store; all indices (`TaskId`) reference
367/// positions within it.
368///
369/// [`Planner`]: crate::planner::Planner
370/// [`DagScheduler`]: crate::scheduler::DagScheduler
371///
372/// # Examples
373///
374/// ```rust
375/// use zeph_orchestration::{TaskGraph, TaskNode, GraphStatus, FailureStrategy};
376///
377/// let mut graph = TaskGraph::new("build and deploy service");
378/// assert_eq!(graph.status, GraphStatus::Created);
379/// assert_eq!(graph.default_failure_strategy, FailureStrategy::Abort);
380/// assert_eq!(graph.default_max_retries, 3);
381/// ```
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct TaskGraph {
384    /// Unique graph identifier (UUID v4).
385    pub id: GraphId,
386    /// High-level user goal that was decomposed into this graph.
387    pub goal: String,
388    /// All task nodes. Index `i` must satisfy `tasks[i].id == TaskId(i)`.
389    pub tasks: Vec<TaskNode>,
390    /// Current lifecycle status of the graph as a whole.
391    pub status: GraphStatus,
392    /// Graph-wide failure strategy applied when a task has no per-task override.
393    pub default_failure_strategy: FailureStrategy,
394    /// Graph-wide maximum retry count applied when a task has no per-task override.
395    pub default_max_retries: u32,
396    /// ISO-8601 UTC timestamp of graph creation.
397    pub created_at: String,
398    /// ISO-8601 UTC timestamp set when the graph reaches a terminal status.
399    pub finished_at: Option<String>,
400}
401
402impl TaskGraph {
403    /// Create a new graph with `Created` status.
404    #[must_use]
405    pub fn new(goal: impl Into<String>) -> Self {
406        Self {
407            id: GraphId::new(),
408            goal: goal.into(),
409            tasks: Vec::new(),
410            status: GraphStatus::Created,
411            default_failure_strategy: FailureStrategy::default(),
412            default_max_retries: 3,
413            created_at: chrono_now(),
414            finished_at: None,
415        }
416    }
417}
418
419pub(crate) fn chrono_now() -> String {
420    // ISO-8601 UTC timestamp, consistent with the rest of the codebase.
421    // Format: "2026-03-05T22:04:41Z"
422    let secs = std::time::SystemTime::now()
423        .duration_since(std::time::UNIX_EPOCH)
424        .map_or(0, |d| d.as_secs());
425    // Manual formatting: seconds since epoch → ISO-8601 UTC
426    // Days since epoch, then decompose into year/month/day
427    let (y, mo, d, h, mi, s) = epoch_secs_to_datetime(secs);
428    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
429}
430
431/// Convert Unix epoch seconds to (year, month, day, hour, min, sec) UTC.
432fn epoch_secs_to_datetime(secs: u64) -> (u64, u8, u8, u8, u8, u8) {
433    let s = (secs % 60) as u8;
434    let mins = secs / 60;
435    let mi = (mins % 60) as u8;
436    let hours = mins / 60;
437    let h = (hours % 24) as u8;
438    let days = hours / 24; // days since 1970-01-01
439
440    // Gregorian calendar decomposition
441    // 400-year cycle = 146097 days
442    let (mut year, mut remaining_days) = {
443        let cycles = days / 146_097;
444        let rem = days % 146_097;
445        (1970 + cycles * 400, rem)
446    };
447    // 100-year century (36524 days, no leap on century unless /400)
448    let centuries = (remaining_days / 36_524).min(3);
449    year += centuries * 100;
450    remaining_days -= centuries * 36_524;
451    // 4-year cycle (1461 days)
452    let quads = remaining_days / 1_461;
453    year += quads * 4;
454    remaining_days -= quads * 1_461;
455    // remaining years
456    let extra_years = (remaining_days / 365).min(3);
457    year += extra_years;
458    remaining_days -= extra_years * 365;
459
460    let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
461    let days_in_month: [u64; 12] = if is_leap {
462        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
463    } else {
464        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
465    };
466
467    let mut month = 0u8;
468    for (i, &dim) in days_in_month.iter().enumerate() {
469        if remaining_days < dim {
470            // i is in 0..12, so i+1 fits in u8
471            month = u8::try_from(i + 1).unwrap_or(1);
472            break;
473        }
474        remaining_days -= dim;
475    }
476    // remaining_days is in 0..30, so +1 fits in u8
477    let day = u8::try_from(remaining_days + 1).unwrap_or(1);
478
479    (year, month, day, h, mi, s)
480}
481
482/// Maximum allowed length for a `TaskGraph` goal string.
483const MAX_GOAL_LEN: usize = 1024;
484
485/// Type-safe wrapper around `RawGraphStore` that handles `TaskGraph` serialization.
486///
487/// Consumers in `zeph-core` use this instead of `RawGraphStore` directly, so they
488/// never need to deal with JSON strings.
489///
490/// # Storage layout
491///
492/// The `task_graphs` table stores both metadata columns (`goal`, `status`,
493/// `created_at`, `finished_at`) and the full `graph_json` blob. The metadata
494/// columns are summary/index data used for listing and filtering; `graph_json`
495/// is the authoritative source for full graph reconstruction. On `load`, only
496/// `graph_json` is deserialized — the columns are not consulted.
497pub struct GraphPersistence<S: RawGraphStore> {
498    store: S,
499}
500
501impl<S: RawGraphStore> GraphPersistence<S> {
502    /// Create a new `GraphPersistence` wrapping the given store.
503    pub fn new(store: S) -> Self {
504        Self { store }
505    }
506
507    /// Persist a `TaskGraph` (upsert).
508    ///
509    /// Returns `OrchestrationError::InvalidGraph` if `graph.goal` exceeds
510    /// `MAX_GOAL_LEN` (1024) characters.
511    ///
512    /// # Errors
513    ///
514    /// Returns `OrchestrationError::Persistence` on serialization or database failure.
515    pub async fn save(&self, graph: &TaskGraph) -> Result<(), OrchestrationError> {
516        if graph.goal.len() > MAX_GOAL_LEN {
517            return Err(OrchestrationError::InvalidGraph(format!(
518                "goal exceeds {MAX_GOAL_LEN} character limit ({} chars)",
519                graph.goal.len()
520            )));
521        }
522        let json = serde_json::to_string(graph)
523            .map_err(|e| OrchestrationError::Persistence(e.to_string()))?;
524        self.store
525            .save_graph(
526                &graph.id.to_string(),
527                &graph.goal,
528                &graph.status.to_string(),
529                &json,
530                &graph.created_at,
531                graph.finished_at.as_deref(),
532            )
533            .await
534            .map_err(|e| OrchestrationError::Persistence(e.to_string()))
535    }
536
537    /// Load a `TaskGraph` by its `GraphId`.
538    ///
539    /// Returns `None` if not found.
540    ///
541    /// # Errors
542    ///
543    /// Returns `OrchestrationError::Persistence` on database or deserialization failure.
544    pub async fn load(&self, id: &GraphId) -> Result<Option<TaskGraph>, OrchestrationError> {
545        match self
546            .store
547            .load_graph(&id.to_string())
548            .await
549            .map_err(|e| OrchestrationError::Persistence(e.to_string()))?
550        {
551            Some(json) => {
552                let graph = serde_json::from_str(&json)
553                    .map_err(|e| OrchestrationError::Persistence(e.to_string()))?;
554                Ok(Some(graph))
555            }
556            None => Ok(None),
557        }
558    }
559
560    /// List stored graphs (newest first).
561    ///
562    /// # Errors
563    ///
564    /// Returns `OrchestrationError::Persistence` on database failure.
565    pub async fn list(&self, limit: u32) -> Result<Vec<GraphSummary>, OrchestrationError> {
566        self.store
567            .list_graphs(limit)
568            .await
569            .map_err(|e| OrchestrationError::Persistence(e.to_string()))
570    }
571
572    /// Delete a graph by its `GraphId`.
573    ///
574    /// Returns `true` if a row was deleted.
575    ///
576    /// # Errors
577    ///
578    /// Returns `OrchestrationError::Persistence` on database failure.
579    pub async fn delete(&self, id: &GraphId) -> Result<bool, OrchestrationError> {
580        self.store
581            .delete_graph(&id.to_string())
582            .await
583            .map_err(|e| OrchestrationError::Persistence(e.to_string()))
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_taskid_display() {
593        assert_eq!(TaskId(3).to_string(), "3");
594    }
595
596    #[test]
597    fn test_graphid_display_and_new() {
598        let id = GraphId::new();
599        let s = id.to_string();
600        assert_eq!(s.len(), 36, "UUID string should be 36 chars");
601        let parsed: GraphId = s.parse().expect("should parse back");
602        assert_eq!(id, parsed);
603    }
604
605    #[test]
606    fn test_graphid_from_str_invalid() {
607        let err = "not-a-uuid".parse::<GraphId>();
608        assert!(err.is_err());
609    }
610
611    #[test]
612    fn test_task_status_is_terminal() {
613        assert!(TaskStatus::Completed.is_terminal());
614        assert!(TaskStatus::Failed.is_terminal());
615        assert!(TaskStatus::Skipped.is_terminal());
616        assert!(TaskStatus::Canceled.is_terminal());
617
618        assert!(!TaskStatus::Pending.is_terminal());
619        assert!(!TaskStatus::Ready.is_terminal());
620        assert!(!TaskStatus::Running.is_terminal());
621    }
622
623    #[test]
624    fn test_task_status_display() {
625        assert_eq!(TaskStatus::Pending.to_string(), "pending");
626        assert_eq!(TaskStatus::Ready.to_string(), "ready");
627        assert_eq!(TaskStatus::Running.to_string(), "running");
628        assert_eq!(TaskStatus::Completed.to_string(), "completed");
629        assert_eq!(TaskStatus::Failed.to_string(), "failed");
630        assert_eq!(TaskStatus::Skipped.to_string(), "skipped");
631        assert_eq!(TaskStatus::Canceled.to_string(), "canceled");
632    }
633
634    #[test]
635    fn test_failure_strategy_default() {
636        assert_eq!(FailureStrategy::default(), FailureStrategy::Abort);
637    }
638
639    #[test]
640    fn test_failure_strategy_display() {
641        assert_eq!(FailureStrategy::Abort.to_string(), "abort");
642        assert_eq!(FailureStrategy::Retry.to_string(), "retry");
643        assert_eq!(FailureStrategy::Skip.to_string(), "skip");
644        assert_eq!(FailureStrategy::Ask.to_string(), "ask");
645    }
646
647    #[test]
648    fn test_graph_status_display() {
649        assert_eq!(GraphStatus::Created.to_string(), "created");
650        assert_eq!(GraphStatus::Running.to_string(), "running");
651        assert_eq!(GraphStatus::Completed.to_string(), "completed");
652        assert_eq!(GraphStatus::Failed.to_string(), "failed");
653        assert_eq!(GraphStatus::Canceled.to_string(), "canceled");
654        assert_eq!(GraphStatus::Paused.to_string(), "paused");
655    }
656
657    #[test]
658    fn test_task_graph_serde_roundtrip() {
659        let mut graph = TaskGraph::new("test goal");
660        graph.tasks.push(TaskNode::new(0, "task 0", "do something"));
661        let json = serde_json::to_string(&graph).expect("serialize");
662        let restored: TaskGraph = serde_json::from_str(&json).expect("deserialize");
663        assert_eq!(graph.id, restored.id);
664        assert_eq!(graph.goal, restored.goal);
665        assert_eq!(graph.tasks.len(), restored.tasks.len());
666    }
667
668    #[test]
669    fn test_task_node_serde_roundtrip() {
670        let mut node = TaskNode::new(1, "compile", "run cargo build");
671        node.agent_hint = Some("rust-dev".to_string());
672        node.depends_on = vec![TaskId(0)];
673        let json = serde_json::to_string(&node).expect("serialize");
674        let restored: TaskNode = serde_json::from_str(&json).expect("deserialize");
675        assert_eq!(node.id, restored.id);
676        assert_eq!(node.title, restored.title);
677        assert_eq!(node.depends_on, restored.depends_on);
678    }
679
680    #[test]
681    fn test_task_result_serde_roundtrip() {
682        let result = TaskResult {
683            output: "ok".to_string(),
684            artifacts: vec![PathBuf::from("/tmp/out.bin")],
685            duration_ms: 500,
686            agent_id: Some("agent-1".to_string()),
687            agent_def: None,
688        };
689        let json = serde_json::to_string(&result).expect("serialize");
690        let restored: TaskResult = serde_json::from_str(&json).expect("deserialize");
691        assert_eq!(result.output, restored.output);
692        assert_eq!(result.duration_ms, restored.duration_ms);
693        assert_eq!(result.artifacts, restored.artifacts);
694    }
695
696    #[test]
697    fn test_failure_strategy_from_str() {
698        assert_eq!(
699            "abort".parse::<FailureStrategy>().unwrap(),
700            FailureStrategy::Abort
701        );
702        assert_eq!(
703            "retry".parse::<FailureStrategy>().unwrap(),
704            FailureStrategy::Retry
705        );
706        assert_eq!(
707            "skip".parse::<FailureStrategy>().unwrap(),
708            FailureStrategy::Skip
709        );
710        assert_eq!(
711            "ask".parse::<FailureStrategy>().unwrap(),
712            FailureStrategy::Ask
713        );
714        assert!("abort_all".parse::<FailureStrategy>().is_err());
715        assert!("".parse::<FailureStrategy>().is_err());
716    }
717
718    #[test]
719    fn test_chrono_now_iso8601_format() {
720        let ts = chrono_now();
721        // Format: "YYYY-MM-DDTHH:MM:SSZ" — 20 chars
722        assert_eq!(ts.len(), 20, "timestamp should be 20 chars: {ts}");
723        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
724        assert!(ts.contains('T'), "should contain T: {ts}");
725        // Year should be >= 2024
726        let year: u32 = ts[..4].parse().expect("year should be numeric");
727        assert!(year >= 2024, "year should be >= 2024: {year}");
728    }
729
730    #[test]
731    fn test_failure_strategy_serde_snake_case() {
732        assert_eq!(
733            serde_json::to_string(&FailureStrategy::Abort).unwrap(),
734            "\"abort\""
735        );
736        assert_eq!(
737            serde_json::to_string(&FailureStrategy::Retry).unwrap(),
738            "\"retry\""
739        );
740        assert_eq!(
741            serde_json::to_string(&FailureStrategy::Skip).unwrap(),
742            "\"skip\""
743        );
744        assert_eq!(
745            serde_json::to_string(&FailureStrategy::Ask).unwrap(),
746            "\"ask\""
747        );
748    }
749
750    #[test]
751    fn test_graph_persistence_save_rejects_long_goal() {
752        // GraphPersistence::save() is async and requires a real store;
753        // we verify the goal-length guard directly via the const.
754        let long_goal = "x".repeat(MAX_GOAL_LEN + 1);
755        let mut graph = TaskGraph::new(long_goal);
756        graph.goal = "x".repeat(MAX_GOAL_LEN + 1);
757        assert!(
758            graph.goal.len() > MAX_GOAL_LEN,
759            "test setup: goal must exceed limit"
760        );
761        // The check itself lives in GraphPersistence::save(), exercised by
762        // the async persistence tests in zeph-memory; here we verify the constant.
763        assert_eq!(MAX_GOAL_LEN, 1024);
764    }
765
766    #[test]
767    fn test_task_node_predicate_fields_default_to_none() {
768        // Old SQLite blobs without verify_predicate / predicate_outcome must deserialize
769        // to None without error (#[serde(default)]).
770        let json = r#"{
771            "id": 0,
772            "title": "t",
773            "description": "d",
774            "agent_hint": null,
775            "status": "pending",
776            "depends_on": [],
777            "result": null,
778            "assigned_agent": null,
779            "retry_count": 0,
780            "failure_strategy": null,
781            "max_retries": null
782        }"#;
783        let node: TaskNode = serde_json::from_str(json).expect("should deserialize old JSON");
784        assert!(node.verify_predicate.is_none());
785        assert!(node.predicate_outcome.is_none());
786    }
787
788    #[test]
789    fn test_task_node_missing_execution_mode_deserializes_as_parallel() {
790        // Old SQLite-stored JSON blobs lack the execution_mode field.
791        // #[serde(default)] must make them deserialize to Parallel without error.
792        let json = r#"{
793            "id": 0,
794            "title": "t",
795            "description": "d",
796            "agent_hint": null,
797            "status": "pending",
798            "depends_on": [],
799            "result": null,
800            "assigned_agent": null,
801            "retry_count": 0,
802            "failure_strategy": null,
803            "max_retries": null
804        }"#;
805        let node: TaskNode = serde_json::from_str(json).expect("should deserialize old JSON");
806        assert_eq!(node.execution_mode, ExecutionMode::Parallel);
807    }
808
809    #[test]
810    fn test_execution_mode_serde_snake_case() {
811        assert_eq!(
812            serde_json::to_string(&ExecutionMode::Parallel).unwrap(),
813            "\"parallel\""
814        );
815        assert_eq!(
816            serde_json::to_string(&ExecutionMode::Sequential).unwrap(),
817            "\"sequential\""
818        );
819        let p: ExecutionMode = serde_json::from_str("\"parallel\"").unwrap();
820        assert_eq!(p, ExecutionMode::Parallel);
821        let s: ExecutionMode = serde_json::from_str("\"sequential\"").unwrap();
822        assert_eq!(s, ExecutionMode::Sequential);
823    }
824}