aion-cli 0.30.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Single error-rendering path shared by every aion-cli subcommand.
//!
//! Contract: every operational failure prints to stderr and the process
//! exits with code 1. When the failure carries the client taxonomy
//! ([`ClientError`] anywhere in the anyhow chain) the first line is
//!
//! ```text
//! error[<class>]: <operation context>: <server detail message>
//! ```
//!
//! where `<class>` is the stable taxonomy class aligned with the wire error
//! codes (`not_found`, `namespace_denied`, `invalid_input`, `backend`,
//! `query_failed`, `query_timeout`, `unknown_query`, `not_running`, ...).
//! The structured wire `error_type` and an actionable hint follow on their
//! own indented lines when available. Failures without a taxonomy class
//! render the full anyhow cause chain on one `error:` line, so no underlying
//! detail is ever masked by a context message.

use aion_client::ClientError;
use aion_proto::{WireError, WireErrorCode};

/// Renders any CLI failure into the stderr contract described in the module
/// docs. Deploy subcommands carry the raw typed [`WireError`] (their wire
/// codes — `deploy_denied`, `version_pinned` — are operator surface and
/// deliberately outside the caller SDK taxonomy).
pub(crate) fn render_error(error: &anyhow::Error) -> String {
    if let Some(client_error) = find_client_error(error) {
        return render_client_error(error, client_error);
    }
    if let Some(wire) = find_wire_error(error) {
        return render_wire_error(error, wire);
    }
    format!("error: {}", joined_chain(error))
}

/// Finds a raw wire error (deploy path) anywhere in the anyhow chain.
fn find_wire_error(error: &anyhow::Error) -> Option<&WireError> {
    error
        .chain()
        .find_map(|cause| cause.downcast_ref::<WireError>())
}

fn render_wire_error(error: &anyhow::Error, wire: &WireError) -> String {
    let mut rendered = format!("error[{}]: ", wire.code);
    for layer in error
        .chain()
        .take_while(|cause| cause.downcast_ref::<WireError>().is_none())
    {
        rendered.push_str(&layer.to_string());
        rendered.push_str(": ");
    }
    if wire.message.is_empty() {
        rendered.push_str("(the server supplied no detail message)");
    } else {
        rendered.push_str(&wire.message);
    }
    if let Some(error_type) = &wire.error_type {
        rendered.push_str("\n  server error type: ");
        rendered.push_str(error_type);
    }
    if let Some(hint) = wire_hint(wire.code) {
        rendered.push_str("\n  hint: ");
        rendered.push_str(hint);
    }
    rendered
}

/// Actionable hints for the deploy wire codes the CLI can surface.
const fn wire_hint(code: WireErrorCode) -> Option<&'static str> {
    match code {
        WireErrorCode::DeployDenied => Some(
            "this caller holds no deploy grant; pass --token (or AION_TOKEN) with a \
             token whose deploy claim is true, or in development mode check the \
             server's denial detail above",
        ),
        WireErrorCode::VersionPinned => Some(
            "the version is route-active or pinned by live state; `aion versions` \
             shows what is routed — route another version first, or wait for the \
             pinning runs to finish",
        ),
        WireErrorCode::NotFound => Some(
            "the (workflow-type, content-hash) pair is not loaded; `aion versions` \
             lists every loaded version",
        ),
        _ => None,
    }
}

/// Finds the taxonomy error anywhere in the anyhow context chain.
fn find_client_error(error: &anyhow::Error) -> Option<&ClientError> {
    error
        .chain()
        .find_map(|cause| cause.downcast_ref::<ClientError>())
}

fn render_client_error(error: &anyhow::Error, client_error: &ClientError) -> String {
    let mut rendered = format!("error[{}]: ", client_error.class());
    // Context layers above the ClientError (operation labels such as
    // "failed to query workflow") keep their place in the message; the
    // ClientError itself contributes its detail, not its Display, so the
    // class is never printed twice.
    for layer in error
        .chain()
        .take_while(|cause| cause.downcast_ref::<ClientError>().is_none())
    {
        rendered.push_str(&layer.to_string());
        rendered.push_str(": ");
    }
    let detail = client_error.detail();
    if detail.message.is_empty() {
        rendered.push_str("(the server supplied no detail message)");
    } else {
        rendered.push_str(&detail.message);
    }
    if let Some(error_type) = &detail.error_type {
        rendered.push_str("\n  server error type: ");
        rendered.push_str(error_type);
    }
    if let Some(hint) = hint(client_error) {
        rendered.push_str("\n  hint: ");
        rendered.push_str(hint);
    }
    rendered
}

/// Joins every layer of an anyhow chain, so `.context(...)` labels never
/// mask the root cause.
fn joined_chain(error: &anyhow::Error) -> String {
    error
        .chain()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join(": ")
}

