aion-server 0.31.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
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
517
518
519
520
521
522
//! Server-side authoring loop e2e over the public HTTP transport.
//!
//! Proves R2 / C13 / C14 / S7 / S8: with `[authoring].gleam_path` configured,
//! `POST /authoring/compile` returns a type error inline (HTTP 400) for a
//! type-erroneous workflow, and packages + hot-loads a corrected workflow so a
//! subsequent `/workflows/start` runs it on the new version.
//!
//! The compile path requires the external `gleam` binary plus the cached Hex
//! dependencies of the `aion_flow` SDK, so it is gated at RUNTIME: when
//! `gleam` is not runnable the test emits a skip line and returns `Ok(())` —
//! never `#[ignore]`.

#[path = "test_support/state_guard.rs"]
mod state_guard;

use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;

use aion::signal::ConcreteSignalRouter;
use aion::{Engine, EngineBuilder, RuntimeHandle, SignalRouter};
use aion_core::{RunId, WorkflowId};
use aion_server::api::http::http_router;
use aion_server::config::{
    AuthConfig, AuthoringConfig, DeployConfig, ListenConfig, MetricsConfig, NamespaceConfig,
    NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig, WebSocketConfig,
    WorkerConfig,
};
use aion_server::{NamespaceResolver, ServerState};
use aion_store::{EventStore, InMemoryStore};
use axum::{body, http::Request, http::StatusCode, response::Response};
use serde_json::json;
use tower::ServiceExt;

use state_guard::StateUnderTest;

type TestError = Box<dyn std::error::Error>;

const NAMESPACE: &str = "default";
const ENTRY_MODULE: &str = "aion_authoring_fixture";

/// A type-erroneous workflow: `run` is annotated `Result(String, _)` but
/// returns a bare `Int`. The Gleam compiler rejects it.
const TYPE_ERROR_SOURCE: &str = r"import gleam/dynamic.{type Dynamic}

pub fn run(_raw_input: Dynamic) -> Result(String, Nil) {
  42
}
";

/// A corrected, valid workflow with no activity, so a started run completes
/// without a worker. `run` returns the decoded name (or a default).
const VALID_SOURCE: &str = r#"import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode

pub fn run(raw_input: Dynamic) -> Result(String, Nil) {
  case decode.run(raw_input, decode.string) {
    Ok(name) -> Ok("Hello, " <> name)
    Error(_) -> Ok("Hello, world")
  }
}
"#;

/// A second, distinct valid workflow: same entry module (so the same
/// `workflow_type`) but a different `run` body, hence different bytecode and a
/// different content hash than [`VALID_SOURCE`]. Used to prove two overlapping
/// submissions of DIFFERENT source each receive THEIR OWN content hash.
const OTHER_VALID_SOURCE: &str = r#"import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode

pub fn run(raw_input: Dynamic) -> Result(String, Nil) {
  case decode.run(raw_input, decode.string) {
    Ok(name) -> Ok("Goodbye, " <> name <> "!")
    Error(_) -> Ok("Goodbye, world!")
  }
}
"#;

fn gleam_binary() -> Option<PathBuf> {
    let candidate = PathBuf::from("gleam");
    match Command::new(&candidate).arg("--version").output() {
        Ok(output) if output.status.success() => Some(candidate),
        _ => None,
    }
}

/// Absolute path to the repository `examples/` directory.
///
/// The fixture is provisioned here, at the same directory depth (2) as every
/// real example template, so its **relative** `aion_flow = { path =
/// "../../gleam/aion_flow" }` dependency resolves to the real SDK from the
/// staged same-depth working copy — exactly as production does.
fn examples_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples")
}

