Skip to main content

salvor_engine/
error.rs

1//! [`EngineError`]: everything that can stop a graph drive.
2//!
3//! Two families sit here. The first is the engine's own refusals, each naming
4//! the offending node: a `map` node whose `over` reference does not resolve to a
5//! list ([`EngineError::MapOverNotAList`]) or whose body form is not
6//! executable ([`EngineError::UnsupportedMapBody`]), an agent or tool the resolver
7//! could not supply, a graph whose topology is not a well-formed DAG, a branch
8//! that no case matched or whose model decision named no case, a tool that
9//! failed, a gate resumed with an approval that does not satisfy its
10//! `approval_schema` ([`EngineError::ApprovalSchemaViolation`]),
11//! or a node whose kind is not executable
12//! ([`EngineError::UnsupportedNode`], today a `fold`). Most are returned
13//! **before** recording anything for the node they
14//! name, so the log never carries events past the refusal; the two branch-decision
15//! errors that require running a model first are the documented exception (their
16//! `NodeEntered` and the model's events are already recorded when the mapping
17//! fails). The second family is [`EngineError::Runtime`], the plain pass-through
18//! of a [`RuntimeError`] from the `RunCtx` operations the engine drives.
19
20use crate::approval::ApprovalViolation;
21use salvor_runtime::RuntimeError;
22use thiserror::Error;
23
24/// Why a graph drive could not continue.
25#[derive(Debug, Error)]
26pub enum EngineError {
27    /// A `map` node's `over` reference did not resolve to a JSON array against the
28    /// routed value (it was missing, or resolved to a non-array value). A map can
29    /// only fan out over a list, so the engine refuses deterministically rather
30    /// than guessing. Returned **before** the map's `NodeEntered` is recorded, so
31    /// nothing lands in the log past the refusal, and it reproduces on replay: the
32    /// same recorded routed value re-resolves to the same non-list.
33    #[error("map node `{node}`: the `over` reference `{over}` did not resolve to a list")]
34    MapOverNotAList {
35        /// The id of the map node.
36        node: String,
37        /// The `over` reference that failed to resolve to a list.
38        over: String,
39    },
40
41    /// A `map` node's body is a form that is not executable: an embedded
42    /// `subgraph` (per-item sub-walks need their own
43    /// log per iteration to keep node ids unambiguous, which is not implemented
44    /// yet), or a `node` body that
45    /// names a node whose kind cannot be a per-item worker (only `agent` and
46    /// `tool` bodies run). Returned **before** the map's `NodeEntered` is recorded,
47    /// so nothing lands in the log past the refusal. The document layer still
48    /// validates these as legal graphs; only the engine declines to run them.
49    #[error("map node `{node}`: {detail}")]
50    UnsupportedMapBody {
51        /// The id of the map node.
52        node: String,
53        /// What about the body is not supported.
54        detail: String,
55    },
56
57    /// An expression `branch` reached with no case whose condition evaluated
58    /// true. The author declared the cases exhaustively or the graph cannot
59    /// proceed; the engine refuses deterministically rather than guessing a
60    /// route. Returned before the branch's `NodeEntered` is recorded, so nothing
61    /// lands in the log past the refusal, and the refusal reproduces on replay
62    /// (the same routed value re-evaluates to the same no-match).
63    #[error("branch node `{node}`: no case condition matched the routed value")]
64    NoBranchCaseMatched {
65        /// The id of the branch node.
66        node: String,
67    },
68
69    /// A model-decision `branch`'s agent produced a reply that is not one of the
70    /// branch's case names. Unlike the other refusals this arrives **after** the
71    /// branch's `NodeEntered` and the decision agent's own events are recorded
72    /// (the model had to run to produce the reply); it still reproduces on
73    /// replay, because the reply is decoded from the recorded model completion.
74    #[error(
75        "branch node `{node}`: the decision agent replied `{reply}`, which is not one of the cases [{}]",
76        .cases.join(", ")
77    )]
78    BranchDecisionUnmatched {
79        /// The id of the branch node.
80        node: String,
81        /// The agent's reply, trimmed, that named no case.
82        reply: String,
83        /// The branch's case names, in author order.
84        cases: Vec<String>,
85    },
86
87    /// A node whose kind the engine does not execute was reached on the
88    /// walk. Today the sole such kind is `fold`: its execution semantics are
89    /// not implemented, so the engine refuses it with this typed error rather
90    /// than guessing a loop. Returned **before** the node's `NodeEntered`
91    /// is recorded, so nothing lands in the log past the refusal, and it
92    /// reproduces on replay (the same document re-walks to the same refusal). The
93    /// document layer still validates a fold as a legal graph; only the engine
94    /// declines to run it.
95    #[error("node `{node}`: the engine does not execute `{kind}` nodes yet")]
96    UnsupportedNode {
97        /// The id of the node whose kind is not executable here.
98        node: String,
99        /// The node's kind name (`"fold"`).
100        kind: &'static str,
101    },
102
103    /// An `agent` node referenced an agent hash the resolver could not supply.
104    #[error("agent node `{node}`: no agent registered for hash `{agent_hash}`")]
105    UnknownAgent {
106        /// The id of the agent node.
107        node: String,
108        /// The unresolved agent definition hash.
109        agent_hash: String,
110    },
111
112    /// A `tool` node named a tool the resolver could not supply.
113    #[error("tool node `{node}`: no tool registered under the name `{tool}`")]
114    UnknownTool {
115        /// The id of the tool node.
116        node: String,
117        /// The unresolved tool name.
118        tool: String,
119    },
120
121    /// The graph's edges do not form a well-formed DAG (a cycle, or an edge
122    /// referencing a node that is not in the document). The document validator
123    /// rejects both at submit; the engine re-checks defensively so a walk is
124    /// never attempted over a malformed topology.
125    #[error("the graph is not a well-formed acyclic document: {detail}")]
126    MalformedGraph {
127        /// What was wrong with the topology.
128        detail: String,
129    },
130
131    /// A `tool` node's call failed after exhausting its retry policy. The full
132    /// failure is already recorded in the log's `ToolCallCompleted`; this
133    /// carries the message so the caller sees why the graph stopped.
134    #[error("tool node `{node}` failed: {message}")]
135    ToolFailed {
136        /// The id of the tool node that failed.
137        node: String,
138        /// The recorded failure message.
139        message: String,
140    },
141
142    /// A `gate` node was resumed with an input that does not satisfy the gate's
143    /// declared `approval_schema`. Returned from the **accept edge**: after the
144    /// gate's `Suspended` has been replayed and BEFORE `await_resume` can
145    /// append a `Resumed`, so the refusal appends nothing and leaves the run
146    /// parked exactly where it was, ready for a conforming approval. It is
147    /// therefore not reachable on replay at all: a recorded `Resumed` is
148    /// history and is fed to the gate untouched. See [`crate::approval`].
149    #[error(
150        "gate node `{node}`: the approval input does not satisfy the gate's approval_schema ({})",
151        .violations.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
152    )]
153    ApprovalSchemaViolation {
154        /// The id of the gate node the run is parked at.
155        node: String,
156        /// Every way the input failed the schema, in a stable order.
157        violations: Vec<ApprovalViolation>,
158    },
159
160    /// The graph document could not be serialized to compute its hash. A graph
161    /// is plain data, so this does not arise in practice; it exists to keep the
162    /// hashing edge honest rather than panicking on a `serde_json` error.
163    #[error("could not serialize the graph document to hash it: {0}")]
164    GraphEncode(#[source] serde_json::Error),
165
166    /// A `RunCtx` operation surfaced a runtime error (replay divergence, a
167    /// dangling write needing reconciliation, a live provider failure, a store
168    /// failure). Passed through unchanged.
169    #[error(transparent)]
170    Runtime(#[from] RuntimeError),
171}