Skip to main content

aion_server/
error.rs

1//! `ServerError` taxonomy for server library modules.
2
3use std::borrow::Cow;
4use std::net::SocketAddr;
5use std::path::PathBuf;
6
7use aion::EngineError;
8use aion_core::{ActivityId, WorkflowId};
9use aion_proto::WireError;
10use aion_store::StoreError;
11use thiserror::Error;
12
13#[path = "error_engine.rs"]
14mod engine;
15#[path = "error_process_exit.rs"]
16mod process_exit;
17
18/// Server-library error taxonomy.
19#[derive(Debug, Error)]
20pub enum ServerError {
21    /// Operator configuration could not be loaded or validated.
22    #[error("configuration error: {message}")]
23    Config {
24        /// Redacted, operator-facing failure message.
25        message: String,
26    },
27
28    /// A path-ambient store backend was configured beneath a renameable directory.
29    #[error(
30        "unsafe store.data_dir `{}`: ancestor `{}` is not owner-controlled: {reason}; \
31         leave store.data_dir unset so it defaults beneath the private Aion home \
32         (`$AION_HOME`, default `$HOME/.aion`), or set a path whose ancestor chain is \
33         owner-only (a leading `~` expands against $HOME; a relative path resolves \
34         against the server's working directory)",
35        .data_root.display(),
36        .component.display()
37    )]
38    UnsafeDataRootAncestor {
39        /// Descriptor-resolved data root that the backend would use by pathname.
40        data_root: PathBuf,
41        /// First unsafe component in the resolved root's ancestor chain.
42        component: PathBuf,
43        /// Ownership, mode, or inspection failure that made the component unsafe.
44        reason: String,
45    },
46
47    /// A transport listener could not bind or start.
48    #[error("{transport} transport failed at {address}: {message}")]
49    TransportBind {
50        /// Transport name.
51        transport: &'static str,
52        /// Configured listener address.
53        address: SocketAddr,
54        /// Redacted, operator-facing failure message.
55        message: String,
56    },
57
58    /// A running transport task aborted: it panicked or was cancelled.
59    #[error("{transport} transport task failed: {message}")]
60    Transport {
61        /// Transport name.
62        transport: &'static str,
63        /// Redacted, operator-facing failure message.
64        message: String,
65    },
66
67    /// A termination-signal listener could not be installed or failed.
68    #[error("{listener} listener failed: {message}")]
69    SignalListener {
70        /// Listener name (`SIGTERM`, `SIGINT`, or the portable fallback).
71        listener: &'static str,
72        /// Redacted, operator-facing failure message.
73        message: String,
74    },
75
76    /// Namespace validation or authorization failed.
77    #[error("namespace error: {message}")]
78    Namespace {
79        /// Redacted namespace failure message.
80        message: String,
81    },
82
83    /// Engine call failed.
84    #[error("engine call failed: {source}")]
85    EngineCall {
86        /// Typed engine error returned by the embedded engine.
87        #[from]
88        source: EngineError,
89    },
90
91    /// Store backend call failed before an engine handle was available.
92    #[error("store backend failed: {source}")]
93    StoreBackend {
94        /// Typed store error returned by the configured backend.
95        #[from]
96        source: StoreError,
97    },
98
99    /// Streaming failure.
100    #[error("stream failure: {failure}")]
101    Stream {
102        /// Stream failure class.
103        failure: StreamFailure,
104    },
105
106    /// A scheduled activity could not be pushed to a worker.
107    #[error(
108        "worker dispatch failed for namespace {namespace}, activity type {activity_type}: {reason}"
109    )]
110    WorkerDispatch {
111        /// Namespace scoped before dispatch.
112        namespace: String,
113        /// Activity type requested by the engine.
114        activity_type: String,
115        /// Redacted dispatch failure reason.
116        reason: String,
117    },
118
119    /// The worker connection chosen for a dispatch was lost mid-flight: the
120    /// connection was already gone at push time, or it closed before the worker
121    /// sent its correlated push reply.
122    ///
123    /// This is DISTINCT from [`Self::WorkerDispatch`]: a `WorkerDispatch` covers a
124    /// genuine reply timeout (the worker is alive but slow), a no-worker-available
125    /// selection failure, or any other dispatch fault, all of which keep the
126    /// outbox's normal exponential backoff. A `WorkerConnectionLost` instead means
127    /// the chosen worker is gone (and has already been deregistered by liminal's
128    /// `on_worker_unregistered`), so the row can be re-armed for IMMEDIATE re-claim
129    /// to fail over to a live worker without waiting out the backoff. The outbox
130    /// dispatcher keys its fast-failover decision on this variant.
131    #[error("worker connection lost during dispatch on {channel}: {detail}")]
132    WorkerConnectionLost {
133        /// Row-derived dispatch channel for operator diagnostics.
134        channel: String,
135        /// Redacted, operator-facing description of how the connection was lost.
136        detail: String,
137    },
138
139    /// The worker connection chosen for a dispatch refused admission because it
140    /// already holds its pending-push cap — the worker is ALIVE and WORKING,
141    /// its connection is simply full of held dispatches.
142    ///
143    /// DISTINCT from both [`Self::WorkerDispatch`] (which consumes a retry
144    /// attempt on backoff) and [`Self::WorkerConnectionLost`] (immediate
145    /// attempt-consuming failover): a busy worker is neither slow to reply nor
146    /// gone, so the outbox re-arms the row ATTEMPT-NEUTRALLY after a short
147    /// backoff — capacity pressure must never spend the retry budget that
148    /// prices genuine delivery failures, and must never dead-letter work.
149    #[error("worker connection busy during dispatch on {channel}: {detail}")]
150    WorkerBusy {
151        /// Row-derived dispatch channel for operator diagnostics.
152        channel: String,
153        /// Redacted, operator-facing description of the admission refusal.
154        detail: String,
155    },
156
157    /// A worker result did not prove ownership of the current execution
158    /// generation and was refused before reaching workflow state.
159    #[error(
160        "activity completion rejected for workflow {workflow_id}, activity {activity_id}: {reason}"
161    )]
162    ActivityCompletionRejected {
163        /// Workflow named by the submitted completion.
164        workflow_id: WorkflowId,
165        /// Activity site named by the submitted completion.
166        activity_id: ActivityId,
167        /// Typed reason the completion token did not authorize this write.
168        reason: CompletionRejectionReason,
169    },
170
171    /// A lock was poisoned and the protected state cannot be trusted.
172    #[error("{resource} lock was poisoned")]
173    LockPoisoned {
174        /// Protected resource name.
175        resource: &'static str,
176    },
177
178    /// A failure already translated into the public wire taxonomy.
179    #[error("wire error: {wire}")]
180    Wire {
181        /// Stable wire error.
182        wire: WireError,
183    },
184}
185
186/// Typed completion-fence refusal classes.
187#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
188pub enum CompletionRejectionReason {
189    /// A worker from before completion-token support omitted the required token.
190    #[error("completion token is missing (worker registration era is incompatible)")]
191    MissingCompletionToken,
192    /// No generation is currently authorized for this activity execution.
193    #[error("no execution generation is currently accepting completion")]
194    NoCurrentGeneration,
195    /// A later dispatch superseded the submitted generation.
196    #[error("completion token belongs to a stale execution generation")]
197    StaleGeneration,
198}
199
200/// Bounded-stream and connection failure classes.
201#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
202pub enum StreamFailure {
203    /// Bounded per-connection buffer overflowed because the consumer lagged.
204    #[error("consumer lagged behind bounded buffer")]
205    Lagged,
206    /// Subscriber closed the connection.
207    #[error("subscriber connection closed")]
208    Closed,
209    /// Upstream engine event stream ended unexpectedly.
210    #[error("engine event stream closed")]
211    UpstreamClosed,
212}
213
214impl From<WireError> for ServerError {
215    fn from(wire: WireError) -> Self {
216        Self::Wire { wire }
217    }
218}
219
220impl ServerError {
221    /// Convert a server error that crosses a transport boundary into the stable
222    /// public wire taxonomy.
223    #[must_use]
224    pub fn to_wire_error(&self) -> WireError {
225        match self {
226            Self::Config { .. }
227            | Self::UnsafeDataRootAncestor { .. }
228            | Self::TransportBind { .. }
229            | Self::Transport { .. }
230            | Self::SignalListener { .. }
231            | Self::LockPoisoned { .. } => WireError::backend("server backend failure"),
232            Self::ActivityCompletionRejected { .. } => {
233                WireError::backend("stale activity completion rejected")
234            }
235            Self::WorkerDispatch { .. } => WireError::backend("worker dispatch failed"),
236            Self::WorkerConnectionLost { .. } => {
237                WireError::backend("worker connection lost during dispatch")
238            }
239            Self::WorkerBusy { .. } => WireError::backend("worker connection busy during dispatch"),
240            Self::Namespace { message } => WireError::namespace_denied(message.clone()),
241            Self::EngineCall { source } => wire_from_engine(source),
242            Self::StoreBackend { source } => wire_from_store(source),
243            Self::Stream { failure } => match failure {
244                StreamFailure::Lagged => WireError::lagged("subscriber lagged behind"),
245                StreamFailure::Closed | StreamFailure::UpstreamClosed => {
246                    WireError::backend("event stream closed")
247                }
248            },
249            Self::Wire { wire } => wire.clone(),
250        }
251    }
252
253    /// Return true when this is an operator configuration failure.
254    #[must_use]
255    pub const fn is_config(&self) -> bool {
256        matches!(
257            self,
258            Self::Config { .. } | Self::UnsafeDataRootAncestor { .. }
259        )
260    }
261
262    /// Construct a namespace-denied error without embedding authorization logic.
263    #[must_use]
264    pub fn namespace_denied(message: impl Into<String>) -> Self {
265        Self::Namespace {
266            message: message.into(),
267        }
268    }
269
270    /// Construct the loud, whole-registration rejection when a worker's advertised
271    /// `node` violates a `Pinned{L}` namespace's placement (Control-Plane Phase 2,
272    /// P2-I1). Names the offending namespace, the worker's advertised node (or
273    /// "none"), and the required label set, so the operator sees exactly why the
274    /// registration was refused. Carried on the namespace-denied wire code — a
275    /// registration refused on isolation grounds is a namespace-authorization
276    /// failure, not a transient dispatch error.
277    #[must_use]
278    pub fn placement_admission_denied(
279        namespace: &str,
280        worker_node: Option<&str>,
281        required: &std::collections::BTreeSet<String>,
282    ) -> Self {
283        let node = worker_node.unwrap_or("none");
284        let required = required
285            .iter()
286            .map(String::as_str)
287            .collect::<Vec<_>>()
288            .join(", ");
289        Self::namespace_denied(format!(
290            "worker registration rejected: namespace {namespace} is Pinned to node label(s) \
291             [{required}] but the worker advertises node {node}, which is not in the required set"
292        ))
293    }
294
295    /// Construct a deploy-authorization denial carried on the dedicated
296    /// `deploy_denied` wire code (deploy is not a namespace operation).
297    #[must_use]
298    pub fn deploy_denied(message: impl Into<String>) -> Self {
299        Self::Wire {
300            wire: WireError::deploy_denied(message),
301        }
302    }
303
304    /// Construct a lagged-stream error.
305    #[must_use]
306    pub const fn lagged_stream() -> Self {
307        Self::Stream {
308            failure: StreamFailure::Lagged,
309        }
310    }
311
312    /// Construct a worker-dispatch error.
313    #[must_use]
314    pub fn worker_dispatch(
315        namespace: impl Into<String>,
316        activity_type: impl Into<String>,
317        reason: impl Into<String>,
318    ) -> Self {
319        Self::WorkerDispatch {
320            namespace: namespace.into(),
321            activity_type: activity_type.into(),
322            reason: reason.into(),
323        }
324    }
325
326    /// Construct a worker-connection-lost error for a dispatch whose chosen
327    /// worker connection was gone at push time or closed before replying.
328    #[must_use]
329    pub fn worker_connection_lost(channel: impl Into<String>, detail: impl Into<String>) -> Self {
330        Self::WorkerConnectionLost {
331            channel: channel.into(),
332            detail: detail.into(),
333        }
334    }
335
336    /// Return true when this is a lost-worker-connection dispatch failure.
337    ///
338    /// The outbox dispatcher keys its fast cross-node failover on this: a lost
339    /// connection means the worker is gone (already deregistered), so the row is
340    /// re-armed for immediate re-claim instead of waiting out the retry backoff.
341    #[must_use]
342    pub const fn is_worker_connection_lost(&self) -> bool {
343        matches!(self, Self::WorkerConnectionLost { .. })
344    }
345
346    /// Construct a worker-busy error for a dispatch refused at push admission
347    /// because the worker's connection already holds its pending-push cap.
348    #[must_use]
349    pub fn worker_busy(channel: impl Into<String>, detail: impl Into<String>) -> Self {
350        Self::WorkerBusy {
351            channel: channel.into(),
352            detail: detail.into(),
353        }
354    }
355
356    /// Return true when this is a busy-worker admission refusal.
357    ///
358    /// The outbox dispatcher keys its ATTEMPT-NEUTRAL re-arm on this: a full
359    /// connection means the worker is alive and holding earlier dispatches, so
360    /// the row waits for capacity without spending the retry budget that prices
361    /// genuine delivery failures.
362    #[must_use]
363    pub const fn is_worker_busy(&self) -> bool {
364        matches!(self, Self::WorkerBusy { .. })
365    }
366
367    /// Construct a lock-poison error at the lock boundary.
368    #[must_use]
369    pub const fn lock_poisoned(resource: &'static str) -> Self {
370        Self::LockPoisoned { resource }
371    }
372}
373
374/// Stable structured error metadata for tracing events.
375#[derive(Clone)]
376pub struct ErrorTraceFields<'a> {
377    /// Outer error type recorded in the `error_type` tracing field.
378    pub error_type: Cow<'a, str>,
379    /// Optional inner store error type for `StoreError` records.
380    pub store_error_type: Option<&'static str>,
381    /// Human-readable reason safe for operator logs.
382    pub reason: &'a dyn std::fmt::Display,
383}
384
385impl ServerError {
386    /// Return stable typed fields for structured error logging.
387    #[must_use]
388    pub fn trace_fields(&self) -> ErrorTraceFields<'_> {
389        match self {
390            Self::Config { message } => ErrorTraceFields {
391                error_type: Cow::Borrowed("Config"),
392                store_error_type: None,
393                reason: message,
394            },
395            Self::UnsafeDataRootAncestor { reason, .. } => ErrorTraceFields {
396                error_type: Cow::Borrowed("UnsafeDataRootAncestor"),
397                store_error_type: None,
398                reason,
399            },
400            Self::TransportBind { message, .. } => ErrorTraceFields {
401                error_type: Cow::Borrowed("TransportBind"),
402                store_error_type: None,
403                reason: message,
404            },
405            Self::Transport { message, .. } => ErrorTraceFields {
406                error_type: Cow::Borrowed("Transport"),
407                store_error_type: None,
408                reason: message,
409            },
410            Self::SignalListener { message, .. } => ErrorTraceFields {
411                error_type: Cow::Borrowed("SignalListener"),
412                store_error_type: None,
413                reason: message,
414            },
415            Self::Namespace { message } => ErrorTraceFields {
416                error_type: Cow::Borrowed("Namespace"),
417                store_error_type: None,
418                reason: message,
419            },
420            Self::EngineCall { source } => engine_trace_fields(source),
421            Self::StoreBackend { source } => store_trace_fields(source),
422            Self::Stream { failure } => ErrorTraceFields {
423                error_type: Cow::Borrowed("Stream"),
424                store_error_type: None,
425                reason: failure,
426            },
427            Self::WorkerDispatch { reason, .. } => ErrorTraceFields {
428                error_type: Cow::Borrowed("WorkerDispatch"),
429                store_error_type: None,
430                reason,
431            },
432            Self::WorkerConnectionLost { detail, .. } => ErrorTraceFields {
433                error_type: Cow::Borrowed("WorkerConnectionLost"),
434                store_error_type: None,
435                reason: detail,
436            },
437            Self::WorkerBusy { detail, .. } => ErrorTraceFields {
438                error_type: Cow::Borrowed("WorkerBusy"),
439                store_error_type: None,
440                reason: detail,
441            },
442            Self::ActivityCompletionRejected { reason, .. } => ErrorTraceFields {
443                error_type: Cow::Borrowed("ActivityCompletionRejected"),
444                store_error_type: None,
445                reason,
446            },
447            Self::LockPoisoned { resource } => ErrorTraceFields {
448                error_type: Cow::Borrowed("LockPoisoned"),
449                store_error_type: None,
450                reason: resource,
451            },
452            Self::Wire { wire } => ErrorTraceFields {
453                error_type: wire
454                    .error_type
455                    .as_deref()
456                    .map_or_else(|| Cow::Borrowed(wire.code.as_str()), Cow::Borrowed),
457                store_error_type: None,
458                reason: wire,
459            },
460        }
461    }
462}
463
464/// The wire type name for each #117(c) never-alive cancellation refusal.
465///
466/// Each keeps its OWN name rather than collapsing into one: an operator told
467/// "not found" for a run whose history they are reading learns nothing, and
468/// telling them "this workflow already has a writer" versus "your package now
469/// loads again" is the difference between a dead end and the next action.
470///
471/// Total without a panic. A variant that should never reach here gets the
472/// family label — an admittedly vague answer, which is the right failure mode
473/// for a function whose only job is to name an error someone is already
474/// receiving.
475fn never_alive_error_type(source: &EngineError) -> &'static str {
476    match source {
477        EngineError::TerminalWriterUnavailable { .. } => "TerminalWriterUnavailable",
478        EngineError::TerminalWriterHeld { .. } => "TerminalWriterHeld",
479        EngineError::RunIsRecoverable { .. } => "RunIsRecoverable",
480        EngineError::NoResidencyVerdict { .. } => "NoResidencyVerdict",
481        _ => "EngineError",
482    }
483}
484
485/// A durability failure that is really a STORE failure keeps the store's own
486/// trace fields; the rest are the engine's.
487///
488/// Extracted from [`engine_trace_fields`] because a nested match is a different
489/// question from the flat dispatch around it, not to satisfy a line count.
490fn durability_trace_fields<'a>(
491    durability: &'a aion::durability::DurabilityError,
492    source: &'a EngineError,
493) -> ErrorTraceFields<'a> {
494    match durability {
495        aion::durability::DurabilityError::Store(store) => store_trace_fields(store),
496        aion::durability::DurabilityError::NonDeterminism(_)
497        | aion::durability::DurabilityError::HistoryShape { .. }
498        | aion::durability::DurabilityError::SearchAttribute(_) => {
499            simple_engine_fields("Durability", source)
500        }
501        // Its own label rather than the generic `Durability`, and the SAME
502        // label the engine-level variant gets below, so an operator searching
503        // traces for "did this engine begin closing" finds every seam that
504        // refused for that reason under one name instead of one name and a
505        // generic one.
506        aion::durability::DurabilityError::EngineTaskEpochClosed { .. } => {
507            simple_engine_fields("EngineTaskEpochClosed", source)
508        }
509    }
510}
511
512fn engine_trace_fields(source: &EngineError) -> ErrorTraceFields<'_> {
513    match source {
514        EngineError::WorkflowNotFound { .. } => simple_engine_fields("WorkflowNotFound", source),
515        // #117(c): the never-alive cancellation path's four refusals.
516        EngineError::TerminalWriterUnavailable { .. }
517        | EngineError::TerminalWriterHeld { .. }
518        | EngineError::RunIsRecoverable { .. }
519        | EngineError::NoResidencyVerdict { .. } => {
520            simple_engine_fields(never_alive_error_type(source), source)
521        }
522        EngineError::InvalidState { .. } => simple_engine_fields("InvalidState", source),
523        EngineError::ScheduleNotFound { .. } => simple_engine_fields("ScheduleNotFound", source),
524        EngineError::ShuttingDown => simple_engine_fields("ShuttingDown", source),
525        EngineError::EngineTaskEpochClosed { .. } => {
526            simple_engine_fields("EngineTaskEpochClosed", source)
527        }
528        EngineError::Store(store) => store_trace_fields(store),
529        EngineError::Durability(durability) => durability_trace_fields(durability, source),
530        EngineError::MissingStore => simple_engine_fields("MissingStore", source),
531        EngineError::MissingVisibilityStore => {
532            simple_engine_fields("MissingVisibilityStore", source)
533        }
534        EngineError::ConflictingEventPublisher => {
535            simple_engine_fields("ConflictingEventPublisher", source)
536        }
537        EngineError::EventStreaming(_) => simple_engine_fields("EventStreaming", source),
538        EngineError::Load { .. } => simple_engine_fields("Load", source),
539        EngineError::UnenforceableContract { .. } => {
540            simple_engine_fields("UnenforceableContract", source)
541        }
542        EngineError::UnknownVersion { .. } => simple_engine_fields("UnknownVersion", source),
543        EngineError::VersionPinned { .. } => simple_engine_fields("VersionPinned", source),
544        EngineError::RouteActive { .. } => simple_engine_fields("RouteActive", source),
545        EngineError::ManifestMismatch { .. } => simple_engine_fields("ManifestMismatch", source),
546        EngineError::Package(_) => simple_engine_fields("Package", source),
547        EngineError::ContractIdentity { .. } => simple_engine_fields("ContractIdentity", source),
548        EngineError::NoQueueDeclaration { .. } => {
549            simple_engine_fields("NoQueueDeclaration", source)
550        }
551        EngineError::StartInputRefused { .. } => simple_engine_fields("StartInputRefused", source),
552        EngineError::SignalRefused { .. } => simple_engine_fields("SignalRefused", source),
553        EngineError::Schedule { .. } => simple_engine_fields("Schedule", source),
554        EngineError::Runtime { .. } => simple_engine_fields("Runtime", source),
555        EngineError::Gate3BifReplacementMissing { .. } => {
556            simple_engine_fields("Gate3BifReplacementMissing", source)
557        }
558        EngineError::CleanupExecutorPoisoned => {
559            simple_engine_fields("CleanupExecutorPoisoned", source)
560        }
561        EngineError::CleanupExecutorShutdownTimedOut { .. } => {
562            simple_engine_fields("CleanupExecutorShutdownTimedOut", source)
563        }
564        EngineError::ProcessExitRegistryPoisoned => {
565            simple_engine_fields("ProcessExitRegistryPoisoned", source)
566        }
567        EngineError::ProcessExitOwnershipPoisoned { .. } => {
568            simple_engine_fields("ProcessExitOwnershipPoisoned", source)
569        }
570        EngineError::ProcessExitStatePoisoned { .. } => {
571            process_exit::trace("ProcessExitStatePoisoned", source)
572        }
573        EngineError::ProcessExitSubscriptionUnavailable => {
574            process_exit::trace("ProcessExitSubscriptionUnavailable", source)
575        }
576        EngineError::ProcessExitDrainerSpawn { .. } => {
577            process_exit::trace("ProcessExitDrainerSpawn", source)
578        }
579        EngineError::ProcessExitDrainerPoisoned => {
580            process_exit::trace("ProcessExitDrainerPoisoned", source)
581        }
582        EngineError::ProcessExitOutcomeMissingAfterEvent { .. } => {
583            process_exit::trace("ProcessExitOutcomeMissingAfterEvent", source)
584        }
585        EngineError::ProcessExitEventStreamDisconnected => {
586            process_exit::trace("ProcessExitEventStreamDisconnected", source)
587        }
588        EngineError::ProcessExitDrainerShutdownTimedOut { .. } => {
589            process_exit::trace("ProcessExitDrainerShutdownTimedOut", source)
590        }
591        EngineError::ProcessExitDrainerPanicked => {
592            process_exit::trace("ProcessExitDrainerPanicked", source)
593        }
594        EngineError::ProcessExitCallbackDispatcherPoisoned
595        | EngineError::ProcessExitCallbackDispatcherUnavailable
596        | EngineError::ProcessExitCallbackDispatcherShutdownTimedOut { .. } => {
597            process_exit::callback_trace(source)
598        }
599        EngineError::ProcessExitAlreadyTerminal { .. } => {
600            simple_engine_fields("ProcessExitAlreadyTerminal", source)
601        }
602        EngineError::ActivityDeliveryPoisoned { .. } => {
603            simple_engine_fields("ActivityDeliveryPoisoned", source)
604        }
605        EngineError::RegistryPoisoned => simple_engine_fields("RegistryPoisoned", source),
606        EngineError::CatalogPoisoned => simple_engine_fields("CatalogPoisoned", source),
607        EngineError::NifRegistration { .. } => simple_engine_fields("NifRegistration", source),
608        EngineError::SignalRouter(_) => simple_engine_fields("SignalRouter", source),
609        EngineError::Query(query) => simple_engine_fields(engine::query_error_type(query), source),
610    }
611}
612
613fn simple_engine_fields<'a>(
614    error_type: &'static str,
615    source: &'a EngineError,
616) -> ErrorTraceFields<'a> {
617    ErrorTraceFields {
618        error_type: Cow::Borrowed(error_type),
619        store_error_type: None,
620        reason: source,
621    }
622}
623
624fn store_trace_fields(source: &StoreError) -> ErrorTraceFields<'_> {
625    ErrorTraceFields {
626        error_type: Cow::Borrowed("StoreError"),
627        store_error_type: Some(engine::store_error_type(source)),
628        reason: source,
629    }
630}
631
632fn wire_from_engine(source: &EngineError) -> WireError {
633    use EngineError as E;
634    use engine::backend_wire as backend;
635
636    match source {
637        EngineError::WorkflowNotFound { .. } => {
638            WireError::not_found_with_type("WorkflowNotFound", source.to_string())
639        }
640        // Reopen preconditions use failed-precondition/HTTP 409, not NotFound.
641        EngineError::InvalidState { reason } => engine::invalid_state_wire(reason),
642        // #117(c). All four are PRECONDITION failures, never NotFound: in every
643        // one of them the run exists and its history is readable, and what has
644        // failed is a condition on cancelling it here and now. Mapping any of
645        // them to 404 would reproduce, one layer out, the exact lie this path
646        // exists to stop telling.
647        E::TerminalWriterUnavailable { .. }
648        | E::TerminalWriterHeld { .. }
649        | E::RunIsRecoverable { .. }
650        | E::NoResidencyVerdict { .. } => engine::invalid_state_wire(&source.to_string())
651            .with_error_type(never_alive_error_type(source)),
652        EngineError::ScheduleNotFound { .. } => {
653            WireError::not_found_with_type("ScheduleNotFound", source.to_string())
654        }
655        EngineError::ShuttingDown => {
656            WireError::not_running_with_type("ShuttingDown", source.to_string())
657        }
658        // The engine-task epoch closed before this run's terminal could be
659        // appended, so this process is no longer the run's single writer. The
660        // work was refused because the engine is going away, not because the
661        // request was wrong. Reachable only from an internal completion path,
662        // never from a request — but a total match is what keeps a new variant
663        // a COMPILE failure here rather than a silent default.
664        //
665        // 🔴 `backend`, NOT `not_running`, and an earlier revision of this arm
666        // had it wrong. The variant's own doc (`crates/aion/src/error.rs:389`)
667        // states the opposite of what `not_running` claims: "in both cases THE
668        // RUN STAYS `Running`", and a startup sweep re-installs a monitor. A
669        // caller told `not_running` is then sent to the CLI hint for that class
670        // (`aion-cli/src/render.rs:157-160`) — "the target run is no longer
671        // running; `aion list --status running` shows runs that can still serve
672        // queries" — which is false about this run twice over, and would have
673        // the operator hunting for a terminal that never landed. `backend`
674        // makes no claim about the run's state at all and carries the variant's
675        // own `Display`, which says exactly what happened; it is also the class
676        // this table already gives every other engine-internal refusal
677        // (`MissingStore`, `ConflictingEventPublisher`, `EventStreaming`).
678        E::EngineTaskEpochClosed { .. } => backend("EngineTaskEpochClosed", source),
679        EngineError::Store(store) => wire_from_store(store),
680        EngineError::Durability(durability) => engine::durability_wire(durability, source),
681        E::MissingStore => backend("MissingStore", source),
682        E::MissingVisibilityStore => backend("MissingVisibilityStore", source),
683        E::ConflictingEventPublisher => backend("ConflictingEventPublisher", source),
684        E::EventStreaming(_) => backend("EventStreaming", source),
685        E::Load { .. } => backend("Load", source),
686        // The archive is well-formed and the defect is in what it DECLARES, so
687        // this is the operator's own input to correct — not a backend fault.
688        EngineError::UnenforceableContract { .. } => {
689            WireError::invalid_input(source.to_string()).with_error_type("UnenforceableContract")
690        }
691        // Deploy refusals preserve not-found, state-conflict, and input classes.
692        EngineError::UnknownVersion { .. } => {
693            WireError::not_found_with_type("UnknownVersion", source.to_string())
694        }
695        EngineError::VersionPinned { .. } => {
696            WireError::version_pinned(source.to_string()).with_error_type("VersionPinned")
697        }
698        EngineError::RouteActive { .. } => {
699            WireError::version_pinned(source.to_string()).with_error_type("RouteActive")
700        }
701        EngineError::ManifestMismatch { .. } => {
702            WireError::invalid_input(source.to_string()).with_error_type("ManifestMismatch")
703        }
704        E::Package(_) => backend("Package", source),
705        E::ContractIdentity { .. } => engine::contract_refusal_wire("ContractIdentity", source),
706        E::NoQueueDeclaration { .. } => engine::contract_refusal_wire("NoQueueDeclaration", source),
707        E::StartInputRefused { .. } => engine::declared_contract_wire("StartInputRefused", source),
708        E::SignalRefused { .. } => engine::declared_contract_wire("SignalRefused", source),
709        EngineError::Schedule { .. } => backend("Schedule", source),
710        E::Runtime { .. } => backend("Runtime", source),
711        E::Gate3BifReplacementMissing { .. } => backend("Gate3BifReplacementMissing", source),
712        E::CleanupExecutorPoisoned => backend("CleanupExecutorPoisoned", source),
713        E::CleanupExecutorShutdownTimedOut { .. } => {
714            backend("CleanupExecutorShutdownTimedOut", source)
715        }
716        E::ProcessExitRegistryPoisoned => backend("ProcessExitRegistryPoisoned", source),
717        E::ProcessExitOwnershipPoisoned { .. } => backend("ProcessExitOwnershipPoisoned", source),
718        EngineError::ProcessExitStatePoisoned { .. } => {
719            process_exit::wire("ProcessExitStatePoisoned", source)
720        }
721        EngineError::ProcessExitSubscriptionUnavailable => {
722            process_exit::wire("ProcessExitSubscriptionUnavailable", source)
723        }
724        EngineError::ProcessExitDrainerSpawn { .. } => {
725            process_exit::wire("ProcessExitDrainerSpawn", source)
726        }
727        EngineError::ProcessExitDrainerPoisoned => {
728            process_exit::wire("ProcessExitDrainerPoisoned", source)
729        }
730        EngineError::ProcessExitOutcomeMissingAfterEvent { .. } => {
731            process_exit::wire("ProcessExitOutcomeMissingAfterEvent", source)
732        }
733        EngineError::ProcessExitEventStreamDisconnected => {
734            process_exit::wire("ProcessExitEventStreamDisconnected", source)
735        }
736        EngineError::ProcessExitDrainerShutdownTimedOut { .. } => {
737            process_exit::wire("ProcessExitDrainerShutdownTimedOut", source)
738        }
739        EngineError::ProcessExitDrainerPanicked => {
740            process_exit::wire("ProcessExitDrainerPanicked", source)
741        }
742        EngineError::ProcessExitCallbackDispatcherPoisoned
743        | EngineError::ProcessExitCallbackDispatcherUnavailable
744        | EngineError::ProcessExitCallbackDispatcherShutdownTimedOut { .. } => {
745            process_exit::callback_wire(source)
746        }
747        E::ProcessExitAlreadyTerminal { .. } => backend("ProcessExitAlreadyTerminal", source),
748        E::ActivityDeliveryPoisoned { .. } => backend("ActivityDeliveryPoisoned", source),
749        E::CatalogPoisoned => backend("CatalogPoisoned", source),
750        E::RegistryPoisoned => backend("RegistryPoisoned", source),
751        E::NifRegistration { .. } => backend("NifRegistration", source),
752        E::SignalRouter(_) => backend("SignalRouter", source),
753        EngineError::Query(query) => engine::query_wire(query, source),
754    }
755}
756
757fn wire_from_store(source: &StoreError) -> WireError {
758    match source {
759        StoreError::SequenceConflict { .. } => WireError::new_with_type(
760            aion_proto::WireErrorCode::SequenceConflict,
761            "SequenceConflict",
762            source.to_string(),
763        ),
764        StoreError::NotFound { .. } => {
765            WireError::not_found_with_type("NotFound", source.to_string())
766        }
767        StoreError::NotOwner { .. } => {
768            WireError::not_owner(source.to_string()).with_error_type("NotOwner")
769        }
770        StoreError::Backend(_) => WireError::backend_with_type("Backend", source.to_string()),
771        StoreError::Serialization(_) => {
772            WireError::backend_with_type("Serialization", source.to_string())
773        }
774    }
775}
776
777#[cfg(test)]
778#[path = "error_tests.rs"]
779mod tests;