aion-cli 0.18.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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
use aion_awl::CheckError;

use super::*;

/// A canonical rev-2 document: one input, one success outcome, one
/// worker action, one step whose pipe routes the result out.
const VALID_DOC: &str = "//! Probe: make a note of the token and hand it back.\n\
workflow probe\n\
\x20 input token: String\n\
\x20 outcome done: type String, route success\n\
\n\
worker probe\n\
\x20 action make(token: String) -> String\n\
\n\
step one\n\
\x20 token |> make |> route done\n";

/// A canonical WORKER document: the other family, whose opening declaration
/// is `worker <queue>` and whose actions each carry the body that runs them.
const WORKER_DOC: &str = "//! Greeter: the queue this document serves.\n\
worker greeter\n\
\x20 action greet(name: String) -> String\n\
\x20   run text \"printf %s $name\"\n\
\x20 action shout(text: String) -> String\n\
\x20   run text \"tr a-z A-Z\"\n";

/// A canonical document using the B1 flow vocabulary: a multi-line raw
/// string, a `json { … }` literal, `schema of`, const folding, and an
/// expression-headed statement.
const VOCAB_DOC: &str = "//! Vocabulary probe.\n\
workflow vocab_probe\n\
\x20 input task: String\n\
\x20 outcome done: type String, route success\n\
\n\
const greeting = \"\"\"\n\
\x20 Hello from the vocabulary.\n\
\x20 \"\"\"\n\
const item_schema = json { \"type\": \"object\" }\n\
const verdict_schema = schema of Verdict\n\
const prompt = greeting + \" Task: \"\n\
\n\
type Verdict { passed: Bool }\n\
\n\
worker probe\n\
\x20 action make(prompt: String, output_schema: String) -> String\n\
\n\
step one\n\
\x20 make(prompt: prompt + task, output_schema: verdict_schema) -> made\n\
\x20 make(prompt: greeting, output_schema: item_schema) -> extra\n\
\x20 \"made: \" + made + extra -> summary\n\
\x20 summary |> route done\n";

/// Well-formed rev-2 source whose route names no declared outcome or
/// step — a typecheck error, not a parse error.
const BROKEN_ROUTE_DOC: &str = "//! Probe with a dangling route.\n\
workflow probe\n\
\x20 input token: String\n\
\x20 outcome done: type String, route success\n\
\n\
worker probe\n\
\x20 action make(token: String) -> String\n\
\n\
step one\n\
\x20 token |> make |> route missing\n";

#[test]
fn diagnostic_renders_the_compiler_style_line() {
    // A synthetic checker diagnostic renders as <file>:<line>:<column>.
    let error = CheckError {
        span: Span {
            start: 12,
            end: 16,
            line: 3,
            column: 7,
        },
        message: "unknown name `stat`".to_owned(),
    };
    let line = diagnostic(Path::new("flows/probe.awl"), error.span, &error.message);
    assert_eq!(line, "flows/probe.awl:3:7: error: unknown name `stat`");
}

#[test]
fn check_source_counts_steps_on_a_clean_document() {
    let summary = check_source(Path::new("probe.awl"), VALID_DOC);
    assert_eq!(summary, Ok("1 step".to_owned()));
}

/// A worker document has no steps to count, so its summary counts the
/// actions it serves and names the queue it serves them on.
#[test]
fn check_source_counts_actions_on_a_clean_worker_document() {
    let summary = check_source(Path::new("greeter.awl"), WORKER_DOC);
    assert_eq!(summary, Ok("worker `greeter`, 2 actions".to_owned()));
}

#[test]
fn check_source_renders_a_parse_error_as_a_diagnostic() -> anyhow::Result<()> {
    let Err(diagnostics) = check_source(Path::new("probe.awl"), "not a workflow\n") else {
        anyhow::bail!("expected a parse diagnostic");
    };
    assert_eq!(diagnostics.len(), 1);
    assert!(
        diagnostics[0].starts_with("probe.awl:1:1: error: "),
        "unexpected diagnostic: {}",
        diagnostics[0]
    );
    Ok(())
}