/// Provisions a built single-workflow Gleam project whose `aion_flow`
/// dependency is the production-shape **relative** path `../../gleam/aion_flow`
/// (mirroring every real example template), placed at the same directory depth
/// as those templates. This makes the server e2e genuinely exercise the
/// same-depth staging that production relies on: an absolute dependency would
/// resolve regardless of staging depth and so would not be load-bearing.
///
/// The temp dir is auto-removed on drop, leaving the repo's `examples/` clean.
fn provision_project() -> Result<tempfile::TempDir, TestError> {
    let dir = tempfile::Builder::new()
        .prefix("aion-authoring-server-e2e-")
        .tempdir_in(examples_dir())?;
    let root = dir.path();

    std::fs::write(
        root.join("gleam.toml"),
        format!(
            "name = \"{ENTRY_MODULE}\"\nversion = \"0.1.0\"\ntarget = \"erlang\"\n\n[dependencies]\naion_flow = {{ path = \"../../gleam/aion_flow\" }}\ngleam_stdlib = \">= 0.34.0 and < 2.0.0\"\ngleam_json = \">= 2.0.0 and < 4.0.0\"\n"
        ),
    )?;
    std::fs::write(
        root.join("workflow.toml"),
        format!(
            "[[workflow]]\nentry_module = \"{ENTRY_MODULE}\"\nentry_function = \"run\"\ntimeout_seconds = 30\ninput_schema = \"schemas/input.json\"\noutput_schema = \"schemas/output.json\"\nactivities = []\noutput = \"fixture.aion\"\n"
        )
        .into_bytes(),
    )?;
    std::fs::create_dir_all(root.join("schemas"))?;
    std::fs::write(root.join("schemas/input.json"), br#"{ "type": "string" }"#)?;
    std::fs::write(root.join("schemas/output.json"), br#"{ "type": "string" }"#)?;
    std::fs::create_dir_all(root.join("src"))?;
    std::fs::write(
        root.join(format!("src/{ENTRY_MODULE}.gleam")),
        b"pub fn run(_raw: a) -> Result(String, Nil) {\n  Ok(\"placeholder\")\n}\n",
    )?;
    Ok(dir)
}

fn runtime_config(authoring: AuthoringConfig) -> RuntimeConfig {
    RuntimeConfig {
        listen: ListenConfig {
            grpc: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
            http: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
        },
        tls: None,
        auth: AuthConfig {
            enabled: false,
            jwks_url: None,
            jwks_refresh_seconds: 300,
        },
        ops_console: OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        },
        namespace: NamespaceConfig {
            mode: NamespaceMode::SharedEngine,
        },
        worker: WorkerConfig {
            heartbeat_window: Duration::from_secs(30),
            ..Default::default()
        },
        websocket: WebSocketConfig {
            outbound_buffer_bound: 32,
            event_broadcast_capacity: Some(64),
            cluster_broadcast_capacity: Some(64),
        },
        workflow_packages: Vec::new(),
        deploy: DeployConfig::default(),
        authoring,
        dev: aion_server::config::DevConfig::default(),
        outbox: aion_server::config::OutboxConfig::default(),
        observability: aion_server::config::ObservabilityConfig::with_flush_policy(64, 0),
        mcp: aion_server::config::ResolvedMcpConfig::default(),
        assistant: aion_server::config::ResolvedAssistantConfig::default(),
        scheduler_threads: 1,
        stop_drain_timeout: Some(std::time::Duration::from_secs(5)),
        jit_threshold: None,
        query_timeout: Some(Duration::from_secs(10)),
        workloop_sweep_interval: Some(Duration::from_millis(50)),
        default_namespace: NAMESPACE.to_owned(),
        auto_create: aion_server::config::AutoCreate::Open,
        max_in_flight_activities: aion_server::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
        drain_timeout: Duration::from_secs(30),
        metrics: MetricsConfig { enabled: true },
        owned_shards: Vec::new(),
        cors_allowed_origins: Vec::new(),
    }
}

/// Builds the engine, the state over it, and the public router.
///
/// The state travels back inside its guard so the engine outlives this helper:
/// the state is what stops the engine, and a guard dropped here would stop it
/// before the test ran a single request. The engine handle rides along for the
/// assertions that read loaded versions and results directly.
async fn server_with(
    authoring: AuthoringConfig,
) -> Result<(Arc<Engine>, StateUnderTest, axum::Router), TestError> {
    let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
    let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
    search_attribute_schema.register(
        aion_server::NAMESPACE_ATTRIBUTE,
        aion_core::SearchAttributeType::String,
    )?;
    let engine = Arc::new(
        EngineBuilder::new()
            .stop_drain_timeout(std::time::Duration::from_secs(5))
            .store_arc(store)
            .in_memory_visibility()
            .search_attribute_schema(search_attribute_schema)
            .scheduler_threads(1)
            .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
                Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
            })
            .build()
            .await?,
    );
    let resolver = NamespaceResolver::from_config(
        NamespaceConfig {
            mode: NamespaceMode::SharedEngine,
        },
        Arc::clone(&engine),
    );
    let server = StateUnderTest::new(ServerState::from_parts(resolver, runtime_config(authoring)));
    let router = http_router(server.state.clone())?;
    Ok((engine, server, router))
}