/// Actionable per-class hints. Classes whose detail message is already
/// self-sufficient (`already_exists`, `cancelled`, `invalid_input`,
/// `backend`) carry none.
// Not `const`: the `ShuttingDown` arm below reads the runtime `error_type`
// discriminator off the detail, and there is no const context — the sole
// caller is the runtime render path above.
fn hint(error: &ClientError) -> Option<&'static str> {
    match error {
        ClientError::NotFound { .. } => Some(
            "verify the workflow id, --run-id, and --namespace; workflows in \
             other namespaces are reported as not found",
        ),
        ClientError::QueryFailed { .. } => Some(
            "the workflow's query handler ran and reported this failure; inspect \
             the handler, or the run with `aion describe <workflow-id>`",
        ),
        ClientError::QueryTimeout { .. } => Some(
            "the query missed its deadline; the workflow may be busy or stalled \
             — retry, or inspect the run with `aion describe <workflow-id>`",
        ),
        ClientError::UnknownQuery { .. } => Some(
            "the workflow does not register a query with this name; check the \
             query name against the workflow's query handlers",
        ),
        // 🔴 THE CLASS IS NOT THE CAUSE. `not_running` carries two different
        // situations, and the generic hint is actively wrong for one of them:
        // `ShuttingDown` means the ENGINE stopped serving, not that this run
        // ended — so telling the operator to go looking at `aion list` sends
        // them after a terminal that never landed, and that command would fail
        // in exactly the same way. Read the discriminator the wire and the
        // embedded transport both carry.
        ClientError::NotRunning { detail }
            if detail.error_type.as_deref() == Some("ShuttingDown") =>
        {
            Some(
                "the server is shutting down and is no longer accepting work; \
                 the run itself is unaffected — wait for the server to come \
                 back and retry, and expect other commands to fail the same \
                 way until it does",
            )
        }
        ClientError::NotRunning { .. } => Some(
            "the target run is no longer running; `aion list --status \
             running` shows runs that can still serve queries and signals",
        ),
        ClientError::NamespaceDenied { .. } => Some(
            "this caller has no grant for the requested namespace; pass a \
             --namespace the caller is authorized for",
        ),
        ClientError::Unauthenticated { .. } => {
            Some("the server rejected the caller's credentials; check the auth token and --subject")
        }
        ClientError::Unavailable { .. } => {
            Some("cannot reach the server; check --endpoint and that aion-server is running")
        }
        // NOT a connectivity failure: the server answered, it simply does not
        // own this target's shard. Telling the operator to check --endpoint
        // here sends them hunting a network fault that does not exist — the
        // exact wrong turn the failover start-path investigation took.
        ClientError::NotOwner { .. } => Some(
            "the server answered but does not own this target's shard; shard \
             ownership moves as the cluster fails over, so retry — or point \
             --endpoint at another node in the cluster",
        ),
        ClientError::InvalidState { .. } => Some(
            "the target run is not in a state this operation accepts (e.g. reopen \
             requires a terminal Failed or Cancelled run); inspect it with `aion \
             describe <workflow-id>`",
        ),
        ClientError::AlreadyExists { .. }
        | ClientError::Cancelled { .. }
        | ClientError::InvalidArgument { .. }
        | ClientError::Server { .. } => None,
    }
}

#[cfg(test)]
mod tests {
    use aion_client::{ClientError, ErrorDetail};

    use super::render_error;

    fn rendered(client_error: ClientError, context: &'static str) -> String {
        let error = anyhow::Error::new(client_error).context(context);
        render_error(&error)
    }

    #[test]
    fn each_query_wire_code_renders_a_distinct_class_detail_and_hint() {
        let cases = [
            (
                ClientError::query_failed("handler raised: cart is empty"),
                "error[query_failed]: failed to query workflow: handler raised: cart is empty",
                "query handler ran and reported",
            ),
            (
                ClientError::query_timeout("query window of 5s elapsed"),
                "error[query_timeout]: failed to query workflow: query window of 5s elapsed",
                "missed its deadline",
            ),
            (
                ClientError::unknown_query("no query named 'stat' is registered"),
                "error[unknown_query]: failed to query workflow: no query named 'stat' is \
                 registered",
                "does not register a query with this name",
            ),
            (
                ClientError::not_running("run already reached Completed"),
                "error[not_running]: failed to query workflow: run already reached Completed",
                "no longer running",
            ),
        ];
        for (client_error, first_line, hint_fragment) in cases {
            let output = rendered(client_error, "failed to query workflow");
            let mut lines = output.lines();
            assert_eq!(lines.next(), Some(first_line));
            let hint = lines.next().unwrap_or_default();
            assert!(
                hint.starts_with("  hint: ") && hint.contains(hint_fragment),
                "hint line for {first_line:?} was {hint:?}"
            );
        }
    }