#[test]
fn check_source_renders_typecheck_errors_as_diagnostics() -> anyhow::Result<()> {
    let Err(diagnostics) = check_source(Path::new("probe.awl"), BROKEN_ROUTE_DOC) else {
        anyhow::bail!("expected a typecheck diagnostic");
    };
    assert!(!diagnostics.is_empty());
    for line in &diagnostics {
        assert!(
            line.starts_with("probe.awl:") && line.contains(": error: "),
            "unexpected diagnostic: {line}"
        );
    }
    Ok(())
}

#[test]
fn format_source_is_the_canonical_printer() -> anyhow::Result<()> {
    // An already-canonical document formats to itself (one rendering).
    let formatted = format_source(Path::new("probe.awl"), VALID_DOC)
        .map_err(|d| anyhow::anyhow!("unexpected diagnostics: {d:?}"))?;
    assert_eq!(formatted, VALID_DOC);
    Ok(())
}

/// `aion awl check` accepts the B1 flow vocabulary (raw strings,
/// `json { … }`, `schema of`, consts, expression-headed statements).
#[test]
fn check_source_accepts_the_flow_vocabulary() {
    let summary = check_source(Path::new("vocab.awl"), VOCAB_DOC);
    assert_eq!(summary, Ok("1 step".to_owned()));
}

/// `aion awl fmt` is idempotent on the B1 flow vocabulary: the document
/// is already canonical, and formatting the formatted output changes
/// nothing.
#[test]
fn format_source_is_idempotent_on_the_flow_vocabulary() -> anyhow::Result<()> {
    let once = format_source(Path::new("vocab.awl"), VOCAB_DOC)
        .map_err(|d| anyhow::anyhow!("unexpected diagnostics: {d:?}"))?;
    assert_eq!(once, VOCAB_DOC);
    let twice = format_source(Path::new("vocab.awl"), &once)
        .map_err(|d| anyhow::anyhow!("unexpected diagnostics: {d:?}"))?;
    assert_eq!(twice, once);
    Ok(())
}

/// `aion awl emit` folds the vocabulary before lowering: the emitted
/// Gleam carries the folded strings, never a const name.
#[test]
fn emit_source_folds_the_flow_vocabulary() -> anyhow::Result<()> {
    let generated = emit_source(Path::new("vocab.awl"), VOCAB_DOC)
        .map_err(|d| anyhow::anyhow!("unexpected diagnostics: {d:?}"))?;
    assert!(
        generated.contains("Hello from the vocabulary."),
        "raw string content missing: {generated}"
    );
    assert!(
        !generated.contains("verdict_schema"),
        "unfolded const reference leaked: {generated}"
    );
    Ok(())
}

#[test]
fn format_source_reports_a_parse_error_without_output() -> anyhow::Result<()> {
    let Err(diagnostics) = format_source(Path::new("probe.awl"), "step\n") else {
        anyhow::bail!("expected a parse diagnostic");
    };
    assert_eq!(diagnostics.len(), 1);
    assert!(diagnostics[0].starts_with("probe.awl:1:"));
    Ok(())
}

#[test]
fn emit_source_generates_gleam_for_a_clean_document() -> anyhow::Result<()> {
    let generated = emit_source(Path::new("probe.awl"), VALID_DOC)
        .map_err(|d| anyhow::anyhow!("unexpected diagnostics: {d:?}"))?;
    assert!(
        generated.contains("pub fn execute"),
        "expected generated code to contain `pub fn execute`: {generated}"
    );
    assert!(
        generated.contains("make_activity(token)"),
        "expected the action dispatch in the generated module: {generated}"
    );
    Ok(())
}