fn granted_headers(builder: axum::http::request::Builder) -> axum::http::request::Builder {
    builder
        .header("x-aion-subject", "ci")
        .header("x-aion-namespaces", NAMESPACE)
        .header("x-aion-deploy", "true")
}

fn compile_request(source: &str) -> Result<Request<body::Body>, TestError> {
    Ok(granted_headers(
        Request::builder()
            .uri("/authoring/compile")
            .method("POST")
            .header("content-type", "application/json"),
    )
    .body(body::Body::from(serde_json::to_vec(&json!({
        "source": source,
    }))?))?)
}

async fn read_json<T>(response: Response) -> Result<T, TestError>
where
    T: serde::de::DeserializeOwned,
{
    let bytes = body::to_bytes(response.into_body(), usize::MAX).await?;
    Ok(serde_json::from_slice(&bytes)?)
}

async fn read_text(response: Response) -> Result<String, TestError> {
    let bytes = body::to_bytes(response.into_body(), usize::MAX).await?;
    Ok(String::from_utf8(bytes.to_vec())?)
}

/// R2 acceptance #3 / CN7: with `[authoring].gleam_path` absent, every
/// authoring route is a plain 404 — the surface is not mounted.
#[tokio::test]
async fn authoring_absent_is_404_on_every_route() -> Result<(), TestError> {
    let (_engine, server, router) = server_with(AuthoringConfig::default()).await?;

    let cases = [
        ("POST", "/authoring/compile"),
        ("GET", "/authoring/compile"),
        ("POST", "/authoring/anything"),
    ];
    for (method, uri) in cases {
        let response = router
            .clone()
            .oneshot(
                granted_headers(Request::builder().method(method).uri(uri))
                    .body(body::Body::empty())?,
            )
            .await?;
        assert_eq!(
            response.status(),
            StatusCode::NOT_FOUND,
            "{method} {uri} must be 404 when authoring is dark"
        );
    }
    server.shutdown()?;
    Ok(())
}

