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