aion_client/transport/embedded.rs
1//! Transport backed by an in-process [`aion::Engine`].
2//!
3//! Event subscriptions honour the same resume/replay-splice contract as the
4//! server's `/events/stream` endpoint, built directly on [`aion::Engine`]
5//! seams (`Engine::subscribe` for the live broadcast, `engine.store()` for
6//! the history snapshot — never a client-held stream over engine internals):
7//!
8//! 1. attach the live broadcast subscription FIRST (time T0);
9//! 2. snapshot recorded history via `engine.store().read_history` (T1 > T0);
10//! 3. validate the cursor against the snapshot head;
11//! 4. splice: replay `[resume_from_seq ..= head]` from the snapshot, then the
12//! live tail filtered to `seq > head`.
13//!
14//! Gap-free: publish strictly follows durable commit, so every event with
15//! `seq > head` was committed — and therefore broadcast — after T0.
16//! Duplicate-free: the live filter drops every `seq <= head`, so an event
17//! present in both the snapshot and the broadcast is emitted exactly once,
18//! from the snapshot. Engine-side lag is never silent: each
19//! `Err(EventStreamLagged)` item surfaces as `Err(ClientError::Unavailable)`
20//! so the resume loop reconnects with its cursor.
21
22use std::sync::Arc;
23
24use aion_core::Event;
25use async_trait::async_trait;
26use futures::stream::BoxStream;
27use futures::{StreamExt, stream};
28
29use crate::error::ClientError;
30use crate::transport::contract::{SubscriptionAttempt, WorkflowTransport};
31
32/// Transport backed by an in-process [`aion::Engine`].
33pub struct EmbeddedWorkflowTransport {
34 engine: Arc<aion::Engine>,
35}
36
37impl EmbeddedWorkflowTransport {
38 /// Creates an embedded transport for `engine`.
39 #[must_use]
40 pub fn new(engine: Arc<aion::Engine>) -> Self {
41 Self { engine }
42 }
43
44 /// Resolve the target run id: the supplied one, or the latest run from the
45 /// workflow's run chain when omitted (mirrors the server's `resolve_run_id`).
46 async fn resolve_run_id(
47 &self,
48 workflow_id: &aion_core::WorkflowId,
49 run_id: Option<aion_proto::ProtoRunId>,
50 ) -> Result<aion_core::RunId, ClientError> {
51 if let Some(run_id) = run_id {
52 return run_id.try_into().map_err(ClientError::from_wire_error);
53 }
54 let chain = self
55 .engine
56 .store()
57 .read_run_chain(workflow_id)
58 .await
59 .map_err(|error| store_error_class(&error, error.to_string()))?;
60 chain
61 .last()
62 .map(|summary| summary.run_id.clone())
63 .ok_or_else(|| ClientError::not_found(format!("workflow {workflow_id} not found")))
64 }
65}
66
67#[async_trait]
68impl WorkflowTransport for EmbeddedWorkflowTransport {
69 async fn start_workflow(
70 &self,
71 request: aion_proto::ProtoStartWorkflowRequest,
72 ) -> Result<aion_proto::ProtoStartWorkflowResponse, ClientError> {
73 let input = request
74 .input
75 .ok_or_else(|| ClientError::invalid_argument("start request input payload is missing"))
76 .and_then(|payload| {
77 aion_core::Payload::try_from(payload).map_err(ClientError::from_wire_error)
78 })?;
79 // The embedded engine is single-tenant and in-process: there is no
80 // namespace authority stamping visibility attributes, so the start
81 // carries no search attributes.
82 let handle = self
83 .engine
84 .start_workflow(
85 &request.workflow_type,
86 input,
87 std::collections::HashMap::new(),
88 String::from("default"),
89 )
90 .await
91 .map_err(|error| map_engine_error(&error))?;
92 Ok(aion_proto::ProtoStartWorkflowResponse {
93 workflow_id: Some(aion_proto::ProtoWorkflowId::from(
94 handle.workflow_id().clone(),
95 )),
96 run_id: Some(aion_proto::ProtoRunId::from(handle.run_id().clone())),
97 })
98 }
99
100 async fn signal(
101 &self,
102 request: aion_proto::ProtoSignalRequest,
103 ) -> Result<aion_proto::ProtoSignalResponse, ClientError> {
104 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
105 let run_id = decode_required_run_id(request.run_id)?;
106 let payload = request
107 .payload
108 .ok_or_else(|| ClientError::invalid_argument("signal request payload is missing"))
109 .and_then(|payload| {
110 aion_core::Payload::try_from(payload).map_err(ClientError::from_wire_error)
111 })?;
112 self.engine
113 .signal(&workflow_id, &run_id, request.signal_name, payload)
114 .await
115 .map_err(|error| map_engine_error(&error))?;
116 Ok(aion_proto::ProtoSignalResponse {})
117 }
118
119 async fn query(
120 &self,
121 request: aion_proto::ProtoQueryRequest,
122 ) -> Result<aion_proto::ProtoQueryResponse, ClientError> {
123 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
124 let run_id = decode_required_run_id(request.run_id)?;
125 // Absent arguments mean the caller supplied none; the handler still
126 // receives one well-formed document.
127 let arguments = request.arguments.map_or_else(
128 || Ok(aion_core::Payload::json_null()),
129 |arguments| {
130 aion_core::Payload::try_from(arguments).map_err(ClientError::from_wire_error)
131 },
132 )?;
133 let payload = self
134 .engine
135 .query(&workflow_id, &run_id, request.query_name, arguments)
136 .await
137 .map_err(|error| map_engine_error(&error))?;
138 Ok(aion_proto::ProtoQueryResponse {
139 outcome: Some(aion_proto::proto_query_response::Outcome::Result(
140 aion_proto::ProtoPayload::from(payload),
141 )),
142 })
143 }
144
145 async fn cancel(
146 &self,
147 request: aion_proto::ProtoCancelRequest,
148 ) -> Result<aion_proto::ProtoCancelResponse, ClientError> {
149 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
150 let run_id = decode_required_run_id(request.run_id)?;
151 self.engine
152 .cancel(&workflow_id, &run_id, request.reason)
153 .await
154 .map_err(|error| map_engine_error(&error))?;
155 Ok(aion_proto::ProtoCancelResponse {})
156 }
157
158 async fn reopen(
159 &self,
160 request: aion_proto::ProtoReopenRequest,
161 ) -> Result<aion_proto::ProtoReopenResponse, ClientError> {
162 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
163 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
164 let handle = self
165 .engine
166 .reopen_workflow(&workflow_id, &run_id)
167 .await
168 .map_err(|error| map_engine_error(&error))?;
169 Ok(aion_proto::ProtoReopenResponse {
170 run_id: Some(handle.run_id().clone().into()),
171 status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
172 })
173 }
174
175 async fn pause(
176 &self,
177 request: aion_proto::ProtoPauseRequest,
178 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
179 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
180 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
181 let reason = if request.reason.is_empty() {
182 None
183 } else {
184 Some(request.reason)
185 };
186 let handle = self
187 .engine
188 .pause_workflow(&workflow_id, &run_id, reason, None)
189 .await
190 .map_err(|error| map_engine_error(&error))?;
191 Ok(aion_proto::ProtoPauseResponse {
192 run_id: Some(handle.run_id().clone().into()),
193 status: aion_proto::ProtoWorkflowStatus::Paused as i32,
194 })
195 }
196
197 async fn resume(
198 &self,
199 request: aion_proto::ProtoResumeRequest,
200 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
201 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
202 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
203 let handle = self
204 .engine
205 .resume_paused_workflow(&workflow_id, &run_id, None)
206 .await
207 .map_err(|error| map_engine_error(&error))?;
208 Ok(aion_proto::ProtoResumeResponse {
209 run_id: Some(handle.run_id().clone().into()),
210 status: aion_proto::ProtoWorkflowStatus::Running as i32,
211 })
212 }
213
214 async fn list_workflows(
215 &self,
216 request: aion_proto::ProtoListWorkflowsRequest,
217 ) -> Result<aion_proto::ProtoListWorkflowsResponse, ClientError> {
218 let filter = match request.filter.as_ref() {
219 Some(filter) => {
220 aion_proto::decode_workflow_filter(filter).map_err(ClientError::from_wire_error)?
221 }
222 None => aion_core::WorkflowFilter::default(),
223 };
224 let summaries = self
225 .engine
226 .list_workflows(filter)
227 .await
228 .map_err(|error| map_engine_error(&error))?
229 .iter()
230 .map(|summary| {
231 aion_proto::encode_workflow_summary(request.namespace.clone(), None, summary)
232 })
233 .map(|result| result.map_err(ClientError::from_wire_error))
234 .collect::<Result<Vec<_>, _>>()?;
235 Ok(aion_proto::ProtoListWorkflowsResponse { summaries })
236 }
237
238 async fn describe_workflow(
239 &self,
240 request: aion_proto::ProtoDescribeWorkflowRequest,
241 ) -> Result<aion_proto::ProtoDescribeWorkflowResponse, ClientError> {
242 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
243 let history = self
244 .engine
245 .store()
246 .read_history(&workflow_id)
247 .await
248 // This read goes straight to the store, so the refusal arrives with
249 // no `EngineError` around it — but it is the same refusal, and a
250 // `NotOwner` here means exactly what it means everywhere else: ask a
251 // different owner. Collapsing it into `server` told the caller its
252 // request was unanswerable when a re-route would have served it.
253 .map_err(|error| store_error_class(&error, error.to_string()))?;
254 let Some(summary) = aion_core::WorkflowSummary::from_history(&history) else {
255 return Err(ClientError::not_found(format!(
256 "workflow {workflow_id} has no recorded history"
257 )));
258 };
259 let summary = Some(
260 aion_proto::encode_workflow_summary(request.namespace.clone(), None, &summary)
261 .map_err(ClientError::from_wire_error)?,
262 );
263 let history = if request.include_history {
264 history
265 .iter()
266 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
267 .map(|result| result.map_err(ClientError::from_wire_error))
268 .collect::<Result<Vec<_>, _>>()?
269 } else {
270 Vec::new()
271 };
272 Ok(aion_proto::ProtoDescribeWorkflowResponse { summary, history })
273 }
274
275 async fn subscribe(
276 &self,
277 request: aion_proto::SubscriptionRequest,
278 resume_from_sequence: Option<u64>,
279 ) -> Result<SubscriptionAttempt, ClientError> {
280 let (workflow_target, filter) = embedded_subscription_target(request)?;
281 // T0: attach to the live broadcast BEFORE any history snapshot — one
282 // half of the gap-free splice proof (mirrors the server's
283 // subscribe-then-snapshot ordering).
284 let live = self.engine.subscribe(filter);
285 let events = match (&workflow_target, resume_from_sequence) {
286 (Some(workflow_id), Some(resume_from_seq)) => {
287 // T1 (> T0): snapshot recorded history, then validate the
288 // cursor against its head and build the dedupe splice.
289 let history = self
290 .engine
291 .store()
292 .read_history(workflow_id)
293 .await
294 .map_err(|error| store_error_class(&error, error.to_string()))?;
295 splice_resume(live, history, resume_from_seq)?
296 }
297 (None, Some(_)) => {
298 return Err(ClientError::invalid_argument(
299 "filtered and firehose event streams are live-only by design; resume \
300 cursors are valid for per-workflow subscriptions only",
301 ));
302 }
303 (_, None) => map_lag(live),
304 };
305 // Per-workflow streams end at the run's terminal event, exactly like
306 // the server socket; callers walk continue-as-new chains by
307 // resubscribing with their cursor.
308 Ok(SubscriptionAttempt::new(match workflow_target {
309 Some(_) => close_after_terminal(events),
310 None => events,
311 }))
312 }
313}
314
315/// Validates a resume cursor against a history snapshot and builds the
316/// replay/live splice (see the module docs for the gap/duplicate proof).
317fn splice_resume(
318 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
319 history: Vec<Event>,
320 resume_from_seq: u64,
321) -> Result<BoxStream<'static, Result<Event, ClientError>>, ClientError> {
322 if resume_from_seq == 0 {
323 return Err(ClientError::invalid_argument(
324 "resume_from_seq must be >= 1 (the first sequence number wanted)",
325 ));
326 }
327 let head = history.last().map_or(0, Event::seq);
328 if resume_from_seq > head.saturating_add(1) {
329 return Err(ClientError::invalid_argument(format!(
330 "resume_from_seq {resume_from_seq} is ahead of recorded history (head seq {head}); \
331 the largest valid cursor is {}",
332 head.saturating_add(1)
333 )));
334 }
335
336 let mut history = history;
337 let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
338 let replay = history.split_off(replay_start);
339 let tail = live.filter(move |item| {
340 let keep = match item {
341 Ok(event) => event.seq() > head,
342 // Lag is information, never filtered away.
343 Err(aion::EventStreamLagged { .. }) => true,
344 };
345 futures::future::ready(keep)
346 });
347
348 Ok(stream::iter(replay.into_iter().map(Ok))
349 .chain(map_lag(tail.boxed()))
350 .boxed())
351}
352
353/// Maps engine-side lag items to retryable [`ClientError::Unavailable`] so
354/// the resume loop reconnects with its cursor instead of silently gapping.
355fn map_lag(
356 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
357) -> BoxStream<'static, Result<Event, ClientError>> {
358 live.map(|item| {
359 item.map_err(|lagged| {
360 ClientError::from_wire_error(aion_proto::WireError::lagged(lagged.to_string()))
361 })
362 })
363 .boxed()
364}
365
366/// Ends the stream after the first terminal workflow event, mirroring the
367/// server socket's per-workflow run-boundary close.
368fn close_after_terminal(
369 events: BoxStream<'static, Result<Event, ClientError>>,
370) -> BoxStream<'static, Result<Event, ClientError>> {
371 stream::unfold(Some(events), |state| async move {
372 let mut events = state?;
373 let item = events.next().await?;
374 // The terminal event is delivered and the inner stream is dropped
375 // immediately afterwards (releasing the broadcast receiver), so the
376 // close is eager — it never waits for a further event to be polled.
377 let closed = matches!(&item, Ok(event) if is_terminal_workflow_event(event));
378 Some((item, if closed { None } else { Some(events) }))
379 })
380 .boxed()
381}
382
383fn is_terminal_workflow_event(event: &Event) -> bool {
384 matches!(
385 event,
386 Event::WorkflowCompleted { .. }
387 | Event::WorkflowFailed { .. }
388 | Event::WorkflowCancelled { .. }
389 | Event::WorkflowTimedOut { .. }
390 | Event::WorkflowContinuedAsNew { .. }
391 )
392}
393
394fn decode_required_workflow_id(
395 value: Option<aion_proto::ProtoWorkflowId>,
396) -> Result<aion_core::WorkflowId, ClientError> {
397 value
398 .ok_or_else(|| ClientError::invalid_argument("request workflow id is missing"))?
399 .try_into()
400 .map_err(ClientError::from_wire_error)
401}
402
403fn decode_required_run_id(
404 value: Option<aion_proto::ProtoRunId>,
405) -> Result<aion_core::RunId, ClientError> {
406 value
407 .ok_or_else(|| ClientError::invalid_argument("request run id is missing"))?
408 .try_into()
409 .map_err(ClientError::from_wire_error)
410}
411
412/// Maps a wire subscription request onto the engine filter surface plus the
413/// per-workflow target the splice and run-boundary close key on.
414fn embedded_subscription_target(
415 request: aion_proto::SubscriptionRequest,
416) -> Result<(Option<aion_core::WorkflowId>, aion::EventFilter), ClientError> {
417 match request.subscription {
418 Some(aion_proto::subscription_request::Subscription::PerWorkflow(subscription)) => {
419 let workflow_id = subscription
420 .workflow_id
421 .ok_or_else(|| {
422 ClientError::invalid_argument(
423 "per-workflow subscription requires a workflow id",
424 )
425 })?
426 .try_into()
427 .map_err(ClientError::from_wire_error)?;
428 Ok((
429 Some(aion_core::WorkflowId::clone(&workflow_id)),
430 aion::EventFilter {
431 workflow_id: Some(workflow_id),
432 run: None,
433 family: None,
434 },
435 ))
436 }
437 Some(
438 aion_proto::subscription_request::Subscription::Filtered(_)
439 | aion_proto::subscription_request::Subscription::Firehose(_),
440 ) => Ok((None, aion::EventFilter::default())),
441 Some(aion_proto::subscription_request::Subscription::Cluster(_)) => {
442 // The WS3 cluster topology/ownership channel is a server-side
443 // projection of distributed cluster state. The embedded in-process
444 // transport drives a single local engine with no cluster topology to
445 // project, so a cluster subscription is not serviceable here; reject it
446 // cleanly rather than silently degrading to a workflow event stream.
447 Err(ClientError::invalid_argument(
448 "cluster topology subscriptions are not supported by the embedded in-process \
449 transport; connect to an aion-server over gRPC/WebSocket to subscribe to the \
450 cluster channel",
451 ))
452 }
453 Some(aion_proto::subscription_request::Subscription::Transcript(_)) => {
454 // The NOI-5b agent-observability transcript channel is a server-side
455 // projection over the durable `O` keyspace + the server's transcript
456 // sequencer. The embedded in-process transport has no such server
457 // bridge, so a transcript subscription is not serviceable here; reject
458 // it cleanly rather than degrading to a workflow event stream.
459 Err(ClientError::invalid_argument(
460 "agent-observability transcript subscriptions are not supported by the embedded \
461 in-process transport; connect to an aion-server over gRPC/WebSocket to subscribe \
462 to the transcript channel",
463 ))
464 }
465 None => Err(ClientError::invalid_argument(
466 "subscription request is missing its subscription variant",
467 )),
468 }
469}
470
471/// Translate an engine failure into the client-facing error class.
472///
473/// # The `_` arm is a family default, and it is not free
474///
475/// `EngineError` has 51 variants and is not `#[non_exhaustive]`, so an
476/// exhaustive match here *would* compile-fail on every new variant. That is
477/// deliberately not what this does: a 51-arm list in a transport adapter is a
478/// list nothing reads, and it would go stale as silently as the wildcard does.
479/// The wildcard is chosen, with its cost stated — **a new variant lands in the
480/// generic server bucket and the compiler will not say so.**
481///
482/// What makes that acceptable is the direction it fails in. `server` is the
483/// "something went wrong, this is not your request's fault and not a wait"
484/// class; landing there is uninformative, never *wrong* in a way that makes a
485/// caller retry something unretryable or give up on something transient. Every
486/// arm above it exists because its variant would be actively mis-served by that
487/// default — a not-found retried forever, a shutdown reported as an engine bug.
488///
489/// So the rule for adding an arm is not "is this variant new" but "would the
490/// generic bucket mislead a caller about what to DO". Only those get named.
491fn map_engine_error(error: &aion::EngineError) -> ClientError {
492 match error {
493 aion::EngineError::WorkflowNotFound { .. } => ClientError::not_found(error.to_string()),
494 // Reopen precondition failure (AD-012): distinct typed variant, never
495 // conflated with not-found or the generic server bucket.
496 aion::EngineError::InvalidState { .. } => ClientError::invalid_state(error.to_string()),
497 // The caller's own payload did not satisfy the contract the target
498 // package declares. Both are raised BEFORE anything is recorded, so
499 // there is nothing to clean up and nothing to retry — the remedy is to
500 // send different bytes. `server` would say the opposite: "not your
501 // fault, try again", which is how a caller ends up retrying a payload
502 // that can never be accepted. `aion-server` classifies these two
503 // through `declared_contract_wire` for the same reason; this is the
504 // embedded surface reaching the same verdict, not a second opinion.
505 aion::EngineError::StartInputRefused { .. } | aion::EngineError::SignalRefused { .. } => {
506 ClientError::invalid_argument(error.to_string())
507 }
508 // Six refusals about the state of a run, or of the package a run would
509 // start from, rather than the health of the engine — all reachable
510 // through the embedded surface, all classified `invalid_state` by
511 // `aion-server`, and all erased into "engine bug" by the generic
512 // bucket, which is the wrong thing to say about any of them.
513 //
514 // Two are transient by construction (`TerminalWriterUnavailable`,
515 // `TerminalWriterHeld`): a writer reservation lives across one terminal
516 // transition. Two name a precondition the caller can act on: redeploy
517 // and cancel through the ordinary path (`RunIsRecoverable`), or address
518 // this engine's missing startup verdict (`NoResidencyVerdict`). The
519 // last two are admission refusals raised before the run starts — the
520 // package's declared contract does not identify what the request names
521 // (`ContractIdentity`), or it declares no task queue to serve from
522 // (`NoQueueDeclaration`) — and the remedy for both is a redeploy.
523 aion::EngineError::TerminalWriterUnavailable { .. }
524 | aion::EngineError::TerminalWriterHeld { .. }
525 | aion::EngineError::RunIsRecoverable { .. }
526 | aion::EngineError::NoResidencyVerdict { .. }
527 | aion::EngineError::ContractIdentity { .. }
528 | aion::EngineError::NoQueueDeclaration { .. } => {
529 ClientError::invalid_state(error.to_string())
530 }
531 // 🔴 BOTH SHAPES A STORE REFUSAL ARRIVES IN, CLASSIFIED IN ONE PLACE.
532 //
533 // An earlier revision matched only the bare `Store(..)` shape and its
534 // test hand-built that shape, so the arm looked covered and fired on
535 // nothing a caller could provoke: every durable write these operations
536 // trigger goes through the `Recorder`, which returns `DurabilityError`,
537 // and the engine wraps that as `EngineError::Durability` — so the
538 // production shape of a lost-ownership refusal is
539 // `Durability(Store(NotOwner))`. Both shapes now route through
540 // `store_error_class`, which is the only place the rule is written.
541 aion::EngineError::Store(store)
542 | aion::EngineError::Durability(aion::durability::DurabilityError::Store(store)) => {
543 store_error_class(store, error.to_string())
544 }
545 // Live-query dispatch. `aion-server` gives this family five distinct
546 // wire codes and the caller taxonomy has a class for each; collapsing
547 // them into `server` left `unknown_query`, `query_timeout`,
548 // `query_failed` and the query half of `not_running` unreachable
549 // in-process — four classes that exist solely for this operation. The
550 // same caller code branches correctly over the wire and could not
551 // branch at all here.
552 aion::EngineError::Query(query) => query_error_class(query, error),
553 // This engine has stopped serving. `aion-server`'s `wire_from_engine`
554 // (`error.rs:645`) answers `not_running`, and an over-the-wire caller of
555 // these operations therefore sees `ClientError::NotRunning`; an earlier
556 // revision of this arm said `unavailable`, which is the transport-level
557 // class the SDK also raises for a dial failure. That told an in-process
558 // caller its ENDPOINT was unreachable when what had happened was that
559 // the engine it holds is shutting down.
560 //
561 // ⚠️ "Mirror the server" is not one answer — the server gives two, and
562 // saying otherwise would hide a real divergence. `wire_from_engine` is
563 // the mapping that governs the eight engine operations THIS transport
564 // exposes, and it is the one mirrored here. Two control-plane handlers
565 // answer `Unavailable` for the same variant —
566 // `aion-server/src/authoring/handlers.rs:404-406` and
567 // `api/handlers/deploy.rs:415` — and both serve deploy/authoring
568 // operations that this transport does not expose, so neither is
569 // reachable through a caller holding an `EmbeddedWorkflowTransport`.
570 // Recorded rather than reconciled: a shared classifier is what would
571 // actually force the two into agreement, and that needs a crate both
572 // sides can depend on.
573 //
574 // `EngineTaskEpochClosed` deliberately has no arm. It is constructed at
575 // exactly one site — the terminal-append boundary in the engine's
576 // process-exit path — which is reached by the completion monitor, never
577 // by any of the operations this transport exposes. An arm for it would
578 // be a classification nobody can observe, and a wrong claim about what
579 // this surface can return.
580 // 🔴 CARRY THE DISCRIMINATOR, because the class alone is a false hint.
581 // `not_running` is the right CLASS and mirrors `wire_from_engine` — but
582 // the operator-facing hint for a bare `not_running` says the run is no
583 // longer running and points at `aion list --status running`, which is
584 // false twice here: the run is fine, it is the ENGINE that stopped
585 // serving, and `aion list` would fail identically. That is the same
586 // defect this lane's F4 fix cited when it moved `EngineTaskEpochClosed`
587 // OFF `not_running`; leaving the discriminator behind would have this
588 // lane arguing both sides of one rule.
589 //
590 // The wire surface already carries it — `wire_from_engine` builds
591 // `not_running_with_type("ShuttingDown", …)` — so attaching it here
592 // makes the embedded and wire surfaces agree rather than diverge, and
593 // gives `render.rs` the one fact it needs to say something true.
594 aion::EngineError::ShuttingDown => ClientError::NotRunning {
595 detail: crate::ErrorDetail::with_type(error.to_string(), "ShuttingDown"),
596 },
597 _ => ClientError::server(error.to_string()),
598 }
599}
600
601/// The class a store-layer refusal carries, whichever shape it reached
602/// [`map_engine_error`] in.
603///
604/// Mirrors `aion-server`'s `wire_from_store` composed with
605/// [`ClientError::from_wire_error`], so the same underlying refusal names the
606/// same class whether the caller is in-process or across the wire.
607///
608/// `not_owner` is the one class that names a ROUTING failure — the endpoint
609/// answered, it simply does not own this target's shard, so the remedy is a
610/// DIFFERENT owner rather than a retry against this one. Collapsing it into
611/// `server` costs the caller that distinction entirely: `server` says the
612/// request is unanswerable, and a caller that believes it abandons work a
613/// re-route would have served.
614///
615/// ⚠️ Nothing in this crate retries it AUTOMATICALLY, and an earlier revision of
616/// this comment claimed otherwise. `aion-client`'s only retry classifier is
617/// `stream.rs`'s `is_retryable`, which is `matches!(error, ClientError::
618/// Unavailable { .. })` — `NotOwner` is not in it, so `ResumingEventStream`
619/// terminates the stream on one. The distinction this arm preserves is
620/// therefore one the CALLER acts on, and the surface that already does is the
621/// CLI's operator hint (`aion-cli/src/render.rs:175-178`), which tells the
622/// operator to retry or point `--endpoint` at another node instead of sending
623/// them hunting a network fault that does not exist.
624///
625/// Takes the caller-facing `message` separately rather than deriving it, because
626/// a store refusal reaches this transport in three shapes and only two of them
627/// have an `EngineError` to render: wrapped as `EngineError::Store`, wrapped as
628/// `EngineError::Durability(Store(..))`, and — at the three call sites that read
629/// the store directly rather than through an engine API — as a bare
630/// [`aion_store::StoreError`] with no wrapper at all. The rule is written once
631/// and all three shapes are held to it.
632fn store_error_class(error: &aion_store::StoreError, message: String) -> ClientError {
633 match error {
634 aion_store::StoreError::NotOwner { .. } => ClientError::not_owner(message),
635 aion_store::StoreError::NotFound { .. } => ClientError::not_found(message),
636 // A `SequenceConflict` is this codebase's single-writer invariant
637 // violation — a double-writer bug, not a caller mistake and not an
638 // idempotency conflict — and `Backend`/`Serialization` are
639 // infrastructure faults. All three are the engine's to answer for, and
640 // `from_wire_error` puts all three in the generic bucket for a caller
641 // arriving over the wire.
642 aion_store::StoreError::SequenceConflict { .. }
643 | aion_store::StoreError::Backend(_)
644 | aion_store::StoreError::Serialization(_) => ClientError::server(message),
645 }
646}
647
648/// The class a live-query dispatch failure carries.
649///
650/// Mirrors `aion-server`'s `query_wire` composed with
651/// [`ClientError::from_wire_error`]. Every arm is a distinct remedy: name a
652/// query the workflow declares, wait or retry, address a run that is not
653/// answering, look up a workflow that does not exist, or fix the handler.
654fn query_error_class(error: &aion::QueryError, source: &aion::EngineError) -> ClientError {
655 match error {
656 aion::QueryError::UnknownQuery(_) => ClientError::unknown_query(source.to_string()),
657 aion::QueryError::Timeout => ClientError::query_timeout(source.to_string()),
658 // A run that cannot answer and a reply channel that closed are the same
659 // fact to a caller: the workflow is not there to answer right now.
660 aion::QueryError::NotRunning(_) | aion::QueryError::ReplyDropped => {
661 ClientError::not_running(source.to_string())
662 }
663 aion::QueryError::Unknown(_) => ClientError::not_found(source.to_string()),
664 // The handler ran and reported an application-level failure — the
665 // workflow author's to fix, and distinct from the engine failing to
666 // deliver the query at all.
667 aion::QueryError::HandlerFailed { .. } => ClientError::query_failed(source.to_string()),
668 // The caller sent arguments the engine cannot carry to a handler: the
669 // caller's request to fix, never the workflow's or the server's.
670 aion::QueryError::InvalidArguments { .. } => {
671 ClientError::invalid_argument(source.to_string())
672 }
673 aion::QueryError::Engine(_) => ClientError::server(source.to_string()),
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use std::num::NonZeroUsize;
680 use std::time::Duration;
681
682 use aion::EventStreamLagged;
683 use aion_core::{Event, EventEnvelope, Payload, RunId, WorkflowId};
684 use chrono::Utc;
685 use futures::{StreamExt, stream};
686
687 use super::{close_after_terminal, map_lag, splice_resume};
688 use crate::error::ClientError;
689
690 fn workflow_id() -> WorkflowId {
691 WorkflowId::new(uuid::Uuid::from_u128(1))
692 }
693
694 fn envelope(seq: u64) -> EventEnvelope {
695 EventEnvelope {
696 seq,
697 recorded_at: Utc::now(),
698 workflow_id: workflow_id(),
699 }
700 }
701
702 fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
703 Ok(Event::SignalReceived {
704 envelope: envelope(seq),
705 name: format!("signal-{seq}"),
706 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
707 })
708 }
709
710 fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
711 Ok(Event::WorkflowCompleted {
712 envelope: envelope(seq),
713 result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
714 })
715 }
716
717 fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
718 seqs.map(signal).collect()
719 }
720
721 fn live(
722 items: Vec<Result<Event, EventStreamLagged>>,
723 ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
724 stream::iter(items).boxed()
725 }
726
727 async fn delivered_seqs(
728 events: futures::stream::BoxStream<'static, Result<Event, ClientError>>,
729 ) -> Result<Vec<u64>, ClientError> {
730 events
731 .map(|item| item.map(|event| event.seq()))
732 .collect::<Vec<_>>()
733 .await
734 .into_iter()
735 .collect()
736 }
737
738 #[tokio::test]
739 async fn cursor_zero_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
740 let error = splice_resume(live(Vec::new()), history(1..=3)?, 0).err();
741
742 let Some(ClientError::InvalidArgument { detail }) = error else {
743 return Err(format!("cursor 0 must be InvalidArgument, got {error:?}").into());
744 };
745 assert!(detail.message.contains(">= 1"), "detail: {detail}");
746 Ok(())
747 }
748
749 #[tokio::test]
750 async fn cursor_ahead_of_history_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>>
751 {
752 let error = splice_resume(live(Vec::new()), history(1..=5)?, 7).err();
753
754 let Some(ClientError::InvalidArgument { detail }) = error else {
755 return Err(format!("cursor head+2 must be InvalidArgument, got {error:?}").into());
756 };
757 assert!(
758 detail.message.contains("ahead of recorded history"),
759 "{detail}"
760 );
761
762 let empty = splice_resume(live(Vec::new()), Vec::new(), 2).err();
763 assert!(
764 matches!(empty, Some(ClientError::InvalidArgument { .. })),
765 "cursor 2 over empty history must be rejected, got {empty:?}"
766 );
767 Ok(())
768 }
769
770 #[tokio::test]
771 async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
772 -> Result<(), Box<dyn std::error::Error>> {
773 // Snapshot holds 1..=5; the live broadcast re-emits 4 and 5 (arrived
774 // between attach and snapshot) before the genuinely new 6.
775 let events = splice_resume(
776 live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
777 history(1..=5)?,
778 1,
779 )?;
780
781 assert_eq!(delivered_seqs(events).await?, vec![1, 2, 3, 4, 5, 6]);
782 Ok(())
783 }
784
785 #[tokio::test]
786 async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
787 let events = splice_resume(live(vec![Ok(signal(6)?)]), history(1..=5)?, 3)?;
788
789 assert_eq!(delivered_seqs(events).await?, vec![3, 4, 5, 6]);
790 Ok(())
791 }
792
793 #[tokio::test]
794 async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
795 -> Result<(), Box<dyn std::error::Error>> {
796 let events = splice_resume(
797 live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
798 history(1..=5)?,
799 6,
800 )?;
801
802 assert_eq!(delivered_seqs(events).await?, vec![6, 7]);
803 Ok(())
804 }
805
806 #[tokio::test]
807 async fn lag_mid_splice_surfaces_unavailable_after_the_replay()
808 -> Result<(), Box<dyn std::error::Error>> {
809 let events = splice_resume(
810 live(vec![Err(EventStreamLagged { skipped: 3 })]),
811 history(1..=2)?,
812 1,
813 )?;
814 let collected: Vec<_> = events.collect().await;
815
816 assert_eq!(collected.len(), 3, "two replay events then the lag item");
817 assert!(collected[0].is_ok() && collected[1].is_ok());
818 assert!(
819 matches!(
820 collected[2].as_ref().err(),
821 Some(ClientError::Unavailable { .. })
822 ),
823 "lag must surface as retryable Unavailable, never a silent gap, got {:?}",
824 collected[2]
825 );
826 Ok(())
827 }
828
829 #[tokio::test]
830 async fn per_workflow_stream_closes_after_terminal_event()
831 -> Result<(), Box<dyn std::error::Error>> {
832 // Terminal at seq 3 mid-replay: deliver 1..=3 and close without
833 // draining the live tail (continue-as-new/terminal run boundary).
834 let mut history = history(1..=2)?;
835 history.push(completed(3)?);
836 history.push(signal(4)?);
837 let events = splice_resume(live(vec![Ok(signal(5)?)]), history, 1)?;
838
839 assert_eq!(
840 delivered_seqs(close_after_terminal(events)).await?,
841 vec![1, 2, 3],
842 "the stream must close after the terminal event"
843 );
844 Ok(())
845 }
846
847 #[tokio::test]
848 async fn live_lag_maps_to_unavailable() -> Result<(), Box<dyn std::error::Error>> {
849 let events = map_lag(live(vec![
850 Ok(signal(1)?),
851 Err(EventStreamLagged { skipped: 9 }),
852 ]));
853 let collected: Vec<_> = events.collect().await;
854
855 assert_eq!(collected.len(), 2);
856 assert!(
857 matches!(
858 collected[1].as_ref().err(),
859 Some(ClientError::Unavailable { .. })
860 ),
861 "got {:?}",
862 collected[1]
863 );
864 Ok(())
865 }
866
867 /// End-to-end through a real engine: the embedded resume splice delivers
868 /// recorded history and live appends gap-free and duplicate-free, built
869 /// on `Engine::subscribe` + `engine.store()` (the pin-note seams).
870 #[tokio::test]
871 async fn embedded_resume_splices_recorded_history_with_live_appends()
872 -> Result<(), Box<dyn std::error::Error>> {
873 use crate::stream::SubscribeTarget;
874 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
875
876 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
877 let engine = std::sync::Arc::new(
878 aion::EngineBuilder::new()
879 .store(aion_store::InMemoryStore::default())
880 .in_memory_visibility()
881 .event_streaming(capacity)
882 .build()
883 .await?,
884 );
885 let workflow_id = WorkflowId::new_v4();
886 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
887 recorder
888 .record_workflow_started(
889 Utc::now(),
890 aion::durability::WorkflowStartRecord {
891 workflow_type: String::from("checkout"),
892 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
893 run_id: RunId::new(uuid::Uuid::from_u128(7)),
894 parent_run_id: None,
895 package_version: aion_core::PackageVersion::new("a".repeat(64)),
896 },
897 )
898 .await?;
899 for seq in 2..=3 {
900 recorder
901 .record_signal_received(
902 Utc::now(),
903 format!("signal-{seq}"),
904 Payload::from_json(&serde_json::json!({ "seq": seq }))?,
905 )
906 .await?;
907 }
908
909 // Resume from seq 2: replay [2, 3] from the snapshot, then splice the
910 // live append (4) with no gaps and no duplicates.
911 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
912 let request = SubscribeTarget::Workflow {
913 workflow_id: workflow_id.clone(),
914 }
915 .request("default");
916 let attempt = transport.subscribe(request, Some(2)).await?;
917 let mut events = attempt.events;
918
919 let mut delivered = Vec::new();
920 for _ in 0..2 {
921 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
922 .await
923 .map_err(|_| "timed out waiting for a replay event")?
924 .ok_or("stream ended before the replay completed")?;
925 delivered.push(item?.seq());
926 }
927 recorder
928 .record_workflow_completed(
929 Utc::now(),
930 Payload::from_json(&serde_json::json!({ "done": true }))?,
931 )
932 .await?;
933 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
934 .await
935 .map_err(|_| "timed out waiting for the live spliced event")?
936 .ok_or("stream ended before the live event arrived")?;
937 delivered.push(item?.seq());
938 assert_eq!(delivered, vec![2, 3, 4]);
939
940 // Seq 4 is terminal: the per-workflow stream must now close.
941 let end = tokio::time::timeout(Duration::from_secs(2), events.next())
942 .await
943 .map_err(|_| "timed out waiting for the post-terminal close")?;
944 assert!(
945 end.is_none(),
946 "per-workflow stream must close after the terminal event, got {end:?}"
947 );
948
949 // A cursor beyond head + 1 is rejected against the same engine.
950 let ahead = transport
951 .subscribe(
952 SubscribeTarget::Workflow { workflow_id }.request("default"),
953 Some(9),
954 )
955 .await
956 .err();
957 assert!(
958 matches!(ahead, Some(ClientError::InvalidArgument { .. })),
959 "cursor ahead of history must be InvalidArgument, got {ahead:?}"
960 );
961
962 engine.shutdown()?;
963 Ok(())
964 }
965
966 /// 🔴 THE THREE CALL SITES THAT READ THE STORE DIRECTLY CARRY A ROUTABLE
967 /// REFUSAL THROUGH — THEY DO NOT FLATTEN IT INTO "ENGINE BUG".
968 ///
969 /// [`map_engine_error`] is only reached by the eight operations that go
970 /// through an engine API. Three do not: `resolve_run_id` reads the run
971 /// chain, and `describe_workflow` and the resuming half of `subscribe` read
972 /// history, all straight off `engine.store()`. Each of those refusals
973 /// arrives as a bare [`aion_store::StoreError`] with no `EngineError` around
974 /// it, and each was mapped by hand to `ClientError::server`.
975 ///
976 /// `not_owner` is the one store class that names a ROUTING failure — try a
977 /// different owner, never this one. It is a distinction the CALLER acts on,
978 /// not one this crate retries for it: `stream.rs`'s `is_retryable` matches
979 /// only `ClientError::Unavailable`. Flattened to `server` the caller is told
980 /// its request is unanswerable and gives up on work a re-route would have
981 /// served, and no test could see the difference:
982 /// [`aion_store::InMemoryStore`] owns every shard and so can never produce
983 /// `NotOwner` at all. [`FencedHistoryStore`] is the instrument that can.
984 ///
985 /// The unarmed pass is the control. Without it an armed `not_owner` would be
986 /// consistent with a fixture that never worked — the wrong workflow id, an
987 /// engine that never built — and the test would be measuring its own setup.
988 #[tokio::test]
989 async fn a_directly_read_store_refusal_keeps_its_routing_class()
990 -> Result<(), Box<dyn std::error::Error>> {
991 use aion_store::testing::FencedHistoryStore;
992
993 use crate::stream::SubscribeTarget;
994 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
995
996 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
997 let store = std::sync::Arc::new(FencedHistoryStore::new());
998 let engine = std::sync::Arc::new(
999 aion::EngineBuilder::new()
1000 .store_arc(
1001 std::sync::Arc::clone(&store) as std::sync::Arc<dyn aion_store::EventStore>
1002 )
1003 .in_memory_visibility()
1004 .event_streaming(capacity)
1005 .build()
1006 .await?,
1007 );
1008 let workflow_id = WorkflowId::new_v4();
1009 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
1010 recorder
1011 .record_workflow_started(
1012 Utc::now(),
1013 aion::durability::WorkflowStartRecord {
1014 workflow_type: String::from("checkout"),
1015 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
1016 run_id: RunId::new(uuid::Uuid::from_u128(11)),
1017 parent_run_id: None,
1018 package_version: aion_core::PackageVersion::new("b".repeat(64)),
1019 },
1020 )
1021 .await?;
1022
1023 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
1024 let describe = |include_history: bool| aion_proto::ProtoDescribeWorkflowRequest {
1025 namespace: String::from("default"),
1026 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1027 run_id: None,
1028 include_history,
1029 };
1030
1031 // CONTROL, one per operation, so an armed `not_owner` below is
1032 // attributable to the fence rather than to a fixture that never reached
1033 // the read at all.
1034 //
1035 // 🔴 An earlier revision of this comment claimed "with the fence
1036 // disarmed every one of these succeeds" and ran a control for
1037 // `describe_workflow` only. That claim was not merely unproven, it was
1038 // impossible: this fixture records `WorkflowStarted` and nothing else,
1039 // so the run is NON-TERMINAL and `reopen_workflow` answers the AD-012
1040 // `invalid_state` this file names at `:486-487`. `reopen`'s control is
1041 // therefore the sharper one available — disarmed it must fail with some
1042 // class OTHER than `not_owner`, which still separates "the fence did
1043 // it" from "this call always fails".
1044 transport.describe_workflow(describe(true)).await?;
1045 transport
1046 .subscribe(
1047 SubscribeTarget::Workflow {
1048 workflow_id: workflow_id.clone(),
1049 }
1050 .request("default"),
1051 Some(1),
1052 )
1053 .await?;
1054 let reopen_request = || aion_proto::ProtoReopenRequest {
1055 namespace: String::from("default"),
1056 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1057 run_id: None,
1058 };
1059 let reopen_control = transport.reopen(reopen_request()).await;
1060 assert_ne!(
1061 reopen_control.as_ref().err().map(ClientError::class),
1062 Some("not_owner"),
1063 "the reopen control answered `not_owner` with the fence DISARMED, so the armed \
1064 assertion below would prove nothing: {reopen_control:?}"
1065 );
1066
1067 store.arm_fence();
1068
1069 let described = transport.describe_workflow(describe(false)).await;
1070 assert_eq!(
1071 described.as_ref().err().map(ClientError::class),
1072 Some("not_owner"),
1073 "describe_workflow flattened a shard-ownership refusal into an unroutable class: \
1074 {described:?}"
1075 );
1076
1077 let subscribed = transport
1078 .subscribe(
1079 SubscribeTarget::Workflow {
1080 workflow_id: workflow_id.clone(),
1081 }
1082 .request("default"),
1083 Some(1),
1084 )
1085 .await;
1086 assert_eq!(
1087 subscribed.as_ref().err().map(ClientError::class),
1088 Some("not_owner"),
1089 "the resuming half of subscribe flattened a shard-ownership refusal into an \
1090 unroutable class"
1091 );
1092
1093 // `resolve_run_id` reads the RUN CHAIN rather than history, and it is
1094 // reached only when the caller omits the run id — which is why
1095 // `reopen_request` carries `run_id: None`.
1096 let reopened = transport.reopen(reopen_request()).await;
1097 assert_eq!(
1098 reopened.as_ref().err().map(ClientError::class),
1099 Some("not_owner"),
1100 "resolve_run_id flattened a shard-ownership refusal into an unroutable class: \
1101 {reopened:?}"
1102 );
1103
1104 // Teardown, and the fence comes down FIRST. Every other engine-backed
1105 // test in this file shuts its engine down; this one left the engine to
1106 // `Drop`, which gates the epoch but cannot await, so teardown work that
1107 // must finish was left racing the test's exit. Disarming before the
1108 // shutdown means the shutdown is measured against an honest store — a
1109 // shutdown error arriving through `?` here would then be a real fault
1110 // and not the fixture fencing its own teardown's history access.
1111 store.disarm_fence();
1112 engine.shutdown()?;
1113 Ok(())
1114 }
1115
1116 /// Every arm this map NAMES must reach its own class, and the wildcard must
1117 /// still catch what it is for.
1118 ///
1119 /// 🔴 Structured as a table with a NEGATIVE control (`Runtime`), because the
1120 /// assertion that matters is not "these map somewhere" but "these map
1121 /// somewhere OTHER than the generic bucket". Without the control, deleting
1122 /// every named arm and returning `ClientError::server` for everything would
1123 /// still fail — but a test that only listed the named variants could not
1124 /// tell a correct map from one that had accidentally started naming
1125 /// everything.
1126 ///
1127 /// 🔴 EACH ERROR IS BUILT IN THE SHAPE PRODUCTION PRODUCES IT, NOT THE
1128 /// SHAPE THAT MAKES THE ARM LOOK COVERED.
1129 ///
1130 /// An earlier revision of this table listed a bare
1131 /// `Store(StoreError::NotOwner)` and passed — and the arm it exercised
1132 /// never fired on the shape a caller actually provokes. A fenced APPEND
1133 /// goes through the `Recorder`, which returns `DurabilityError`, so that
1134 /// path's caller-reachable shape is `Durability(Store(NotOwner))` — and
1135 /// against THAT shape the same table failed with `left: "backend"`.
1136 ///
1137 /// The bare shape is reachable too, and an earlier revision of THIS comment
1138 /// wrongly said it was not. `EngineError` carries `Store(#[from]
1139 /// StoreError)`, so every `?` on a store call inside an engine API that
1140 /// returns `EngineError` produces it: `list_workflows`
1141 /// (`api_workflow_ops.rs`, both `store.query` and `store.read_history`),
1142 /// `cancel` (`lifecycle/terminate.rs`) and `signal`
1143 /// (`engine/delegated.rs`) are three of the eight engine operations this
1144 /// transport calls, and all three are store READS rather than appends —
1145 /// which is precisely why no `Recorder` is involved and no `Durability`
1146 /// wrapper appears. Both shapes are listed below because both happen.
1147 ///
1148 /// ⚠️ WHAT THIS TEST DOES NOT DO. The classes are the same ones
1149 /// `aion-server`'s `error.rs` assigns, but this test cannot check that: the
1150 /// dependency runs server → client, so the server's table is not visible
1151 /// from here and these expectations are a TRANSCRIPTION of it. A rule
1152 /// written in two places with nothing forcing agreement has already
1153 /// drifted or will. What holds them together today is that a change to
1154 /// either side must be made deliberately on both; the durable fix is a
1155 /// shared classifier, which needs a crate both can depend on and is not in
1156 /// this change's scope. Naming it here rather than letting the doc comment
1157 /// imply a guarantee the test does not provide.
1158 #[test]
1159 fn every_named_engine_error_reaches_its_own_class() {
1160 use super::map_engine_error;
1161
1162 for (error, expected) in engine_error_class_table() {
1163 assert_eq!(
1164 map_engine_error(&error).class(),
1165 expected,
1166 "wrong class for {error}"
1167 );
1168 }
1169 }
1170
1171 /// F3: the CLASS is not the whole answer — `ShuttingDown` must also carry
1172 /// its discriminator, because the operator-facing hint keyed off a bare
1173 /// `not_running` tells them the run ended and points at `aion list`, which
1174 /// is false twice (the run is fine; that command fails the same way).
1175 ///
1176 /// The class-table test above is invariant to this: it asserts
1177 /// `"not_running"` and would stay green with the discriminator dropped.
1178 /// That is exactly why this assertion is separate.
1179 ///
1180 /// Killing mutation: revert the arm to `ClientError::not_running(...)`.
1181 /// `error_type` becomes `None` and the first assertion fails.
1182 #[test]
1183 fn shutting_down_carries_its_discriminator_not_only_its_class()
1184 -> Result<(), Box<dyn std::error::Error>> {
1185 let mapped = super::map_engine_error(&aion::EngineError::ShuttingDown);
1186 let ClientError::NotRunning { detail } = &mapped else {
1187 return Err(format!("ShuttingDown must keep the not_running CLASS: {mapped}").into());
1188 };
1189 assert_eq!(
1190 detail.error_type.as_deref(),
1191 Some("ShuttingDown"),
1192 "the wire surface builds `not_running_with_type(\"ShuttingDown\", …)`; an embedded \
1193 caller that loses the discriminator cannot be told anything true about why"
1194 );
1195 // CONTROL: a different error in the same class must NOT claim this
1196 // discriminator, or the assertion above would pass for anything.
1197 let other = super::map_engine_error(&aion::EngineError::Runtime {
1198 reason: "beamr scheduler refused".to_owned(),
1199 });
1200 assert_ne!(
1201 other.class(),
1202 "not_running",
1203 "control: the negative case must not share the class under test"
1204 );
1205 Ok(())
1206 }
1207
1208 /// The table itself, lifted out of the test body so the case list can grow
1209 /// with the taxonomy without the assertion loop growing at all, and split
1210 /// by family so each half stays readable.
1211 fn engine_error_class_table() -> Vec<(aion::EngineError, &'static str)> {
1212 let mut cases = admission_and_run_state_cases();
1213 cases.extend(store_and_query_cases());
1214 cases
1215 }
1216
1217 /// Refusals about the request, the run's state, or the engine's own
1218 /// availability — plus the negative control.
1219 fn admission_and_run_state_cases() -> Vec<(aion::EngineError, &'static str)> {
1220 use aion_core::{RunId, WorkflowId};
1221
1222 let version = aion::ContentHash::from_bytes([7u8; 32]);
1223 vec![
1224 // The two oldest named arms, and until this revision the only two
1225 // with no row: the table asserted every arm reached its own class
1226 // while silently omitting them, so either could have been deleted
1227 // and every assertion here would still have passed.
1228 (
1229 aion::EngineError::WorkflowNotFound {
1230 workflow_type: "orders".to_owned(),
1231 },
1232 "not_found",
1233 ),
1234 (
1235 aion::EngineError::InvalidState {
1236 reason: "workflow w run r is Running, not terminal".to_owned(),
1237 },
1238 "invalid_state",
1239 ),
1240 (
1241 aion::EngineError::StartInputRefused {
1242 workflow_type: "orders".to_owned(),
1243 version: version.clone(),
1244 reason: "field `total` is missing".to_owned(),
1245 },
1246 "invalid_input",
1247 ),
1248 (
1249 aion::EngineError::SignalRefused {
1250 workflow_id: WorkflowId::new_v4(),
1251 run_id: RunId::new_v4(),
1252 signal_name: "approve".to_owned(),
1253 version,
1254 reason: "undeclared signal".to_owned(),
1255 },
1256 "invalid_input",
1257 ),
1258 (
1259 aion::EngineError::TerminalWriterUnavailable {
1260 workflow_id: "w".to_owned(),
1261 run_id: "r".to_owned(),
1262 holder: "another reservation".to_owned(),
1263 },
1264 "invalid_state",
1265 ),
1266 (
1267 aion::EngineError::TerminalWriterHeld {
1268 workflow_id: "w".to_owned(),
1269 run_id: "r".to_owned(),
1270 },
1271 "invalid_state",
1272 ),
1273 (
1274 aion::EngineError::RunIsRecoverable {
1275 workflow_id: "w".to_owned(),
1276 run_id: "r".to_owned(),
1277 version: "abc".to_owned(),
1278 },
1279 "invalid_state",
1280 ),
1281 (
1282 aion::EngineError::NoResidencyVerdict {
1283 workflow_id: "w".to_owned(),
1284 run_id: "r".to_owned(),
1285 },
1286 "invalid_state",
1287 ),
1288 (
1289 aion::EngineError::ContractIdentity {
1290 workflow_type: "orders".to_owned(),
1291 source: aion::ContractIdentityError::RedeployRequired {
1292 stored_version: "orders$deadbeef".to_owned(),
1293 },
1294 },
1295 "invalid_state",
1296 ),
1297 (
1298 aion::EngineError::NoQueueDeclaration {
1299 workflow_type: "orders".to_owned(),
1300 version: aion::ContentHash::from_bytes([5u8; 32]),
1301 activities: "charge_card,send_receipt".to_owned(),
1302 },
1303 "invalid_state",
1304 ),
1305 (aion::EngineError::ShuttingDown, "not_running"),
1306 // The control: a variant this map does NOT name must still land in
1307 // the generic bucket. If it stops doing so, the wildcard has been
1308 // replaced by something that names everything, and the assertions
1309 // above would have passed for the wrong reason.
1310 (
1311 aion::EngineError::Runtime {
1312 reason: "beamr scheduler refused".to_owned(),
1313 },
1314 "backend",
1315 ),
1316 ]
1317 }
1318
1319 /// The two nested families, each of which reaches this map wrapped in an
1320 /// outer `EngineError` variant rather than as itself.
1321 fn store_and_query_cases() -> Vec<(aion::EngineError, &'static str)> {
1322 use aion_core::WorkflowId;
1323
1324 vec![
1325 (
1326 aion::EngineError::Store(aion_store::StoreError::NotOwner { shard: 3 }),
1327 "not_owner",
1328 ),
1329 // 🔴 The shape a caller actually gets: the fence is raised inside a
1330 // recorded append, so it arrives wrapped in `Durability`.
1331 (
1332 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1333 aion_store::StoreError::NotOwner { shard: 3 },
1334 )),
1335 "not_owner",
1336 ),
1337 (
1338 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1339 aion_store::StoreError::NotFound {
1340 workflow_id: WorkflowId::new_v4(),
1341 },
1342 )),
1343 "not_found",
1344 ),
1345 // The store family's own control: a store fault that is genuinely
1346 // the engine's problem must NOT acquire a caller-facing class just
1347 // because it arrived through the same helper.
1348 (
1349 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1350 aion_store::StoreError::SequenceConflict {
1351 expected: 4,
1352 found: 7,
1353 },
1354 )),
1355 "backend",
1356 ),
1357 (
1358 aion::EngineError::Query(aion::QueryError::UnknownQuery("balance".to_owned())),
1359 "unknown_query",
1360 ),
1361 (
1362 aion::EngineError::Query(aion::QueryError::Timeout),
1363 "query_timeout",
1364 ),
1365 (
1366 aion::EngineError::Query(aion::QueryError::NotRunning(WorkflowId::new_v4())),
1367 "not_running",
1368 ),
1369 (
1370 aion::EngineError::Query(aion::QueryError::ReplyDropped),
1371 "not_running",
1372 ),
1373 (
1374 aion::EngineError::Query(aion::QueryError::Unknown(WorkflowId::new_v4())),
1375 "not_found",
1376 ),
1377 (
1378 aion::EngineError::Query(aion::QueryError::HandlerFailed {
1379 message: "handler panicked".to_owned(),
1380 }),
1381 "query_failed",
1382 ),
1383 // The caller's own arguments were malformed: the request is the
1384 // thing to fix, so this is `invalid_argument` — not the handler's
1385 // failure and not the engine's.
1386 (
1387 aion::EngineError::Query(aion::QueryError::InvalidArguments {
1388 reason: "arguments payload is not a well-formed JSON document".to_owned(),
1389 }),
1390 "invalid_input",
1391 ),
1392 // The query family's own control, and the eighth of eight arms —
1393 // an earlier revision of this table listed six and left this one
1394 // unmeasured, so a change re-classifying an engine-seam failure as
1395 // a caller-facing one survived green. A seam that could not deliver
1396 // the query is the ENGINE's failure, not the caller's, and must
1397 // land in the generic bucket rather than acquire a remedy the
1398 // caller cannot act on.
1399 (
1400 aion::EngineError::Query(aion::QueryError::Engine(
1401 aion::engine_seam::EngineSeamError::Delivery {
1402 reason: "mailbox send failed".to_owned(),
1403 },
1404 )),
1405 "backend",
1406 ),
1407 ]
1408 }
1409}