    #[test]
    fn structured_error_type_gets_its_own_line() {
        let output = rendered(
            ClientError::server(ErrorDetail::with_type("store unavailable", "Durability")),
            "failed to start workflow",
        );
        assert_eq!(
            output,
            "error[backend]: failed to start workflow: store unavailable\n  server error type: \
             Durability"
        );
    }

    #[test]
    fn unavailable_renders_transport_chain_and_endpoint_hint() {
        let output = rendered(
            ClientError::unavailable("transport error: tcp connect error: connection refused"),
            "failed to connect to Aion server",
        );
        assert_eq!(
            output,
            "error[unavailable]: failed to connect to Aion server: transport error: tcp connect \
             error: connection refused\n  hint: cannot reach the server; check --endpoint and \
             that aion-server is running"
        );
    }

    #[test]
    fn namespace_denied_and_not_found_render_their_classes() {
        let denied = rendered(
            ClientError::namespace_denied("namespace tenant-b is not granted to this caller"),
            "failed to list workflows",
        );
        assert!(
            denied.starts_with(
                "error[namespace_denied]: failed to list workflows: namespace tenant-b is not \
                 granted to this caller"
            ),
            "got {denied:?}"
        );

        let not_found = rendered(
            ClientError::not_found(ErrorDetail::with_type(
                "workflow was not found",
                "WorkflowNotFound",
            )),
            "failed to describe workflow",
        );
        assert!(
            not_found
                .contains("error[not_found]: failed to describe workflow: workflow was not found")
                && not_found.contains("  server error type: WorkflowNotFound"),
            "got {not_found:?}"
        );
    }

    #[test]
    fn invalid_input_and_backend_render_without_a_hint() {
        for client_error in [
            ClientError::invalid_argument("resume_from_seq must be >= 1"),
            ClientError::server("query response outcome is missing"),
            ClientError::already_exists("idempotency key conflict"),
            ClientError::cancelled("call cancelled"),
        ] {
            let output = rendered(client_error, "operation failed");
            assert!(
                !output.contains("\n  hint: "),
                "detail is self-sufficient, got {output:?}"
            );
        }
    }

    #[test]
    fn empty_server_detail_is_stated_not_hidden() {
        let output = rendered(ClientError::cancelled(""), "failed to cancel workflow");
        assert_eq!(
            output,
            "error[cancelled]: failed to cancel workflow: (the server supplied no detail message)"
        );
    }

    #[test]
    fn non_client_errors_render_the_full_anyhow_chain() {
        let root = std::io::Error::new(std::io::ErrorKind::NotFound, "workflow.toml is missing");
        let error = anyhow::Error::new(root).context("failed to package workflow project");
        assert_eq!(
            render_error(&error),
            "error: failed to package workflow project: workflow.toml is missing"
        );
    }

    #[test]
    fn nested_context_layers_all_appear_before_the_detail() {
        let error = anyhow::Error::new(ClientError::not_running("run is terminal"))
            .context("failed to signal workflow")
            .context("signal operation aborted");
        let output = render_error(&error);
        assert!(
            output.starts_with(
                "error[not_running]: signal operation aborted: failed to signal workflow: run is \
                 terminal"
            ),
            "got {output:?}"
        );
    }
    /// F3: a `not_running` carrying `ShuttingDown` must NOT be given the
    /// generic hint. The run is fine — the engine stopped serving — so the
    /// generic text ("no longer running", "`aion list --status running`") is
    /// wrong twice, and that command would fail identically.
    ///
    /// Killing mutation: delete the `ShuttingDown` guard arm in `hint`. Both
    /// cases then render the same text and the `assert_ne!` fails.
    #[test]
    fn a_shutting_down_not_running_is_not_given_the_generic_hint() {
        let shutting_down = rendered(
            ClientError::NotRunning {
                detail: ErrorDetail::with_type("engine is shutting down", "ShuttingDown"),
            },
            "signal",
        );
        // CONTROL: the same class WITHOUT the discriminator still gets the
        // generic hint. Without this the assertion below would pass if the
        // generic hint had simply been deleted for everyone.
        let genuinely_ended = rendered(
            ClientError::NotRunning {
                detail: ErrorDetail::new("run 7 is Completed"),
            },
            "signal",
        );
        assert!(
            genuinely_ended.contains("aion list --status running"),
            "control: a plain not_running must still get the generic hint, or this test would \
             pass against a hint that was merely removed: {genuinely_ended}"
        );
        assert_ne!(
            shutting_down, genuinely_ended,
            "the two situations must not render the same advice"
        );
        assert!(
            !shutting_down.contains("aion list --status running"),
            "a shutting-down engine cannot serve `aion list` either — sending the operator there \
             is the same wrong turn this lane's F4 fix removed elsewhere: {shutting_down}"
        );
        assert!(
            shutting_down.contains("shutting down"),
            "the hint must name the real cause: {shutting_down}"
        );
    }
}