Skip to main content

salvor_server/
error.rs

1//! [`ApiError`]: the one error type every handler returns, and the JSON
2//! envelope it serializes to.
3//!
4//! Every failure the control plane reports has the same shape on the wire, so
5//! a thin SDK can decode one thing:
6//!
7//! ```json
8//! { "error": { "code": "unknown_run", "message": "...", "details": { ... } } }
9//! ```
10//!
11//! `code` is a stable machine token (an SDK matches on it); `message` is a
12//! human sentence; `details` is present only when there is structured evidence
13//! to carry. The reconciliation refusal is the original case (the recorded write
14//! intent travels in `details.intent`, mirroring the CLI's report); the fork
15//! endpoint's `write_replay_hazard` carries the same kind of evidence in
16//! `details.writes` (the exact writes a fork would re-fire), which is the
17//! refuse-then-record differentiator on the wire.
18//!
19//! Each variant fixes its own HTTP status, so the status and the body's `code`
20//! never drift: a 404 always carries `unknown_run` or `unknown_agent`, a 409
21//! always carries a conflict or a reconciliation refusal, and so on.
22
23use axum::Json;
24use axum::http::StatusCode;
25use axum::response::{IntoResponse, Response};
26use serde_json::{Value, json};
27
28/// A control-plane error, with the HTTP status and machine code baked in.
29#[derive(Debug)]
30pub enum ApiError {
31    /// A request body was malformed, or a resume input failed validation
32    /// against the recorded schema. HTTP 400.
33    BadRequest(String),
34    /// The bearer token was missing or wrong. HTTP 401.
35    Unauthorized,
36    /// No run exists under the given id. HTTP 404.
37    UnknownRun(String),
38    /// No agent is registered under the given id. HTTP 404.
39    UnknownAgent(String),
40    /// A run already exists at the requested id. HTTP 409.
41    RunExists(String),
42    /// A verb was applied to a run in the wrong state (resuming a finished
43    /// run, resolving a run that has no dangling write). HTTP 409.
44    WrongState(String),
45    /// The server-driven resume endpoint was called on a run opened through
46    /// `/v1/client-runs`. HTTP 409. That run's client holds the single-writer
47    /// drive token and is the only legal driver of it; resuming it here, even
48    /// when the agent it recorded happens to be registered on this server,
49    /// would start a second writer racing the client's lease for the same
50    /// positions. Checked before any state-dependent dispatch, so it also
51    /// pre-empts the still-sleeping and reconciliation refusals: a
52    /// client-driven run is refused this way regardless of what its log folds
53    /// to. Nothing is recorded and no driver task is spawned. The message
54    /// names `/v1/client-runs`, the surface that does resume it.
55    ClientDrivenRun(String),
56    /// A client-driven append arrived with no drive token. HTTP 401. The drive
57    /// token is the per-run single-writer lease; every append must present it.
58    MissingDriveToken(String),
59    /// A client-driven append presented a drive token that is not the run's
60    /// current lease. HTTP 403. Only the run's current writer may drive it.
61    InvalidDriveToken(String),
62    /// A run was re-opened while another driver's lease on it is still current.
63    /// HTTP 409, the same state conflict a still-sleeping refusal is: the verb
64    /// is right and the run is simply not available to a second writer yet.
65    /// Carries how long until the hold lapses, so the caller can wait rather
66    /// than poll.
67    ///
68    /// Nothing is recorded and no lease is minted, so the driver that holds the
69    /// run keeps driving it. Taking the run away from a live driver is what this
70    /// exists to prevent: two processes that both believe they hold the run
71    /// append the same steps twice and one of them dies on a divergence.
72    LeaseHeld {
73        /// The human sentence.
74        message: String,
75        /// Whole seconds until the holder's lease lapses if it stays quiet,
76        /// rounded up so a hold with any time left never reports zero.
77        lapses_in_seconds: i64,
78    },
79    /// A client-driven append carried an event kind this endpoint does not
80    /// accept (a model or tool event, which the model-step and tool-step
81    /// endpoints own). HTTP 422.
82    UnsupportedEventKind(String),
83    /// A client-driven append is not the legal next event for the run's log:
84    /// the re-folding append-guard rejected it, or byte-different bytes arrived
85    /// at an already-recorded position. HTTP 409.
86    Divergence(String),
87    /// A request body exceeded the size or count cap. HTTP 413.
88    PayloadTooLarge(String),
89    /// A server-performed model step was requested but no model executor is
90    /// wired on this server (the host injected none). HTTP 503. Recording no
91    /// completion, so the run stays drivable once an executor is present.
92    ModelExecutorUnavailable(String),
93    /// The provider call for a model step failed. HTTP 502. No completion is
94    /// recorded, so the write-ahead intent is left dangling (the legal crash
95    /// story) and the run stays drivable: a retry re-issues the call safely.
96    ModelExecution(String),
97    /// A tool-step named a tool the server's registry does not hold. HTTP 404.
98    /// Nothing is written for a tool the server cannot dispatch, so the step is
99    /// retriable once the tool is registered.
100    UnknownTool(String),
101    /// A server-performed tool step was requested but no tool registry is wired
102    /// on this server (the host injected none). HTTP 503. The mirror of
103    /// [`ModelExecutorUnavailable`](Self::ModelExecutorUnavailable): no intent
104    /// is written, so the run stays drivable once a registry is present.
105    ToolRegistryUnavailable(String),
106    /// The dispatch of a tool-step's tool failed. HTTP 502. No completion is
107    /// recorded, so the write-ahead intent is left dangling (the legal crash
108    /// story) and the run stays drivable-or-reconcilable per the tool's effect.
109    ToolExecution(String),
110    /// A client tried to record its own completion for a client-performed tool
111    /// call this server will not take its word for: the pending intent was
112    /// performed by the server, or the declaration says
113    /// `trust_completion = false`, or the declaration carries no `output_schema`
114    /// to check the report against. HTTP 403.
115    ///
116    /// Nothing is recorded, so the log still ends at the recorded intent. For a
117    /// `Write` that is already `needs_reconciliation` to the pure fold in
118    /// `salvor-replay`, and `POST /v1/client-runs/{id}/resolve` already exists
119    /// to settle it by hand: the refusal reuses machinery rather than inventing
120    /// a state. The message names that endpoint, because it is what the caller
121    /// does next.
122    ClientCompletionRefused(String),
123    /// A submitted graph document failed strict validation. HTTP 400. Carries
124    /// the complete, node/edge-precise error list as evidence, because a graph
125    /// is a control document validated all at once (collect-all, no
126    /// short-circuit), so an author sees every mistake in one response.
127    InvalidGraph {
128        /// The human sentence.
129        message: String,
130        /// The full list of structured validation errors, each naming the node
131        /// or edge at fault.
132        errors: Value,
133    },
134    /// A graph run parked at a `gate` was resumed with an approval that does
135    /// not satisfy the gate's declared `approval_schema`. HTTP 400. Carries the
136    /// gate's node id and the full violation list as evidence, in the same
137    /// collect-all spirit as [`InvalidGraph`](Self::InvalidGraph): an operator
138    /// filling an approval form should see every field that is wrong in one
139    /// response, not one per round trip.
140    ///
141    /// Refused synchronously, before the driver task is spawned, so the log is
142    /// untouched and the run stays parked at that gate.
143    ApprovalSchemaViolation {
144        /// The human sentence.
145        message: String,
146        /// The id of the gate node the run is parked at.
147        node: String,
148        /// Each violation as `{ path, message }`, in a stable order.
149        violations: Value,
150    },
151    /// No graph is stored under the given hash. HTTP 404.
152    UnknownGraph(String),
153    /// A graph-only endpoint (the per-run graph projection) was asked for a run
154    /// whose log is not a graph run (an ordinary agent run has no
155    /// `GraphRunStarted` head). HTTP 409.
156    NotAGraphRun(String),
157    /// A fork was requested from a node the origin never entered (it is not in
158    /// the graph, or the walk routed past it). A fork point must be a node
159    /// boundary the run reached. HTTP 409.
160    InvalidForkNode(String),
161    /// A fork was requested of an origin parked at a dangling write (status
162    /// `NeedsReconciliation`): the origin must be resolved first, since forking
163    /// past an unsettled write would carry that ambiguity into the child. HTTP
164    /// 409. Carries the origin's recorded write intent as evidence, mirroring
165    /// [`NeedsReconciliation`](Self::NeedsReconciliation).
166    OriginNeedsReconciliation {
167        /// The human sentence.
168        message: String,
169        /// The origin's recorded dangling write intent (the same shape a resume
170        /// reconciliation refusal carries).
171        intent: Value,
172    },
173    /// A fork would re-walk a segment containing recorded `Effect::Write` intents
174    /// the operator has not acknowledged. HTTP 409. Carries the exact writes that
175    /// would re-fire as evidence, mirroring
176    /// [`NeedsReconciliation`](Self::NeedsReconciliation)'s use of `details`: the
177    /// refuse-then-record differentiator in one response the operator can read
178    /// and then acknowledge.
179    WriteReplayHazard {
180        /// The human sentence.
181        message: String,
182        /// The unacknowledged writes the fork's re-walked segment would
183        /// re-execute, each `{ seq, tool, input, idempotency_key, recorded_at }`.
184        writes: Value,
185    },
186    /// A run needs human reconciliation and cannot be driven automatically.
187    /// Carries the recorded write intent as evidence. HTTP 409.
188    NeedsReconciliation {
189        /// The human sentence.
190        message: String,
191        /// The recorded intent (tool, input, effect, idempotency key, seq,
192        /// recorded time), so the caller sees exactly what to reconcile.
193        intent: Value,
194    },
195    /// A run parked on a durable timer was resumed before its instant. HTTP
196    /// 409, the same state conflict a reconciliation refusal is: the verb is
197    /// right and the run is simply not in a state to take it yet. Carries the
198    /// deadline and how long is left, so a caller can schedule its retry
199    /// instead of polling.
200    ///
201    /// Nothing is recorded and no driver is spawned, so the run is exactly as
202    /// asleep as it was. A run whose instant HAS arrived never reaches this
203    /// variant: it re-drives like any other recoverable run, which is what
204    /// makes the wake sweeper need no endpoint of its own.
205    StillSleeping {
206        /// The human sentence.
207        message: String,
208        /// The recorded instant the run may continue at, RFC 3339.
209        wake_at: String,
210        /// Whole seconds between now and that instant.
211        remaining_seconds: i64,
212    },
213    /// An unexpected internal failure (a store read, an agent build). HTTP
214    /// 500. The message is safe to surface: it names the layer, not a secret.
215    Internal(String),
216}
217
218impl ApiError {
219    /// The HTTP status and stable machine `code` for this error.
220    fn status_and_code(&self) -> (StatusCode, &'static str) {
221        match self {
222            ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
223            ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
224            ApiError::UnknownRun(_) => (StatusCode::NOT_FOUND, "unknown_run"),
225            ApiError::UnknownAgent(_) => (StatusCode::NOT_FOUND, "unknown_agent"),
226            ApiError::RunExists(_) => (StatusCode::CONFLICT, "run_exists"),
227            ApiError::WrongState(_) => (StatusCode::CONFLICT, "wrong_state"),
228            ApiError::ClientDrivenRun(_) => (StatusCode::CONFLICT, "client_driven_run"),
229            ApiError::InvalidGraph { .. } => (StatusCode::BAD_REQUEST, "invalid_graph"),
230            ApiError::ApprovalSchemaViolation { .. } => {
231                (StatusCode::BAD_REQUEST, "approval_schema_violation")
232            }
233            ApiError::UnknownGraph(_) => (StatusCode::NOT_FOUND, "unknown_graph"),
234            ApiError::NotAGraphRun(_) => (StatusCode::CONFLICT, "not_a_graph_run"),
235            ApiError::InvalidForkNode(_) => (StatusCode::CONFLICT, "invalid_fork_node"),
236            ApiError::OriginNeedsReconciliation { .. } => {
237                (StatusCode::CONFLICT, "origin_needs_reconciliation")
238            }
239            ApiError::WriteReplayHazard { .. } => (StatusCode::CONFLICT, "write_replay_hazard"),
240            ApiError::NeedsReconciliation { .. } => (StatusCode::CONFLICT, "needs_reconciliation"),
241            ApiError::StillSleeping { .. } => (StatusCode::CONFLICT, "still_sleeping"),
242            ApiError::MissingDriveToken(_) => (StatusCode::UNAUTHORIZED, "missing_drive_token"),
243            ApiError::InvalidDriveToken(_) => (StatusCode::FORBIDDEN, "invalid_drive_token"),
244            ApiError::LeaseHeld { .. } => (StatusCode::CONFLICT, "lease_held"),
245            ApiError::UnsupportedEventKind(_) => {
246                (StatusCode::UNPROCESSABLE_ENTITY, "unsupported_event_kind")
247            }
248            ApiError::Divergence(_) => (StatusCode::CONFLICT, "divergence"),
249            ApiError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large"),
250            ApiError::ModelExecutorUnavailable(_) => (
251                StatusCode::SERVICE_UNAVAILABLE,
252                "model_executor_unavailable",
253            ),
254            ApiError::ModelExecution(_) => (StatusCode::BAD_GATEWAY, "model_execution"),
255            ApiError::UnknownTool(_) => (StatusCode::NOT_FOUND, "unknown_tool"),
256            ApiError::ToolRegistryUnavailable(_) => {
257                (StatusCode::SERVICE_UNAVAILABLE, "tool_registry_unavailable")
258            }
259            ApiError::ToolExecution(_) => (StatusCode::BAD_GATEWAY, "tool_execution"),
260            ApiError::ClientCompletionRefused(_) => {
261                (StatusCode::FORBIDDEN, "client_completion_refused")
262            }
263            ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
264        }
265    }
266
267    /// The human sentence for this error.
268    fn message(&self) -> String {
269        match self {
270            ApiError::BadRequest(m)
271            | ApiError::UnknownRun(m)
272            | ApiError::UnknownAgent(m)
273            | ApiError::RunExists(m)
274            | ApiError::WrongState(m)
275            | ApiError::ClientDrivenRun(m)
276            | ApiError::Internal(m)
277            | ApiError::MissingDriveToken(m)
278            | ApiError::InvalidDriveToken(m)
279            | ApiError::UnsupportedEventKind(m)
280            | ApiError::Divergence(m)
281            | ApiError::PayloadTooLarge(m)
282            | ApiError::ModelExecutorUnavailable(m)
283            | ApiError::ModelExecution(m)
284            | ApiError::UnknownTool(m)
285            | ApiError::ToolRegistryUnavailable(m)
286            | ApiError::ToolExecution(m)
287            | ApiError::ClientCompletionRefused(m)
288            | ApiError::UnknownGraph(m)
289            | ApiError::NotAGraphRun(m)
290            | ApiError::InvalidForkNode(m)
291            | ApiError::InvalidGraph { message: m, .. }
292            | ApiError::ApprovalSchemaViolation { message: m, .. }
293            | ApiError::OriginNeedsReconciliation { message: m, .. }
294            | ApiError::WriteReplayHazard { message: m, .. }
295            | ApiError::NeedsReconciliation { message: m, .. }
296            | ApiError::StillSleeping { message: m, .. }
297            | ApiError::LeaseHeld { message: m, .. } => m.clone(),
298            ApiError::Unauthorized => "missing or invalid bearer token".to_owned(),
299        }
300    }
301}
302
303impl IntoResponse for ApiError {
304    fn into_response(self) -> Response {
305        let (status, code) = self.status_and_code();
306        let message = self.message();
307        let mut error = json!({ "code": code, "message": message });
308        match self {
309            ApiError::NeedsReconciliation { intent, .. }
310            | ApiError::OriginNeedsReconciliation { intent, .. } => {
311                error["details"] = json!({ "intent": intent });
312            }
313            ApiError::WriteReplayHazard { writes, .. } => {
314                error["details"] = json!({ "writes": writes });
315            }
316            ApiError::InvalidGraph { errors, .. } => {
317                error["details"] = json!({ "errors": errors });
318            }
319            ApiError::ApprovalSchemaViolation {
320                node, violations, ..
321            } => {
322                error["details"] = json!({ "node": node, "violations": violations });
323            }
324            ApiError::StillSleeping {
325                wake_at,
326                remaining_seconds,
327                ..
328            } => {
329                error["details"] =
330                    json!({ "wake_at": wake_at, "remaining_seconds": remaining_seconds });
331            }
332            ApiError::LeaseHeld {
333                lapses_in_seconds, ..
334            } => {
335                error["details"] = json!({ "lapses_in_seconds": lapses_in_seconds });
336            }
337            _ => {}
338        }
339        (status, Json(json!({ "error": error }))).into_response()
340    }
341}