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_db::{GraphSummary, RawGraphStore};
12
13use super::error::OrchestrationError;
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/// Stable kebab-case identifier assigned to a task in a plan template or LLM planner response.
54///
55/// A `PlanSlug` is a human-readable string of the form `[a-z0-9]([a-z0-9-]*[a-z0-9])?`
56/// (e.g. `"fetch-data"`, `"deploy-service"`). It is distinct from [`TaskId`], which is a
57/// dense numeric index used for in-memory graph traversal. `PlanSlug` values appear in LLM
58/// JSON responses and cached plan templates; they are resolved to `TaskId` during graph
59/// construction.
60///
61/// # Examples
62///
63/// ```rust
64/// use zeph_orchestration::PlanSlug;
65///
66/// let slug = PlanSlug::from("fetch-data");
67/// assert_eq!(slug.to_string(), "fetch-data");
68/// assert_eq!(slug.as_str(), "fetch-data");
69/// ```
70#[derive(
71    Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
72)]
73#[schemars(transparent)]
74pub struct PlanSlug(pub String);
75
76impl PlanSlug {
77    /// Returns the inner string slice.
78    #[must_use]
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82}
83
84impl From<String> for PlanSlug {
85    fn from(s: String) -> Self {
86        Self(s)
87    }
88}
89
90impl From<&str> for PlanSlug {
91    fn from(s: &str) -> Self {
92        Self(s.to_owned())
93    }
94}
95
96impl fmt::Display for PlanSlug {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(&self.0)
99    }
100}
101
102/// Unique identifier for a [`TaskGraph`].
103///
104/// Backed by a UUID v4. Implements `FromStr` / `Display` for serialization and
105/// CLI lookup.
106///
107/// # Examples
108///
109/// ```rust
110/// use zeph_orchestration::GraphId;
111///
112/// let id = GraphId::new();
113/// let s = id.to_string();
114/// assert_eq!(s.len(), 36); // UUID string representation
115///
116/// let parsed: GraphId = s.parse().expect("valid UUID");
117/// assert_eq!(id, parsed);
118/// ```
119#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120pub struct GraphId(Uuid);
121
122impl GraphId {
123    /// Generate a new random v4 `GraphId`.
124    #[must_use]
125    pub fn new() -> Self {
126        Self(Uuid::new_v4())
127    }
128}
129
130impl Default for GraphId {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl fmt::Display for GraphId {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "{}", self.0)
139    }
140}
141
142impl FromStr for GraphId {
143    type Err = OrchestrationError;
144
145    fn from_str(s: &str) -> Result<Self, Self::Err> {
146        Uuid::parse_str(s)
147            .map(GraphId)
148            .map_err(|e| OrchestrationError::InvalidGraph(format!("invalid graph id '{s}': {e}")))
149    }
150}
151
152/// Lifecycle status of a single task node.
153///
154/// State machine:
155///
156/// ```text
157/// Pending → Ready → Running → Completed  (success)
158///                           → Failed     (error; then failure strategy applies)
159///                           → Skipped    (upstream failed with Skip strategy)
160///                           → Canceled   (graph aborted while task was running)
161/// ```
162///
163/// Only `Completed`, `Failed`, `Skipped`, and `Canceled` are terminal — see
164/// [`TaskStatus::is_terminal`].
165///
166/// # Examples
167///
168/// ```rust
169/// use zeph_orchestration::TaskStatus;
170///
171/// assert!(TaskStatus::Completed.is_terminal());
172/// assert!(!TaskStatus::Running.is_terminal());
173/// assert_eq!(TaskStatus::Pending.to_string(), "pending");
174/// ```
175#[non_exhaustive]
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum TaskStatus {
179    /// Waiting for dependencies to complete.
180    Pending,
181    /// All dependencies completed; ready to be scheduled.
182    Ready,
183    /// A sub-agent is actively executing this task.
184    Running,
185    /// Sub-agent completed successfully.
186    Completed,
187    /// Sub-agent returned an error.
188    Failed,
189    /// Task was skipped because an upstream task failed with [`FailureStrategy::Skip`].
190    Skipped,
191    /// Task was running when the graph was aborted ([`FailureStrategy::Abort`]).
192    Canceled,
193}
194
195impl TaskStatus {
196    /// Returns `true` if the status is a terminal state.
197    #[must_use]
198    pub fn is_terminal(self) -> bool {
199        matches!(
200            self,
201            TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Skipped | TaskStatus::Canceled
202        )
203    }
204}
205
206impl fmt::Display for TaskStatus {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        match self {
209            TaskStatus::Pending => write!(f, "pending"),
210            TaskStatus::Ready => write!(f, "ready"),
211            TaskStatus::Running => write!(f, "running"),
212            TaskStatus::Completed => write!(f, "completed"),
213            TaskStatus::Failed => write!(f, "failed"),
214            TaskStatus::Skipped => write!(f, "skipped"),
215            TaskStatus::Canceled => write!(f, "canceled"),
216        }
217    }
218}
219
220/// Lifecycle status of a [`TaskGraph`].
221///
222/// # Examples
223///
224/// ```rust
225/// use zeph_orchestration::GraphStatus;
226///
227/// assert_eq!(GraphStatus::Running.to_string(), "running");
228/// assert_eq!(GraphStatus::Failed.to_string(), "failed");
229/// ```
230#[non_exhaustive]
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum GraphStatus {
234    /// Graph has been created but the scheduler has not started yet.
235    Created,
236    /// Scheduler is actively dispatching tasks.
237    Running,
238    /// All tasks reached a terminal state successfully.
239    Completed,
240    /// At least one task failed and the `Abort` strategy halted the graph.
241    Failed,
242    /// The graph was canceled by an external caller.
243    Canceled,
244    /// Graph is paused; waiting for user input (triggered by [`FailureStrategy::Ask`]).
245    Paused,
246}
247
248impl fmt::Display for GraphStatus {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        match self {
251            GraphStatus::Created => write!(f, "created"),
252            GraphStatus::Running => write!(f, "running"),
253            GraphStatus::Completed => write!(f, "completed"),
254            GraphStatus::Failed => write!(f, "failed"),
255            GraphStatus::Canceled => write!(f, "canceled"),
256            GraphStatus::Paused => write!(f, "paused"),
257        }
258    }
259}
260
261/// Output produced by a completed task.
262///
263/// Stored in [`TaskNode::result`] after the sub-agent finishes. Used by
264/// [`Aggregator`] to build the final synthesised response.
265///
266/// [`Aggregator`]: crate::aggregator::Aggregator
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct TaskResult {
269    /// Raw text output returned by the sub-agent.
270    pub output: String,
271    /// File-system paths to any artifacts produced (e.g. build outputs, reports).
272    pub artifacts: Vec<PathBuf>,
273    /// Wall-clock execution time in milliseconds.
274    pub duration_ms: u64,
275    /// Handle ID of the sub-agent instance that produced this result.
276    pub agent_id: Option<String>,
277    /// Name of the agent definition used to spawn the sub-agent.
278    pub agent_def: Option<String>,
279}
280
281/// Execution mode annotation emitted by the LLM planner for each task.
282///
283/// Controls how the [`DagScheduler`] dispatches a task relative to its siblings.
284/// The annotation is set by the planner and stored in [`TaskNode::execution_mode`].
285/// Absent or `null` in stored JSON deserialises to the default `Parallel`.
286///
287/// [`DagScheduler`]: crate::scheduler::DagScheduler
288///
289/// # Examples
290///
291/// ```rust
292/// use zeph_orchestration::ExecutionMode;
293///
294/// assert_eq!(ExecutionMode::default(), ExecutionMode::Parallel);
295/// let mode: ExecutionMode = serde_json::from_str("\"sequential\"").unwrap();
296/// assert_eq!(mode, ExecutionMode::Sequential);
297/// ```
298#[non_exhaustive]
299#[derive(
300    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
301)]
302#[serde(rename_all = "snake_case")]
303pub enum ExecutionMode {
304    /// Task can run in parallel with others at the same DAG level.
305    #[default]
306    Parallel,
307    /// Task is globally serialized: at most one `Sequential` task runs at a time across
308    /// the entire graph (e.g. deploy, exclusive-resource access, shared-state mutation).
309    Sequential,
310}
311
312/// Controls network access for a task node during orchestrated execution.
313///
314/// **Advisory only** — this field is not yet read at runtime. See the
315/// `TODO(enforcement)` on the `Deny` variant and `specs/069-threat-model/spec.md §5`.
316///
317/// # Examples
318///
319/// ```rust
320/// use zeph_orchestration::graph::NetworkScope;
321///
322/// let scope = NetworkScope::Deny;
323/// assert_eq!(NetworkScope::default(), NetworkScope::Inherit);
324/// let parsed: NetworkScope = serde_json::from_str("\"deny\"").unwrap();
325/// assert_eq!(parsed, NetworkScope::Deny);
326/// ```
327#[non_exhaustive]
328#[derive(
329    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
330)]
331#[serde(rename_all = "snake_case")]
332pub enum NetworkScope {
333    /// Inherit the global `allow_network` setting (default, no per-task restriction).
334    #[default]
335    Inherit,
336    /// Explicitly allow network egress (shell network commands + scrape/fetch) for this task.
337    Allow,
338    /// Deny all network egress for this task regardless of global config.
339    // TODO(enforcement): wire to spawned sub-agent launch in handle_scheduler_spawn_action.
340    // See scheduler_loop.rs spawn_for_task — it does not thread per-task scope today.
341    Deny,
342}
343
344/// A single node in the task DAG.
345///
346/// Constructed by [`Planner`] and stored inside a [`TaskGraph`].  The
347/// scheduler drives each node through its [`TaskStatus`] lifecycle.
348///
349/// [`Planner`]: crate::planner::Planner
350///
351/// # Examples
352///
353/// ```rust
354/// use zeph_orchestration::{TaskNode, TaskStatus, ExecutionMode};
355///
356/// let node = TaskNode::new(0, "fetch data", "Download the dataset from source.");
357/// assert_eq!(node.status, TaskStatus::Pending);
358/// assert!(node.depends_on.is_empty());
359/// assert_eq!(node.execution_mode, ExecutionMode::Parallel);
360/// assert!(node.network_scope.is_none());
361/// assert!(node.asset_sensitivity.is_none());
362/// ```
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct TaskNode {
365    /// Dense zero-based index. Invariant: `tasks[i].id == TaskId(i)`.
366    pub id: TaskId,
367    /// Short, human-readable task title.
368    pub title: String,
369    /// Full task description passed verbatim to the assigned sub-agent as its prompt.
370    pub description: String,
371    /// Preferred agent name suggested by the planner; `None` lets the router decide.
372    pub agent_hint: Option<String>,
373    /// Current lifecycle status.
374    pub status: TaskStatus,
375    /// Indices of tasks this node depends on.
376    pub depends_on: Vec<TaskId>,
377    /// Result populated by the scheduler after the sub-agent finishes.
378    pub result: Option<TaskResult>,
379    /// Agent name actually assigned by the router at dispatch time.
380    pub assigned_agent: Option<String>,
381    /// Number of times this task has been retried so far (execution retries only).
382    pub retry_count: u32,
383    /// Number of predicate-driven re-runs for this task (independent of `retry_count`).
384    #[serde(default)]
385    pub predicate_rerun_count: u32,
386    /// Per-task failure strategy override; `None` means use [`TaskGraph::default_failure_strategy`].
387    pub failure_strategy: Option<FailureStrategy>,
388    /// Maximum retry attempts for this task; `None` means use [`TaskGraph::default_max_retries`].
389    pub max_retries: Option<u32>,
390    /// LLM planner annotation. Old SQLite-stored JSON without this field
391    /// deserialises to the default (`Parallel`).
392    #[serde(default)]
393    pub execution_mode: ExecutionMode,
394    /// Per-subtask verification predicate (predicate gate).
395    ///
396    /// When `Some`, the task's output must satisfy this criterion before downstream
397    /// tasks may consume it. The scheduler emits `SchedulerAction::VerifyPredicate`
398    /// after task completion and blocks downstream dispatch until
399    /// `predicate_outcome.is_some()`.
400    #[serde(default)]
401    pub verify_predicate: Option<VerifyPredicate>,
402    /// Outcome of the most recent predicate evaluation.
403    ///
404    /// `None` means the gate has not been evaluated yet (in-memory only; restart
405    /// re-evaluates any pending predicates). The scheduler re-emits `VerifyPredicate`
406    /// on every tick while this is `None` and `verify_predicate.is_some()`.
407    #[serde(default)]
408    pub predicate_outcome: Option<PredicateOutcome>,
409    /// Named execution environment from `[[execution.environments]]` to use when
410    /// dispatching shell tool calls for this task. `None` inherits the executor default.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub execution_environment: Option<String>,
413
414    /// Per-task cost budget in US cents. `None` = use `OrchestrationConfig::default_task_budget_cents`.
415    ///
416    /// On task completion the scheduler checks whether this budget was exceeded and
417    /// emits a `tracing::warn!`. Hard enforcement is deferred post-v1.0.0.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub token_budget_cents: Option<f64>,
420
421    /// Per-task network egress policy. Advisory only for spawned sub-agents.
422    ///
423    /// `None` / `Inherit` = inherit the executor/global `allow_network` default.
424    /// See [`NetworkScope`] for enforcement caveats and `specs/069-threat-model/spec.md §5`.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub network_scope: Option<NetworkScope>,
427
428    /// Sensitivity level of assets accessed by this task.
429    ///
430    /// Used by the orchestration planner to annotate tasks that touch sensitive resources
431    /// (vault keys, user credentials, private memory). Advisory only in the current
432    /// implementation — the dispatcher does not yet auto-restrict the tool allow-list
433    /// based on this field. See `specs/069-threat-model/spec.md §5`.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub asset_sensitivity: Option<zeph_config::AssetSensitivity>,
436}
437
438impl TaskNode {
439    /// Create a new pending task with the given index.
440    #[must_use]
441    pub fn new(id: u32, title: impl Into<String>, description: impl Into<String>) -> Self {
442        Self {
443            id: TaskId(id),
444            title: title.into(),
445            description: description.into(),
446            agent_hint: None,
447            status: TaskStatus::Pending,
448            depends_on: Vec::new(),
449            result: None,
450            assigned_agent: None,
451            retry_count: 0,
452            predicate_rerun_count: 0,
453            failure_strategy: None,
454            max_retries: None,
455            execution_mode: ExecutionMode::default(),
456            verify_predicate: None,
457            predicate_outcome: None,
458            execution_environment: None,
459            token_budget_cents: None,
460            network_scope: None,
461            asset_sensitivity: None,
462        }
463    }
464}
465
466/// A directed acyclic graph of tasks to be executed by the orchestrator.
467///
468/// Created by the [`Planner`] and driven to completion by the [`DagScheduler`].
469/// The `tasks` vec is the authoritative store; all indices (`TaskId`) reference
470/// positions within it.
471///
472/// [`Planner`]: crate::planner::Planner
473/// [`DagScheduler`]: crate::scheduler::DagScheduler
474///
475/// # Examples
476///
477/// ```rust
478/// use zeph_orchestration::{TaskGraph, TaskNode, GraphStatus, FailureStrategy};
479///
480/// let mut graph = TaskGraph::new("build and deploy service");
481/// assert_eq!(graph.status, GraphStatus::Created);
482/// assert_eq!(graph.default_failure_strategy, FailureStrategy::Abort);
483/// assert_eq!(graph.default_max_retries, 3);
484/// ```
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct TaskGraph {
487    /// Unique graph identifier (UUID v4).
488    pub id: GraphId,
489    /// High-level user goal that was decomposed into this graph.
490    pub goal: String,
491    /// All task nodes. Index `i` must satisfy `tasks[i].id == TaskId(i)`.
492    pub tasks: Vec<TaskNode>,
493    /// Current lifecycle status of the graph as a whole.
494    pub status: GraphStatus,
495    /// Graph-wide failure strategy applied when a task has no per-task override.
496    pub default_failure_strategy: FailureStrategy,
497    /// Graph-wide maximum retry count applied when a task has no per-task override.
498    pub default_max_retries: u32,
499    /// ISO-8601 UTC timestamp of graph creation.
500    pub created_at: String,
501    /// ISO-8601 UTC timestamp set when the graph reaches a terminal status.
502    pub finished_at: Option<String>,
503    /// Monotonically incrementing counter used by the durable P2 adapter to key each
504    /// budget snapshot to a unique [`ExecutionId`].  Zero on creation; incremented on each
505    /// `journal_budget` call so a resumed-then-re-paused plan writes to a fresh execution
506    /// rather than overwriting the previous one (see `zeph-orchestration/src/durable.rs`).
507    ///
508    /// Persisted inside the graph blob so it survives across process restarts.
509    ///
510    /// [`ExecutionId`]: zeph_durable::ExecutionId
511    #[serde(default)]
512    pub durable_save_generation: u32,
513}
514
515impl TaskGraph {
516    /// Create a new graph with `Created` status.
517    #[must_use]
518    pub fn new(goal: impl Into<String>) -> Self {
519        Self {
520            id: GraphId::new(),
521            goal: goal.into(),
522            tasks: Vec::new(),
523            status: GraphStatus::Created,
524            default_failure_strategy: FailureStrategy::default(),
525            default_max_retries: 3,
526            created_at: chrono_now(),
527            finished_at: None,
528            durable_save_generation: 0,
529        }
530    }
531}
532
533/// Current UTC time as an ISO-8601 timestamp (e.g. `"2026-03-05T22:04:41Z"`).
534pub(crate) fn chrono_now() -> String {
535    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
536}
537
538/// Maximum allowed length for a `TaskGraph` goal string.
539const MAX_GOAL_LEN: usize = 1024;
540
541/// Type-safe wrapper around `RawGraphStore` that handles `TaskGraph` serialization.
542///
543/// Consumers in `zeph-core` use this instead of `RawGraphStore` directly, so they
544/// never need to deal with JSON strings.
545///
546/// # Storage layout
547///
548/// The `task_graphs` table stores both metadata columns (`goal`, `status`,
549/// `created_at`, `finished_at`) and the full `graph_json` blob. The metadata
550/// columns are summary/index data used for listing and filtering; `graph_json`
551/// is the authoritative source for full graph reconstruction. On `load`, only
552/// `graph_json` is deserialized — the columns are not consulted.
553pub struct GraphPersistence<S: RawGraphStore> {
554    store: S,
555}
556
557impl<S: RawGraphStore> GraphPersistence<S> {
558    /// Create a new `GraphPersistence` wrapping the given store.
559    pub fn new(store: S) -> Self {
560        Self { store }
561    }
562
563    /// Persist a `TaskGraph` (upsert).
564    ///
565    /// Returns `OrchestrationError::InvalidGraph` if `graph.goal` exceeds
566    /// `MAX_GOAL_LEN` (1024) characters.
567    ///
568    /// # Errors
569    ///
570    /// Returns `OrchestrationError::Persistence` on serialization or database failure.
571    #[tracing::instrument(name = "orchestration.graph_store.save", skip(self, graph), fields(graph.id = %graph.id))]
572    pub async fn save(&self, graph: &TaskGraph) -> Result<(), OrchestrationError> {
573        if graph.goal.len() > MAX_GOAL_LEN {
574            return Err(OrchestrationError::InvalidGraph(format!(
575                "goal exceeds {MAX_GOAL_LEN} character limit ({} chars)",
576                graph.goal.len()
577            )));
578        }
579        let json = serde_json::to_string(graph)
580            .map_err(|e| OrchestrationError::Persistence(e.to_string()))?;
581        self.store
582            .save_graph(
583                &graph.id.to_string(),
584                &graph.goal,
585                &graph.status.to_string(),
586                &json,
587                &graph.created_at,
588                graph.finished_at.as_deref(),
589            )
590            .await
591            .map_err(|e| OrchestrationError::Persistence(e.to_string()))
592    }
593
594    /// Load a `TaskGraph` by its `GraphId`.
595    ///
596    /// Returns `None` if not found.
597    ///
598    /// # Errors
599    ///
600    /// Returns `OrchestrationError::Persistence` on database or deserialization failure.
601    #[tracing::instrument(name = "orchestration.graph_store.load", skip(self), fields(graph.id = %id))]
602    pub async fn load(&self, id: &GraphId) -> Result<Option<TaskGraph>, OrchestrationError> {
603        match self
604            .store
605            .load_graph(&id.to_string())
606            .await
607            .map_err(|e| OrchestrationError::Persistence(e.to_string()))?
608        {
609            Some(json) => {
610                let graph = serde_json::from_str(&json)
611                    .map_err(|e| OrchestrationError::Persistence(e.to_string()))?;
612                Ok(Some(graph))
613            }
614            None => Ok(None),
615        }
616    }
617
618    /// List stored graphs (newest first).
619    ///
620    /// # Errors
621    ///
622    /// Returns `OrchestrationError::Persistence` on database failure.
623    #[tracing::instrument(name = "orchestration.graph_store.list", skip(self), fields(limit))]
624    pub async fn list(&self, limit: u32) -> Result<Vec<GraphSummary>, OrchestrationError> {
625        self.store
626            .list_graphs(limit)
627            .await
628            .map_err(|e| OrchestrationError::Persistence(e.to_string()))
629    }
630
631    /// Delete a graph by its `GraphId`.
632    ///
633    /// Returns `true` if a row was deleted.
634    ///
635    /// # Errors
636    ///
637    /// Returns `OrchestrationError::Persistence` on database failure.
638    #[tracing::instrument(name = "orchestration.graph_store.delete", skip(self), fields(graph.id = %id))]
639    pub async fn delete(&self, id: &GraphId) -> Result<bool, OrchestrationError> {
640        self.store
641            .delete_graph(&id.to_string())
642            .await
643            .map_err(|e| OrchestrationError::Persistence(e.to_string()))
644    }
645}
646
647/// A verification criterion attached to a [`TaskNode`].
648///
649/// Only `Natural` is constructible in v1. If the planner emits `Expression`, the
650/// scheduler returns `OrchestrationError::PredicateNotSupported` rather than
651/// silently ignoring the criterion.
652///
653/// # Examples
654///
655/// ```rust
656/// use zeph_orchestration::VerifyPredicate;
657///
658/// let pred = VerifyPredicate::Natural("output must contain a valid JSON object".to_string());
659/// assert!(matches!(pred, VerifyPredicate::Natural(_)));
660/// ```
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
663#[non_exhaustive]
664pub enum VerifyPredicate {
665    /// Free-form natural-language criterion evaluated by the LLM judge.
666    Natural(String),
667    /// Symbolic expression (reserved, not supported in v1).
668    Expression(String),
669}
670
671impl VerifyPredicate {
672    /// Returns `Ok(&criterion)` for `Natural` predicates; `Err(PredicateNotSupported)` otherwise.
673    ///
674    /// # Errors
675    ///
676    /// Returns [`OrchestrationError::PredicateNotSupported`] when the variant is not
677    /// evaluatable in the current version.
678    pub fn as_natural(&self) -> Result<&str, OrchestrationError> {
679        match self {
680            VerifyPredicate::Natural(s) => Ok(s.as_str()),
681            VerifyPredicate::Expression(s) => Err(OrchestrationError::PredicateNotSupported(
682                format!("Expression predicate '{s}' is not supported in v1; use Natural"),
683            )),
684        }
685    }
686}
687
688/// Result of evaluating a [`VerifyPredicate`] against a task's output.
689///
690/// Stored on [`TaskNode::predicate_outcome`]. A `None` value signals "not yet evaluated".
691///
692/// # Examples
693///
694/// ```rust
695/// use zeph_orchestration::PredicateOutcome;
696///
697/// let outcome = PredicateOutcome { passed: true, confidence: 0.9, reason: "output is valid JSON".to_string() };
698/// assert!(outcome.passed);
699/// ```
700#[derive(Debug, Clone, Serialize, Deserialize)]
701pub struct PredicateOutcome {
702    /// Whether the predicate was satisfied.
703    pub passed: bool,
704    /// Confidence score in [0.0, 1.0].
705    pub confidence: f32,
706    /// Human-readable explanation from the LLM judge.
707    pub reason: String,
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use chrono::Datelike;
714
715    #[test]
716    fn test_taskid_display() {
717        assert_eq!(TaskId(3).to_string(), "3");
718    }
719
720    #[test]
721    fn test_graphid_display_and_new() {
722        let id = GraphId::new();
723        let s = id.to_string();
724        assert_eq!(s.len(), 36, "UUID string should be 36 chars");
725        let parsed: GraphId = s.parse().expect("should parse back");
726        assert_eq!(id, parsed);
727    }
728
729    #[test]
730    fn test_graphid_from_str_invalid() {
731        let err = "not-a-uuid".parse::<GraphId>();
732        assert!(err.is_err());
733    }
734
735    #[test]
736    fn test_task_status_is_terminal() {
737        assert!(TaskStatus::Completed.is_terminal());
738        assert!(TaskStatus::Failed.is_terminal());
739        assert!(TaskStatus::Skipped.is_terminal());
740        assert!(TaskStatus::Canceled.is_terminal());
741
742        assert!(!TaskStatus::Pending.is_terminal());
743        assert!(!TaskStatus::Ready.is_terminal());
744        assert!(!TaskStatus::Running.is_terminal());
745    }
746
747    #[test]
748    fn test_task_status_display() {
749        assert_eq!(TaskStatus::Pending.to_string(), "pending");
750        assert_eq!(TaskStatus::Ready.to_string(), "ready");
751        assert_eq!(TaskStatus::Running.to_string(), "running");
752        assert_eq!(TaskStatus::Completed.to_string(), "completed");
753        assert_eq!(TaskStatus::Failed.to_string(), "failed");
754        assert_eq!(TaskStatus::Skipped.to_string(), "skipped");
755        assert_eq!(TaskStatus::Canceled.to_string(), "canceled");
756    }
757
758    #[test]
759    fn test_failure_strategy_default() {
760        assert_eq!(FailureStrategy::default(), FailureStrategy::Abort);
761    }
762
763    #[test]
764    fn test_failure_strategy_display() {
765        assert_eq!(FailureStrategy::Abort.to_string(), "abort");
766        assert_eq!(FailureStrategy::Retry.to_string(), "retry");
767        assert_eq!(FailureStrategy::Skip.to_string(), "skip");
768        assert_eq!(FailureStrategy::Ask.to_string(), "ask");
769    }
770
771    #[test]
772    fn test_graph_status_display() {
773        assert_eq!(GraphStatus::Created.to_string(), "created");
774        assert_eq!(GraphStatus::Running.to_string(), "running");
775        assert_eq!(GraphStatus::Completed.to_string(), "completed");
776        assert_eq!(GraphStatus::Failed.to_string(), "failed");
777        assert_eq!(GraphStatus::Canceled.to_string(), "canceled");
778        assert_eq!(GraphStatus::Paused.to_string(), "paused");
779    }
780
781    #[test]
782    fn test_task_graph_serde_roundtrip() {
783        let mut graph = TaskGraph::new("test goal");
784        graph.tasks.push(TaskNode::new(0, "task 0", "do something"));
785        let json = serde_json::to_string(&graph).expect("serialize");
786        let restored: TaskGraph = serde_json::from_str(&json).expect("deserialize");
787        assert_eq!(graph.id, restored.id);
788        assert_eq!(graph.goal, restored.goal);
789        assert_eq!(graph.tasks.len(), restored.tasks.len());
790    }
791
792    #[test]
793    fn test_task_node_serde_roundtrip() {
794        let mut node = TaskNode::new(1, "compile", "run cargo build");
795        node.agent_hint = Some("rust-dev".to_string());
796        node.depends_on = vec![TaskId(0)];
797        let json = serde_json::to_string(&node).expect("serialize");
798        let restored: TaskNode = serde_json::from_str(&json).expect("deserialize");
799        assert_eq!(node.id, restored.id);
800        assert_eq!(node.title, restored.title);
801        assert_eq!(node.depends_on, restored.depends_on);
802    }
803
804    #[test]
805    fn test_task_result_serde_roundtrip() {
806        let result = TaskResult {
807            output: "ok".to_string(),
808            artifacts: vec![PathBuf::from("/tmp/out.bin")],
809            duration_ms: 500,
810            agent_id: Some("agent-1".to_string()),
811            agent_def: None,
812        };
813        let json = serde_json::to_string(&result).expect("serialize");
814        let restored: TaskResult = serde_json::from_str(&json).expect("deserialize");
815        assert_eq!(result.output, restored.output);
816        assert_eq!(result.duration_ms, restored.duration_ms);
817        assert_eq!(result.artifacts, restored.artifacts);
818    }
819
820    #[test]
821    fn test_failure_strategy_from_str() {
822        assert_eq!(
823            "abort".parse::<FailureStrategy>().unwrap(),
824            FailureStrategy::Abort
825        );
826        assert_eq!(
827            "retry".parse::<FailureStrategy>().unwrap(),
828            FailureStrategy::Retry
829        );
830        assert_eq!(
831            "skip".parse::<FailureStrategy>().unwrap(),
832            FailureStrategy::Skip
833        );
834        assert_eq!(
835            "ask".parse::<FailureStrategy>().unwrap(),
836            FailureStrategy::Ask
837        );
838        assert!("abort_all".parse::<FailureStrategy>().is_err());
839        assert!("".parse::<FailureStrategy>().is_err());
840    }
841
842    #[test]
843    fn test_chrono_now_iso8601_format() {
844        let ts = chrono_now();
845        // Format: "YYYY-MM-DDTHH:MM:SSZ" — 20 chars
846        assert_eq!(ts.len(), 20, "timestamp should be 20 chars: {ts}");
847        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
848        assert!(ts.contains('T'), "should contain T: {ts}");
849        // Year should be >= 2024
850        let year: u32 = ts[..4].parse().expect("year should be numeric");
851        assert!(year >= 2024, "year should be >= 2024: {year}");
852        // Month must be a valid 1..=12 component, not the 0-indexed 0 that the
853        // old hand-rolled Gregorian decomposition could produce (see #5469).
854        let month: u32 = ts[5..7].parse().expect("month should be numeric");
855        assert!((1..=12).contains(&month), "month out of range: {month}");
856        // Round-trip through a real RFC3339 parser so any future format
857        // regression is caught by the type system, not string slicing alone.
858        chrono::DateTime::parse_from_rfc3339(&ts)
859            .unwrap_or_else(|e| panic!("timestamp should be valid RFC3339: {ts}: {e}"));
860    }
861
862    #[test]
863    fn test_chrono_now_no_month_00_around_leap_year_boundary() {
864        // Regression test for #5469: the old hand-rolled epoch decomposition
865        // mis-decomposed dates immediately preceding a leap year (e.g.
866        // 2025-12-31), producing an invalid "month=00" timestamp. `chrono_now`
867        // now delegates to `chrono::Utc::now()`, so this is a smoke check that
868        // any produced timestamp always round-trips and never has month=0.
869        for _ in 0..5 {
870            let ts = chrono_now();
871            let parsed = chrono::DateTime::parse_from_rfc3339(&ts)
872                .unwrap_or_else(|e| panic!("invalid RFC3339 timestamp: {ts}: {e}"));
873            assert_ne!(parsed.month(), 0, "month must never be 0: {ts}");
874        }
875    }
876
877    #[test]
878    fn test_failure_strategy_serde_snake_case() {
879        assert_eq!(
880            serde_json::to_string(&FailureStrategy::Abort).unwrap(),
881            "\"abort\""
882        );
883        assert_eq!(
884            serde_json::to_string(&FailureStrategy::Retry).unwrap(),
885            "\"retry\""
886        );
887        assert_eq!(
888            serde_json::to_string(&FailureStrategy::Skip).unwrap(),
889            "\"skip\""
890        );
891        assert_eq!(
892            serde_json::to_string(&FailureStrategy::Ask).unwrap(),
893            "\"ask\""
894        );
895    }
896
897    #[test]
898    fn test_graph_persistence_save_rejects_long_goal() {
899        // GraphPersistence::save() is async and requires a real store;
900        // we verify the goal-length guard directly via the const.
901        let long_goal = "x".repeat(MAX_GOAL_LEN + 1);
902        let mut graph = TaskGraph::new(long_goal);
903        graph.goal = "x".repeat(MAX_GOAL_LEN + 1);
904        assert!(
905            graph.goal.len() > MAX_GOAL_LEN,
906            "test setup: goal must exceed limit"
907        );
908        // The check itself lives in GraphPersistence::save(), exercised by
909        // the async persistence tests in zeph-memory; here we verify the constant.
910        assert_eq!(MAX_GOAL_LEN, 1024);
911    }
912
913    #[test]
914    fn test_task_node_predicate_fields_default_to_none() {
915        // Old SQLite blobs without verify_predicate / predicate_outcome must deserialize
916        // to None without error (#[serde(default)]).
917        let json = r#"{
918            "id": 0,
919            "title": "t",
920            "description": "d",
921            "agent_hint": null,
922            "status": "pending",
923            "depends_on": [],
924            "result": null,
925            "assigned_agent": null,
926            "retry_count": 0,
927            "failure_strategy": null,
928            "max_retries": null
929        }"#;
930        let node: TaskNode = serde_json::from_str(json).expect("should deserialize old JSON");
931        assert!(node.verify_predicate.is_none());
932        assert!(node.predicate_outcome.is_none());
933    }
934
935    #[test]
936    fn test_task_node_missing_execution_mode_deserializes_as_parallel() {
937        // Old SQLite-stored JSON blobs lack the execution_mode field.
938        // #[serde(default)] must make them deserialize to Parallel without error.
939        let json = r#"{
940            "id": 0,
941            "title": "t",
942            "description": "d",
943            "agent_hint": null,
944            "status": "pending",
945            "depends_on": [],
946            "result": null,
947            "assigned_agent": null,
948            "retry_count": 0,
949            "failure_strategy": null,
950            "max_retries": null
951        }"#;
952        let node: TaskNode = serde_json::from_str(json).expect("should deserialize old JSON");
953        assert_eq!(node.execution_mode, ExecutionMode::Parallel);
954    }
955
956    #[test]
957    fn test_execution_mode_serde_snake_case() {
958        assert_eq!(
959            serde_json::to_string(&ExecutionMode::Parallel).unwrap(),
960            "\"parallel\""
961        );
962        assert_eq!(
963            serde_json::to_string(&ExecutionMode::Sequential).unwrap(),
964            "\"sequential\""
965        );
966        let p: ExecutionMode = serde_json::from_str("\"parallel\"").unwrap();
967        assert_eq!(p, ExecutionMode::Parallel);
968        let s: ExecutionMode = serde_json::from_str("\"sequential\"").unwrap();
969        assert_eq!(s, ExecutionMode::Sequential);
970    }
971
972    #[test]
973    fn test_task_node_missing_network_scope_deserializes_as_none() {
974        // Old SQLite blobs without network_scope must deserialize to None without error.
975        let json = r#"{
976            "id": 0,
977            "title": "t",
978            "description": "d",
979            "agent_hint": null,
980            "status": "pending",
981            "depends_on": [],
982            "result": null,
983            "assigned_agent": null,
984            "retry_count": 0,
985            "failure_strategy": null,
986            "max_retries": null
987        }"#;
988        let node: TaskNode = serde_json::from_str(json).expect("should deserialize old JSON");
989        assert!(node.network_scope.is_none());
990        assert!(node.asset_sensitivity.is_none());
991    }
992
993    #[test]
994    fn test_network_scope_serde_snake_case() {
995        assert_eq!(
996            serde_json::to_string(&NetworkScope::Inherit).unwrap(),
997            "\"inherit\""
998        );
999        assert_eq!(
1000            serde_json::to_string(&NetworkScope::Allow).unwrap(),
1001            "\"allow\""
1002        );
1003        assert_eq!(
1004            serde_json::to_string(&NetworkScope::Deny).unwrap(),
1005            "\"deny\""
1006        );
1007        let inherit: NetworkScope = serde_json::from_str("\"inherit\"").unwrap();
1008        assert_eq!(inherit, NetworkScope::Inherit);
1009        let deny: NetworkScope = serde_json::from_str("\"deny\"").unwrap();
1010        assert_eq!(deny, NetworkScope::Deny);
1011    }
1012
1013    #[test]
1014    fn test_network_scope_default_is_inherit() {
1015        assert_eq!(NetworkScope::default(), NetworkScope::Inherit);
1016    }
1017
1018    #[test]
1019    fn test_task_node_new_has_none_scope_fields() {
1020        let node = TaskNode::new(0, "t", "d");
1021        assert!(node.network_scope.is_none());
1022        assert!(node.asset_sensitivity.is_none());
1023    }
1024
1025    #[test]
1026    fn test_task_node_network_scope_roundtrip() {
1027        let mut node = TaskNode::new(0, "t", "d");
1028        node.network_scope = Some(NetworkScope::Deny);
1029        node.asset_sensitivity = Some(zeph_config::AssetSensitivity::Confidential);
1030        let json = serde_json::to_string(&node).unwrap();
1031        let restored: TaskNode = serde_json::from_str(&json).unwrap();
1032        assert_eq!(restored.network_scope, Some(NetworkScope::Deny));
1033        assert_eq!(
1034            restored.asset_sensitivity,
1035            Some(zeph_config::AssetSensitivity::Confidential)
1036        );
1037    }
1038
1039    #[test]
1040    fn test_task_node_skip_serializing_if_none_scope() {
1041        // When fields are None, they should not appear in the JSON output.
1042        let node = TaskNode::new(0, "t", "d");
1043        let json = serde_json::to_string(&node).unwrap();
1044        assert!(!json.contains("network_scope"), "none should be omitted");
1045        assert!(
1046            !json.contains("asset_sensitivity"),
1047            "none should be omitted"
1048        );
1049    }
1050}