#[test]
fn emit_output_writes_packaging_sidecar_for_implicit_children() -> anyhow::Result<()> {
    let source = "//! CLI structured artifact proof.\n\
workflow cli_parallel\n\
\x20 input items: [String]\n\
\x20 outcome done: type Done, route success\n\
\n\
type Done { count: Int }\n\
\n\
worker proof\n\
\x20 action first(item: String) -> String\n\
\x20 action second(item: String) -> String\n\
\n\
step fan\n\
\x20 distribute item in items\n\
step one\n\
\x20 first(item: item) -> prepared\n\
step two\n\
\x20 second(item: prepared) -> result\n\
step gather\n\
\x20 collect result -> results\n\
\x20 results |> count -> total\n\
\x20 route done(count: total)\n";
    let temp = tempfile::tempdir()?;
    let source_path = temp.path().join("cli_parallel.awl");
    let output_path = temp.path().join("cli_parallel.gleam");
    fs::write(&source_path, source)?;
    assert_eq!(
        emit_command(&source_path, Some(&output_path), EmitTarget::Gleam),
        ExitCode::SUCCESS,
        "the real CLI emit path failed"
    );
    assert!(fs::read_to_string(&output_path)?.contains("workflow.spawn"));

    let metadata: serde_json::Value =
        serde_json::from_slice(&fs::read(output_path.with_extension("awl.json"))?)?;
    assert_eq!(metadata["entry_module"], "cli_parallel");
    assert_eq!(
        metadata["synthesized_workflows"].as_array().map(Vec::len),
        Some(1)
    );
    assert!(
        metadata["synthesized_workflows"][0]["workflow_type"]
            .as_str()
            .is_some_and(
                |workflow_type| workflow_type.starts_with("aion_internal_awl_child_cli_parallel_")
            )
    );
    assert_eq!(
        metadata["synthesized_workflows"][0]["input_schema"]["type"],
        "object"
    );
    Ok(())
}

#[test]
fn emit_source_is_gated_on_a_clean_typecheck() -> anyhow::Result<()> {
    // Emission must refuse rather than generate code from an ill-typed
    // document.
    let Err(diagnostics) = emit_source(Path::new("probe.awl"), BROKEN_ROUTE_DOC) else {
        anyhow::bail!("expected a typecheck diagnostic");
    };
    assert!(!diagnostics.is_empty());
    for line in &diagnostics {
        assert!(
            line.starts_with("probe.awl:") && line.contains(": error: "),
            "unexpected diagnostic: {line}"
        );
    }
    Ok(())
}

#[test]
fn emit_source_renders_a_parse_error_as_a_diagnostic() -> anyhow::Result<()> {
    let Err(diagnostics) = emit_source(Path::new("probe.awl"), "not a workflow\n") else {
        anyhow::bail!("expected a parse diagnostic");
    };
    assert_eq!(diagnostics.len(), 1);
    assert!(
        diagnostics[0].starts_with("probe.awl:1:1: error: "),
        "unexpected diagnostic: {}",
        diagnostics[0]
    );
    Ok(())
}

/// A declared shorthand type with a `?` field derives its JSON Schema:
/// doc lines flow to `description`, `?` maps to "not in required".
#[test]
fn schema_source_derives_a_declared_type() -> anyhow::Result<()> {
    let source = "//! File a note.\n\
workflow filed_note\n\
\x20 input note: Note\n\
\x20 outcome kept: type Note, route success\n\
\n\
/// A note somebody jotted down.\n\
type Note {\n\
\x20 title: String,\n\
\x20 body: String?,\n\
}\n\
\n\
worker files\n\
\x20 action keep(note: Note) -> Note\n\
\n\
step keep_note\n\
\x20 note |> keep |> route kept\n";
    let schema = schema_source(Path::new("note.awl"), source, Some("Note"), false)
        .map_err(|diagnostics| anyhow::anyhow!("unexpected diagnostics: {diagnostics:?}"))?;
    let value: serde_json::Value = serde_json::from_str(&schema)?;
    assert_eq!(value["type"], "object");
    assert_eq!(value["description"], "A note somebody jotted down.");
    assert_eq!(value["required"], serde_json::json!(["title"]));
    assert_eq!(value["properties"]["body"]["type"], "string");
    Ok(())
}

