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}