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    /// A client-driven append arrived with no drive token. HTTP 401. The drive
46    /// token is the per-run single-writer lease; every append must present it.
47    MissingDriveToken(String),
48    /// A client-driven append presented a drive token that is not the run's
49    /// current lease. HTTP 403. Only the run's current writer may drive it.
50    InvalidDriveToken(String),
51    /// A client-driven append carried an event kind this endpoint does not
52    /// accept (a model or tool event, which the model-step and tool-step
53    /// endpoints own). HTTP 422.
54    UnsupportedEventKind(String),
55    /// A client-driven append is not the legal next event for the run's log:
56    /// the re-folding append-guard rejected it, or byte-different bytes arrived
57    /// at an already-recorded position. HTTP 409.
58    Divergence(String),
59    /// A request body exceeded the size or count cap. HTTP 413.
60    PayloadTooLarge(String),
61    /// A server-performed model step was requested but no model executor is
62    /// wired on this server (the host injected none). HTTP 503. Recording no
63    /// completion, so the run stays drivable once an executor is present.
64    ModelExecutorUnavailable(String),
65    /// The provider call for a model step failed. HTTP 502. No completion is
66    /// recorded, so the write-ahead intent is left dangling (the legal crash
67    /// story) and the run stays drivable: a retry re-issues the call safely.
68    ModelExecution(String),
69    /// A tool-step named a tool the server's registry does not hold. HTTP 404.
70    /// Nothing is written for a tool the server cannot dispatch, so the step is
71    /// retriable once the tool is registered.
72    UnknownTool(String),
73    /// A server-performed tool step was requested but no tool registry is wired
74    /// on this server (the host injected none). HTTP 503. The mirror of
75    /// [`ModelExecutorUnavailable`](Self::ModelExecutorUnavailable): no intent
76    /// is written, so the run stays drivable once a registry is present.
77    ToolRegistryUnavailable(String),
78    /// The dispatch of a tool-step's tool failed. HTTP 502. No completion is
79    /// recorded, so the write-ahead intent is left dangling (the legal crash
80    /// story) and the run stays drivable-or-reconcilable per the tool's effect.
81    ToolExecution(String),
82    /// A submitted graph document failed strict validation. HTTP 400. Carries
83    /// the complete, node/edge-precise error list as evidence, because a graph
84    /// is a control document validated all at once (collect-all, no
85    /// short-circuit), so an author sees every mistake in one response.
86    InvalidGraph {
87        /// The human sentence.
88        message: String,
89        /// The full list of structured validation errors, each naming the node
90        /// or edge at fault.
91        errors: Value,
92    },
93    /// No graph is stored under the given hash. HTTP 404.
94    UnknownGraph(String),
95    /// A graph-only endpoint (the per-run graph projection) was asked for a run
96    /// whose log is not a graph run (an ordinary agent run has no
97    /// `GraphRunStarted` head). HTTP 409.
98    NotAGraphRun(String),
99    /// A fork was requested from a node the origin never entered (it is not in
100    /// the graph, or the walk routed past it). A fork point must be a node
101    /// boundary the run reached. HTTP 409.
102    InvalidForkNode(String),
103    /// A fork was requested of an origin parked at a dangling write (status
104    /// `NeedsReconciliation`): the origin must be resolved first, since forking
105    /// past an unsettled write would carry that ambiguity into the child. HTTP
106    /// 409. Carries the origin's recorded write intent as evidence, mirroring
107    /// [`NeedsReconciliation`](Self::NeedsReconciliation).
108    OriginNeedsReconciliation {
109        /// The human sentence.
110        message: String,
111        /// The origin's recorded dangling write intent (the same shape a resume
112        /// reconciliation refusal carries).
113        intent: Value,
114    },
115    /// A fork would re-walk a segment containing recorded `Effect::Write` intents
116    /// the operator has not acknowledged. HTTP 409. Carries the exact writes that
117    /// would re-fire as evidence, mirroring
118    /// [`NeedsReconciliation`](Self::NeedsReconciliation)'s use of `details`: the
119    /// refuse-then-record differentiator in one response the operator can read
120    /// and then acknowledge.
121    WriteReplayHazard {
122        /// The human sentence.
123        message: String,
124        /// The unacknowledged writes the fork's re-walked segment would
125        /// re-execute, each `{ seq, tool, input, idempotency_key, recorded_at }`.
126        writes: Value,
127    },
128    /// A run needs human reconciliation and cannot be driven automatically.
129    /// Carries the recorded write intent as evidence. HTTP 409.
130    NeedsReconciliation {
131        /// The human sentence.
132        message: String,
133        /// The recorded intent (tool, input, effect, idempotency key, seq,
134        /// recorded time), so the caller sees exactly what to reconcile.
135        intent: Value,
136    },
137    /// An unexpected internal failure (a store read, an agent build). HTTP
138    /// 500. The message is safe to surface: it names the layer, not a secret.
139    Internal(String),
140}
141
142impl ApiError {
143    /// The HTTP status and stable machine `code` for this error.
144    fn status_and_code(&self) -> (StatusCode, &'static str) {
145        match self {
146            ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
147            ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
148            ApiError::UnknownRun(_) => (StatusCode::NOT_FOUND, "unknown_run"),
149            ApiError::UnknownAgent(_) => (StatusCode::NOT_FOUND, "unknown_agent"),
150            ApiError::RunExists(_) => (StatusCode::CONFLICT, "run_exists"),
151            ApiError::WrongState(_) => (StatusCode::CONFLICT, "wrong_state"),
152            ApiError::InvalidGraph { .. } => (StatusCode::BAD_REQUEST, "invalid_graph"),
153            ApiError::UnknownGraph(_) => (StatusCode::NOT_FOUND, "unknown_graph"),
154            ApiError::NotAGraphRun(_) => (StatusCode::CONFLICT, "not_a_graph_run"),
155            ApiError::InvalidForkNode(_) => (StatusCode::CONFLICT, "invalid_fork_node"),
156            ApiError::OriginNeedsReconciliation { .. } => {
157                (StatusCode::CONFLICT, "origin_needs_reconciliation")
158            }
159            ApiError::WriteReplayHazard { .. } => (StatusCode::CONFLICT, "write_replay_hazard"),
160            ApiError::NeedsReconciliation { .. } => (StatusCode::CONFLICT, "needs_reconciliation"),
161            ApiError::MissingDriveToken(_) => (StatusCode::UNAUTHORIZED, "missing_drive_token"),
162            ApiError::InvalidDriveToken(_) => (StatusCode::FORBIDDEN, "invalid_drive_token"),
163            ApiError::UnsupportedEventKind(_) => {
164                (StatusCode::UNPROCESSABLE_ENTITY, "unsupported_event_kind")
165            }
166            ApiError::Divergence(_) => (StatusCode::CONFLICT, "divergence"),
167            ApiError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large"),
168            ApiError::ModelExecutorUnavailable(_) => (
169                StatusCode::SERVICE_UNAVAILABLE,
170                "model_executor_unavailable",
171            ),
172            ApiError::ModelExecution(_) => (StatusCode::BAD_GATEWAY, "model_execution"),
173            ApiError::UnknownTool(_) => (StatusCode::NOT_FOUND, "unknown_tool"),
174            ApiError::ToolRegistryUnavailable(_) => {
175                (StatusCode::SERVICE_UNAVAILABLE, "tool_registry_unavailable")
176            }
177            ApiError::ToolExecution(_) => (StatusCode::BAD_GATEWAY, "tool_execution"),
178            ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
179        }
180    }
181
182    /// The human sentence for this error.
183    fn message(&self) -> String {
184        match self {
185            ApiError::BadRequest(m)
186            | ApiError::UnknownRun(m)
187            | ApiError::UnknownAgent(m)
188            | ApiError::RunExists(m)
189            | ApiError::WrongState(m)
190            | ApiError::Internal(m)
191            | ApiError::MissingDriveToken(m)
192            | ApiError::InvalidDriveToken(m)
193            | ApiError::UnsupportedEventKind(m)
194            | ApiError::Divergence(m)
195            | ApiError::PayloadTooLarge(m)
196            | ApiError::ModelExecutorUnavailable(m)
197            | ApiError::ModelExecution(m)
198            | ApiError::UnknownTool(m)
199            | ApiError::ToolRegistryUnavailable(m)
200            | ApiError::ToolExecution(m)
201            | ApiError::UnknownGraph(m)
202            | ApiError::NotAGraphRun(m)
203            | ApiError::InvalidForkNode(m)
204            | ApiError::InvalidGraph { message: m, .. }
205            | ApiError::OriginNeedsReconciliation { message: m, .. }
206            | ApiError::WriteReplayHazard { message: m, .. }
207            | ApiError::NeedsReconciliation { message: m, .. } => m.clone(),
208            ApiError::Unauthorized => "missing or invalid bearer token".to_owned(),
209        }
210    }
211}
212
213impl IntoResponse for ApiError {
214    fn into_response(self) -> Response {
215        let (status, code) = self.status_and_code();
216        let message = self.message();
217        let mut error = json!({ "code": code, "message": message });
218        match self {
219            ApiError::NeedsReconciliation { intent, .. }
220            | ApiError::OriginNeedsReconciliation { intent, .. } => {
221                error["details"] = json!({ "intent": intent });
222            }
223            ApiError::WriteReplayHazard { writes, .. } => {
224                error["details"] = json!({ "writes": writes });
225            }
226            ApiError::InvalidGraph { errors, .. } => {
227                error["details"] = json!({ "errors": errors });
228            }
229            _ => {}
230        }
231        (status, Json(json!({ "error": error }))).into_response()
232    }
233}