/// R2 acceptance #2 / C14 / S8: with `[authoring].gleam_path` configured, a
/// type-erroneous submission returns the gleam error inline (HTTP 400), then a
/// corrected submission packages, hot-loads (the new version appears in the
/// engine's loaded versions), and a subsequent start runs it.
#[tokio::test]
async fn authoring_compiles_loads_and_runs_a_corrected_workflow() -> Result<(), TestError> {
    let Some(gleam) = gleam_binary() else {
        eprintln!(
            "(PROVES NOTHING): SKIP authoring_compiles_loads_and_runs_a_corrected_workflow: `gleam` binary not runnable"
        );
        return Ok(());
    };
    let project = provision_project()?;
    let authoring = AuthoringConfig {
        gleam_path: Some(gleam),
        project_root: Some(project.path().to_path_buf()),
        workspace_dir: None,
    };
    let (engine, server, router) = server_with(authoring).await?;

    // 1. Type-erroneous source -> 400 carrying the gleam error inline (C13).
    let type_error = router
        .clone()
        .oneshot(compile_request(TYPE_ERROR_SOURCE)?)
        .await?;
    if type_error.status() == StatusCode::SERVICE_UNAVAILABLE
        || type_error.status() == StatusCode::INTERNAL_SERVER_ERROR
    {
        // gleam could not run in this environment (dependency resolution
        // sandbox); skip rather than fail a product assertion.
        eprintln!(
            "(PROVES NOTHING): SKIP authoring_compiles_loads_and_runs_a_corrected_workflow: gleam build unavailable in this environment ({})",
            type_error.status()
        );
        return Ok(());
    }
    assert_eq!(
        type_error.status(),
        StatusCode::BAD_REQUEST,
        "a type error must be a 400"
    );
    let body = read_text(type_error).await?;
    assert!(
        body.to_lowercase().contains("error"),
        "the gleam error must travel back inline: {body}"
    );
    assert!(
        engine.list_workflow_versions()?.is_empty(),
        "a type error must not load any version"
    );

    // 2. Corrected source -> packages + hot-loads (C14).
    let corrected = router
        .clone()
        .oneshot(compile_request(VALID_SOURCE)?)
        .await?;
    assert_eq!(
        corrected.status(),
        StatusCode::OK,
        "a corrected workflow must compile and hot-load"
    );
    let loaded: serde_json::Value = read_json(corrected).await?;
    assert_eq!(loaded["workflow_type"], json!(ENTRY_MODULE));
    assert!(
        loaded["content_hash"]
            .as_str()
            .is_some_and(|hash| !hash.is_empty()),
        "the response must carry a content hash: {loaded}"
    );

    let versions = engine.list_workflow_versions()?;
    assert!(
        versions
            .iter()
            .any(|info| info.workflow_type == ENTRY_MODULE),
        "the hot-loaded version must appear in the engine's loaded versions"
    );

    // 3. A start runs on the new version and completes (S8).
    let (workflow_id, run_id) = start_over_http(&router).await?;
    let result = engine
        .result(&workflow_id, &run_id)
        .await?
        .map_err(|error| format!("workflow failed: {error:?}"))?;
    let rendered = String::from_utf8_lossy(result.bytes()).into_owned();
    assert!(
        rendered.contains("authoring"),
        "the hot-loaded workflow must run and return its computed result over the decoded input, got: {rendered}"
    );
    server.shutdown()?;
    Ok(())
}