/// Without `--type`, the workflow's start contract derives: one object
/// over the inputs, `?` inputs omitted from `required`.
#[test]
fn schema_source_without_type_emits_the_start_contract() -> anyhow::Result<()> {
    let source = "//! Greet, optionally loudly.\n\
workflow greeter\n\
\x20 input name: String\n\
\x20 input flair: String?\n\
\x20 outcome done: type String, route success\n\
\n\
worker greeter\n\
\x20 action greet(name: String) -> String\n\
\n\
step greet\n\
\x20 name |> greet |> route done\n";
    let schema = schema_source(Path::new("greeter.awl"), source, None, false)
        .map_err(|diagnostics| anyhow::anyhow!("unexpected diagnostics: {diagnostics:?}"))?;
    let value: serde_json::Value = serde_json::from_str(&schema)?;
    assert_eq!(value["properties"]["name"]["type"], "string");
    assert_eq!(value["required"], serde_json::json!(["name"]));
    Ok(())
}

#[test]
fn schema_source_is_gated_on_a_clean_typecheck() -> anyhow::Result<()> {
    let source = "//! Probe with an undeclared field type.\n\
workflow probe\n\
\x20 input token: String\n\
\x20 outcome done: type Brief, route success\n\
\n\
type Brief { value: Missing }\n\
\n\
worker probe\n\
\x20 action make(token: String) -> Brief\n\
\n\
step one\n\
\x20 token |> make |> route done\n";
    let Err(diagnostics) = schema_source(Path::new("probe.awl"), source, Some("Brief"), false)
    else {
        anyhow::bail!("expected a typecheck diagnostic");
    };
    assert!(!diagnostics.is_empty());
    for line in &diagnostics {
        assert!(
            line.starts_with("probe.awl:") && line.contains(": error: "),
            "unexpected diagnostic: {line}"
        );
    }
    Ok(())
}

/// The committed golden-fixture directory (`tests/golden/`): matched
/// `<name>.awl` source and `<name>.gleam` / `<name>.awl.json` expected-output
/// pairs, frozen on disk so the regression compares the live emitter against
/// bytes it cannot itself rewrite.
fn golden_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden")
}

/// The gleam target is byte-identical to the pre-`--target` behaviour, proven
/// against a COMMITTED golden rather than the emitter compared with itself: the
/// real `emit_command` writing `--target gleam` reproduces the frozen
/// `golden/probe.gleam` bytes exactly. A mutation to the gleam emitter changes
/// the freshly written bytes but never the committed golden, so this regression
/// is mutation-sensitive where the old self-referential `emit_source == written`
/// form was not (BC-5 review blocker 1).
#[test]
fn emit_gleam_target_reproduces_the_committed_golden() -> anyhow::Result<()> {
    let source_path = golden_dir().join("probe.awl");
    let golden = fs::read(golden_dir().join("probe.gleam"))?;
    let temp = tempfile::tempdir()?;
    let output_path = temp.path().join("probe.gleam");
    assert_eq!(
        emit_command(&source_path, Some(&output_path), EmitTarget::Gleam),
        ExitCode::SUCCESS,
        "the gleam emit path failed"
    );
    let written = fs::read(&output_path)?;
    assert_eq!(
        written, golden,
        "gleam output drifted from the committed golden golden/probe.gleam"
    );
    Ok(())
}

/// The synthesized-children gleam path is byte-frozen too: emitting the
/// committed `golden/cli_parallel.awl` (a `distribute` step, so an implicit
/// child module and its packaging sidecar) reproduces both `cli_parallel.gleam`
/// AND the `cli_parallel.awl.json` sidecar bytes exactly. Covers the synthesized
/// entry path the plain `probe` golden does not (BC-5 review blocker 1).
#[test]
fn emit_gleam_synthesized_children_reproduce_the_committed_goldens() -> anyhow::Result<()> {
    let source_path = golden_dir().join("cli_parallel.awl");
    let golden_source = fs::read(golden_dir().join("cli_parallel.gleam"))?;
    let golden_sidecar = fs::read(golden_dir().join("cli_parallel.awl.json"))?;
    let temp = tempfile::tempdir()?;
    let output_path = temp.path().join("cli_parallel.gleam");
    assert_eq!(
        emit_command(&source_path, Some(&output_path), EmitTarget::Gleam),
        ExitCode::SUCCESS,
        "the gleam emit path failed"
    );
    assert_eq!(
        fs::read(&output_path)?,
        golden_source,
        "gleam output drifted from golden/cli_parallel.gleam"
    );
    assert_eq!(
        fs::read(output_path.with_extension("awl.json"))?,
        golden_sidecar,
        "sidecar drifted from golden/cli_parallel.awl.json"
    );
    Ok(())
}

