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}