/// Per-submission isolation over the wire (the BEST-solution property): two
/// OVERLAPPING `POST /authoring/compile` submissions of DIFFERENT source,
/// against the one operator-configured (read-only) template, each get back
/// THEIR OWN `content_hash` — proving no cross-talk and no wrong-artifact
/// return when concurrent authors race on the shared template. Both load into
/// the engine as distinct versions of the same workflow type, and the template
/// is left pristine.
#[tokio::test]
async fn concurrent_submissions_return_their_own_content_hash() -> Result<(), TestError> {
    let Some(gleam) = gleam_binary() else {
        eprintln!(
            "(PROVES NOTHING): SKIP concurrent_submissions_return_their_own_content_hash: `gleam` binary not runnable"
        );
        return Ok(());
    };
    let project = provision_project()?;
    let template_root = project.path().to_path_buf();
    let authoring = AuthoringConfig {
        gleam_path: Some(gleam),
        project_root: Some(template_root.clone()),
        workspace_dir: None,
    };
    let (engine, server, router) = server_with(authoring).await?;

    // Fire BOTH submissions concurrently against the shared template. If the
    // template were the mutable build root, these two would race on its
    // entry-file, build/ dir, and .aion output and could return the wrong
    // artifact; per-submission isolation makes them independent.
    let first_router = router.clone();
    let second_router = router.clone();
    let first_body = compile_request(VALID_SOURCE)?;
    let second_body = compile_request(OTHER_VALID_SOURCE)?;
    let (first, second) = tokio::join!(
        first_router.oneshot(first_body),
        second_router.oneshot(second_body),
    );
    let first = first?;
    let second = second?;

    // An environment that cannot resolve gleam dependencies skips, exactly like
    // the single-submission e2e above.
    for (label, status) in [("first", first.status()), ("second", second.status())] {
        if status == StatusCode::SERVICE_UNAVAILABLE || status == StatusCode::INTERNAL_SERVER_ERROR
        {
            eprintln!(
                "(PROVES NOTHING): SKIP concurrent_submissions_return_their_own_content_hash: gleam build unavailable in this environment ({label}: {status})"
            );
            return Ok(());
        }
    }
    assert_eq!(
        first.status(),
        StatusCode::OK,
        "the first concurrent submission must compile and hot-load"
    );
    assert_eq!(
        second.status(),
        StatusCode::OK,
        "the second concurrent submission must compile and hot-load"
    );

    let first: serde_json::Value = read_json(first).await?;
    let second: serde_json::Value = read_json(second).await?;

    // Both are the same workflow type (same template entry module)...
    assert_eq!(first["workflow_type"], json!(ENTRY_MODULE));
    assert_eq!(second["workflow_type"], json!(ENTRY_MODULE));

    let first_hash = first["content_hash"]
        .as_str()
        .ok_or("first response missing content hash")?;
    let second_hash = second["content_hash"]
        .as_str()
        .ok_or("second response missing content hash")?;
    assert!(!first_hash.is_empty(), "first content hash must be present");
    assert!(
        !second_hash.is_empty(),
        "second content hash must be present"
    );

    // ...but DIFFERENT content hashes: each author got back exactly their own
    // artifact, never the other's (no cross-talk, no wrong-artifact return).
    assert_ne!(
        first_hash, second_hash,
        "two concurrent submissions of different source must each return their OWN content hash"
    );

    // Both versions are loaded in the engine — distinct content hashes of one
    // workflow type, the live-authoring loop's shape.
    let versions = engine.list_workflow_versions()?;
    let loaded_hashes: Vec<String> = versions
        .iter()
        .filter(|info| info.workflow_type == ENTRY_MODULE)
        .map(|info| info.content_hash.to_string())
        .collect();
    assert!(
        loaded_hashes.iter().any(|hash| hash == first_hash),
        "the first submission's version is loaded: {loaded_hashes:?}"
    );
    assert!(
        loaded_hashes.iter().any(|hash| hash == second_hash),
        "the second submission's version is loaded: {loaded_hashes:?}"
    );

    // The operator-provisioned template is read-only at request time: no build
    // artifacts leaked into it despite two concurrent submissions.
    assert!(
        !template_root.join("fixture.aion").exists(),
        "the read-only template carries no .aion after concurrent submissions"
    );
    assert!(
        !template_root.join("build").exists(),
        "the read-only template carries no build/ dir after concurrent submissions"
    );
    server.shutdown()?;
    Ok(())
}

async fn start_over_http(router: &axum::Router) -> Result<(WorkflowId, RunId), TestError> {
    let request = granted_headers(
        Request::builder()
            .uri("/workflows/start")
            .method("POST")
            .header("content-type", "application/json"),
    )
    .body(body::Body::from(serde_json::to_vec(&json!({
        "namespace": NAMESPACE,
        "workflow_type": ENTRY_MODULE,
        "input": "authoring",
    }))?))?;
    let response = router.clone().oneshot(request).await?;
    assert_eq!(response.status(), StatusCode::OK, "start must succeed");
    // Clean wire contract: start response exposes plain UUID strings.
    let body: serde_json::Value = read_json(response).await?;
    let workflow_id = body["workflow_id"]
        .as_str()
        .ok_or("start response missing workflow id")?
        .parse::<uuid::Uuid>()?;
    let run_id = body["run_id"]
        .as_str()
        .ok_or("start response missing run id")?
        .parse::<uuid::Uuid>()?;
    Ok((WorkflowId::new(workflow_id), RunId::new(run_id)))
}