/// `--target beam` refuses without `--output`: BEAM bytes are never written to
/// stdout, so a missing output is a typed failure, not a stdout dump.
#[test]
fn emit_beam_refuses_without_output() -> anyhow::Result<()> {
    let temp = tempfile::tempdir()?;
    let source_path = temp.path().join("probe.awl");
    fs::write(&source_path, VALID_DOC)?;
    assert_eq!(
        emit_command(&source_path, None, EmitTarget::Beam),
        ExitCode::FAILURE,
        "beam emit to stdout must be refused"
    );
    Ok(())
}

/// `--target beam` writes one BEAM container and a beam-shaped sidecar: the
/// module bytes lead with the `FOR1` magic, and the sidecar carries the derived
/// contracts and action requirements — never the Gleam `project_metadata`
/// shape (no `format_version`/`entry_module` keys next to `.beam` bytes).
#[test]
fn emit_beam_writes_a_module_and_a_beam_shaped_sidecar() -> anyhow::Result<()> {
    let temp = tempfile::tempdir()?;
    let source_path = temp.path().join("probe.awl");
    let output_path = temp.path().join("probe.beam");
    fs::write(&source_path, VALID_DOC)?;
    assert_eq!(
        emit_command(&source_path, Some(&output_path), EmitTarget::Beam),
        ExitCode::SUCCESS,
        "the beam emit path failed"
    );

    let module = fs::read(&output_path)?;
    assert!(
        module.starts_with(b"FOR1"),
        "the beam output is not a BEAM container"
    );

    let sidecar_path = output_path.with_file_name("probe.beam.json");
    let sidecar: serde_json::Value = serde_json::from_slice(&fs::read(&sidecar_path)?)?;
    assert_eq!(sidecar["target"], "beam");
    assert_eq!(sidecar["workflow_name"], "probe");
    assert_eq!(sidecar["input_schema"]["type"], "object");
    assert!(sidecar["output_schema"].is_object());
    assert!(sidecar["actions"].is_array(), "action requirements missing");
    assert!(
        sidecar.get("format_version").is_none() && sidecar.get("entry_module").is_none(),
        "beam sidecar leaked the Gleam project_metadata shape: {sidecar}"
    );
    Ok(())
}

/// The ops-console compatibility proof (the operator's condition): the bytes the
/// CLI writes for `--target beam` are byte-identical to the entry module bytes
/// inside `compile_and_assemble_awl`'s archive for the same source. One seam,
/// zero drift — CLI output and console-deployed output can never diverge.
#[test]
fn emit_beam_bytes_equal_the_archive_entry_module() -> anyhow::Result<()> {
    use aion_package::{ExtractionLimits, Package};

    let temp = tempfile::tempdir()?;
    let source_path = temp.path().join("probe.awl");
    let output_path = temp.path().join("probe.beam");
    fs::write(&source_path, VALID_DOC)?;
    assert_eq!(
        emit_command(&source_path, Some(&output_path), EmitTarget::Beam),
        ExitCode::SUCCESS,
        "the beam emit path failed"
    );
    let cli_bytes = fs::read(&output_path)?;

    let root = source_path
        .parent()
        .ok_or_else(|| anyhow::anyhow!("source path has no parent"))?;
    let prepared = aion_awl_package::compile_and_assemble_awl(VALID_DOC, root, "probe.awl")?;
    let package = Package::load_from_bytes(prepared.archive, ExtractionLimits::unbounded())?;
    let entry_module = package.manifest().entry_module.clone();
    let archive_bytes = package
        .beams()
        .get(&entry_module)
        .ok_or_else(|| anyhow::anyhow!("archive lost its entry module {entry_module}"))?;

    assert_eq!(
        cli_bytes.as_slice(),
        archive_bytes,
        "CLI beam bytes drifted from the archive entry module — the seam split"
    );
    Ok(())
}