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