Skip to main content

aion_server/authoring/
handlers.rs

1//! Transport-agnostic server-side authoring handler.
2//!
3//! `compile_and_load` is the authoring loop in one call: authorize (reusing
4//! the deploy guard — new code admission is gated exactly like a deploy),
5//! refuse during drain, compile and type-check the submitted Gleam source
6//! through [`aion_toolchain`] (which only spawns the external `gleam` binary),
7//! and on success hot-load the resulting package into the running engine via
8//! `engine.load_package`. A type error returns the gleam diagnostics inline.
9//!
10//! Mounted only when `[authoring].gleam_path` is configured; with it absent
11//! the routes do not exist, the server deploys pre-built `.aion` files only,
12//! and nothing here is ever reached (CN7).
13
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use aion::EngineError;
18use aion_awl_package::AwlAssembleOptions;
19use aion_package::{ExtractionLimits, Package, PackageBuilder};
20use aion_proto::WireError;
21use aion_toolchain::{CompileRequest, ToolchainError, compile_source, compile_source_for_entry};
22use serde::{Deserialize, Serialize};
23
24use super::error::AuthoringApiError;
25use crate::config::{AUTHORING_GLEAM_PATH_EMPTY, AUTHORING_PROJECT_ROOT_REQUIRED};
26use crate::{CallerIdentity, ServerState};
27
28/// Request to compile, type-check, and hot-load submitted Gleam source.
29///
30/// Strict parsing (`deny_unknown_fields`, consistent with the server config
31/// surfaces): an unrecognised field is a 400, never silently ignored, so a
32/// typo in the submission body fails loudly instead of being dropped.
33#[derive(Clone, Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CompileSourceRequest {
36    /// The Gleam workflow source written verbatim into a fresh per-submission
37    /// working copy of the server's configured authoring project template,
38    /// into its single entry-module file before building. The toolchain never
39    /// rewrites it.
40    pub source: String,
41}
42
43/// Response for a successful compile-and-hot-load.
44#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
45pub struct CompileSourceResponse {
46    /// The workflow type (the manifest entry module) that was loaded.
47    pub workflow_type: String,
48    /// The content hash of the loaded package version.
49    pub content_hash: String,
50    /// The deployed (content-hash-namespaced) entry module name.
51    pub deployed_entry_module: String,
52    /// The entry function spawned for this version.
53    pub entry_function: String,
54    /// True when this call registered the version; false on idempotent re-load.
55    pub freshly_loaded: bool,
56    /// True when this call re-pointed the type's route at the version.
57    pub route_changed: bool,
58}
59
60/// Compiles, type-checks, and hot-loads submitted Gleam source.
61///
62/// # Errors
63///
64/// Returns [`AuthoringApiError::Wire`] for authorization denials and
65/// misconfiguration, [`AuthoringApiError::Unavailable`] during drain or
66/// engine shutdown, [`AuthoringApiError::TypeError`] (carrying the verbatim
67/// gleam diagnostics) when the source does not compile, and
68/// [`AuthoringApiError::Wire`] for spawn, packaging, or load failures.
69pub async fn compile_and_load(
70    state: &ServerState,
71    caller: &CallerIdentity,
72    transport: &'static str,
73    request: CompileSourceRequest,
74) -> Result<CompileSourceResponse, AuthoringApiError> {
75    compile_and_load_with_options(
76        state,
77        caller,
78        transport,
79        request,
80        AwlAssembleOptions::default(),
81    )
82    .await
83}
84
85/// Compiles and hot-loads submitted Gleam source while applying AWL-native
86/// manifest options after the frozen project compiler has packaged it.
87///
88/// # Errors
89///
90/// Returns the same failures as [`compile_and_load`], plus a package error if
91/// applying the AWL manifest timeout cannot round-trip the built archive.
92pub async fn compile_and_load_with_options(
93    state: &ServerState,
94    caller: &CallerIdentity,
95    transport: &'static str,
96    request: CompileSourceRequest,
97    options: AwlAssembleOptions,
98) -> Result<CompileSourceResponse, AuthoringApiError> {
99    compile_and_load_inner(state, caller, transport, request, options, None).await
100}
101
102/// Compiles and hot-loads an emitted AWL document under the workflow type
103/// declared by that document rather than the authoring template's frozen entry.
104///
105/// `workflow_type` must come from the parsed document header. It becomes the
106/// staged Gleam module path, package manifest entry module, engine workflow
107/// type, and sole routing target for the load.
108///
109/// # Errors
110///
111/// Returns the same failures as [`compile_and_load_with_options`], plus an
112/// invalid-project error if `workflow_type` cannot name a Gleam module.
113pub async fn compile_and_load_document(
114    state: &ServerState,
115    caller: &CallerIdentity,
116    transport: &'static str,
117    request: CompileSourceRequest,
118    workflow_type: String,
119    options: AwlAssembleOptions,
120) -> Result<CompileSourceResponse, AuthoringApiError> {
121    compile_and_load_inner(
122        state,
123        caller,
124        transport,
125        request,
126        options,
127        Some(workflow_type),
128    )
129    .await
130}
131
132async fn compile_and_load_inner(
133    state: &ServerState,
134    caller: &CallerIdentity,
135    transport: &'static str,
136    request: CompileSourceRequest,
137    options: AwlAssembleOptions,
138    workflow_type: Option<String>,
139) -> Result<CompileSourceResponse, AuthoringApiError> {
140    admit_mutation(state, caller, transport, "authoring.compile")?;
141    let (gleam_path, template_root) = authoring_paths(state)?;
142    let expected_workflow_type = workflow_type.clone();
143    let mut compiled =
144        run_compile(gleam_path, template_root, request.source, workflow_type).await?;
145    if let Some(expected) = expected_workflow_type {
146        validate_document_identity(&compiled.package, &expected)?;
147    }
148    compiled.package = package_with_options(compiled.package, &options)?;
149    load_authorized_package(
150        state,
151        caller,
152        transport,
153        "authoring.compile",
154        compiled.package,
155    )
156    .await
157}
158
159/// Hot-loads a package after the caller has passed [`admit_mutation`].
160///
161/// Authorization is stable in the request's [`CallerIdentity`] and is not
162/// repeated. Drain state is mutable, so it is re-checked immediately before
163/// the engine load to close a drain transition during direct compilation.
164pub(crate) async fn load_admitted_package(
165    state: &ServerState,
166    caller: &CallerIdentity,
167    transport: &'static str,
168    operation: &'static str,
169    package: Package,
170) -> Result<CompileSourceResponse, AuthoringApiError> {
171    ensure_not_draining(state)?;
172    load_authorized_package(state, caller, transport, operation, package).await
173}
174
175/// Verifies document-owned package identity before any engine load can mutate
176/// the catalog or routing table.
177pub(crate) fn validate_document_identity(
178    package: &Package,
179    expected: &str,
180) -> Result<(), AuthoringApiError> {
181    let actual = &package.manifest().entry_module;
182    if actual == expected {
183        return Ok(());
184    }
185    Err(AuthoringApiError::Wire(
186        WireError::backend(format!(
187            "document compile returned manifest entry module `{actual}` instead of `{expected}`"
188        ))
189        .with_error_type("Toolchain"),
190    ))
191}
192
193async fn load_authorized_package(
194    state: &ServerState,
195    caller: &CallerIdentity,
196    transport: &'static str,
197    operation: &'static str,
198    package: Package,
199) -> Result<CompileSourceResponse, AuthoringApiError> {
200    let engine = engine_handle(state)?;
201    match engine.load_package(package).await {
202        Ok(outcome) => {
203            let workflow_type = outcome.record.workflow_type().to_owned();
204            let content_hash = outcome.record.version().to_string();
205            tracing::info!(
206                operation,
207                subject = caller.subject(),
208                grant_source = caller.grant_source().label(),
209                transport,
210                workflow_type = %workflow_type,
211                content_hash = %content_hash,
212                outcome = "loaded",
213                freshly_loaded = outcome.freshly_loaded,
214                route_changed = outcome.route_changed,
215                "authoring compile-and-load applied"
216            );
217            Ok(CompileSourceResponse {
218                workflow_type,
219                content_hash,
220                deployed_entry_module: outcome.record.deployed_entry_module().to_owned(),
221                entry_function: outcome.record.entry_function().to_owned(),
222                freshly_loaded: outcome.freshly_loaded,
223                route_changed: outcome.route_changed,
224            })
225        }
226        Err(error) => Err(map_load_failure(caller, transport, operation, error)),
227    }
228}
229
230pub(crate) fn package_with_options(
231    package: Package,
232    options: &AwlAssembleOptions,
233) -> Result<Package, AuthoringApiError> {
234    let Some(timeout) = options.timeout else {
235        return Ok(package);
236    };
237    let mut manifest = package.manifest().clone();
238    manifest.timeout = Some(timeout);
239    let contract = package.contract().map_err(|error| {
240        AuthoringApiError::Wire(WireError::invalid_input(format!(
241            "AWL manifest options require a `.v4` package contract: {error}"
242        )))
243    })?;
244    let source = package
245        .source()
246        .iter()
247        .map(|(name, bytes)| (name.clone(), bytes.clone()));
248    let mut builder = PackageBuilder::with_source(manifest, package.beams().clone(), source)
249        .with_contract(contract.clone());
250    // This path REBUILDS the archive from the loaded package's fields, so every
251    // provenance family has to be carried across explicitly or applying a
252    // manifest option would quietly strip it.
253    if let Some(awl) = package.awl() {
254        builder = builder.with_awl_source(awl.clone());
255    }
256    let bytes = builder
257        .write_to_bytes()
258        .map_err(|error| package_options_error(&error))?;
259    Package::load_from_bytes(bytes, ExtractionLimits::unbounded())
260        .map_err(|error| package_options_error(&error))
261}
262
263fn package_options_error(error: &aion_package::PackageError) -> AuthoringApiError {
264    AuthoringApiError::Wire(
265        WireError::invalid_input(format!(
266            "AWL manifest options could not be applied: {error}"
267        ))
268        .with_error_type("Package"),
269    )
270}
271
272/// Authorization plus drain gate, reusing the deploy guard: hot-loading new
273/// code is new-work admission, gated exactly like a deploy mutation (ADR-002:
274/// no second authorization mechanism).
275pub(crate) fn admit_mutation(
276    state: &ServerState,
277    caller: &CallerIdentity,
278    transport: &'static str,
279    operation: &'static str,
280) -> Result<(), AuthoringApiError> {
281    let guard = state.deploy_guard();
282    if let Err(error) = guard.authorize(caller) {
283        let wire = error.to_wire_error();
284        tracing::warn!(
285            operation,
286            subject = caller.subject(),
287            grant_source = caller.grant_source().label(),
288            transport,
289            reason = %wire.message,
290            "authoring operation denied"
291        );
292        return Err(AuthoringApiError::Wire(wire));
293    }
294    ensure_not_draining(state)
295}
296
297fn ensure_not_draining(state: &ServerState) -> Result<(), AuthoringApiError> {
298    if state.drain_state().is_draining() {
299        return Err(AuthoringApiError::Unavailable(WireError::backend(
300            "server is draining and not accepting authoring submissions",
301        )));
302    }
303    Ok(())
304}
305
306/// Resolves the operator-configured authoring paths, failing loudly if the
307/// surface was mounted without them (a wiring bug, never a caller error).
308fn authoring_paths(state: &ServerState) -> Result<(PathBuf, PathBuf), AuthoringApiError> {
309    let authoring = &state.runtime_config().authoring;
310    let Some(gleam_path) = authoring.gleam_path.clone() else {
311        return Err(AuthoringApiError::Wire(WireError::backend(
312            AUTHORING_GLEAM_PATH_EMPTY,
313        )));
314    };
315    let Some(project_root) = authoring.project_root.clone() else {
316        return Err(AuthoringApiError::Wire(WireError::backend(
317            AUTHORING_PROJECT_ROOT_REQUIRED,
318        )));
319    };
320    Ok((gleam_path, project_root))
321}
322
323/// Runs the synchronous, multi-second compile-and-package off the async
324/// runtime in a blocking task, then maps the toolchain outcome onto the
325/// authoring wire classes.
326///
327/// The toolchain stages its own per-submission working copy of the read-only
328/// `template_root`, so concurrent blocking tasks never collide on the template.
329async fn run_compile(
330    gleam_path: PathBuf,
331    template_root: PathBuf,
332    source: String,
333    workflow_type: Option<String>,
334) -> Result<aion_toolchain::CompiledWorkflow, AuthoringApiError> {
335    let join = tokio::task::spawn_blocking(move || {
336        let request = CompileRequest {
337            template_root: &template_root,
338            gleam_path: &gleam_path,
339            source: &source,
340        };
341        workflow_type.map_or_else(
342            || compile_source(&request),
343            |entry_module| compile_source_for_entry(&request, &entry_module),
344        )
345    })
346    .await;
347    match join {
348        Ok(Ok(compiled)) => Ok(compiled),
349        Ok(Err(error)) => Err(map_toolchain_error(error)),
350        Err(join_error) => Err(AuthoringApiError::Wire(WireError::backend(format!(
351            "authoring compile task failed to run: {join_error}"
352        )))),
353    }
354}
355
356/// Maps a toolchain failure onto the authoring wire classes.
357///
358/// A type error is the inline 400; a dependency-layer failure is the retryable
359/// 503, because the source was never compiled and nothing about it is in
360/// question; a spawn failure or packaging fault is a backend/invalid-input wire
361/// error naming the cause.
362fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
363    match error {
364        ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
365        ToolchainError::DependencyLayer { .. } => {
366            // 🔴 NOT a 400, and not a type error (#125). `gleam build` resolves
367            // and fetches dependencies before it compiles anything, so a
368            // registry outage exits non-zero having never reached the compiler.
369            // Routing that to `TypeError` told the author two untrue things at
370            // once: that type-checking produced these diagnostics, and — by the
371            // 400 — that their submission was the thing at fault. Nothing was
372            // established about the source. This is the server's environment
373            // failing and it is transient, so it is the retryable 503.
374            AuthoringApiError::Unavailable(
375                WireError::backend(error.to_string()).with_error_type("GleamRegistry"),
376            )
377        }
378        ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
379            // Operator-side faults (binary unspawnable, project filesystem
380            // unwritable): backend errors, not caller-correctable input.
381            AuthoringApiError::Wire(
382                WireError::backend(error.to_string()).with_error_type("Toolchain"),
383            )
384        }
385        ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
386            // The source compiled but the project could not be assembled, or
387            // the project layout is unusable: a configuration/input problem.
388            AuthoringApiError::Wire(
389                WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
390            )
391        }
392    }
393}
394
395/// Maps an engine load failure onto the authoring wire classes, mirroring the
396/// deploy load mapping.
397fn map_load_failure(
398    caller: &CallerIdentity,
399    transport: &'static str,
400    operation: &'static str,
401    error: EngineError,
402) -> AuthoringApiError {
403    let mapped = match error {
404        EngineError::ShuttingDown => AuthoringApiError::Unavailable(
405            WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
406        ),
407        EngineError::Load { .. } => AuthoringApiError::Wire(
408            WireError::invalid_input(error.to_string()).with_error_type("Load"),
409        ),
410        EngineError::Package(_) => AuthoringApiError::Wire(
411            WireError::invalid_input(error.to_string()).with_error_type("Package"),
412        ),
413        other => AuthoringApiError::Wire(crate::ServerError::from(other).to_wire_error()),
414    };
415    tracing::info!(
416        operation,
417        subject = caller.subject(),
418        grant_source = caller.grant_source().label(),
419        transport,
420        outcome = mapped.outcome(),
421        "authoring compile-and-load refused at hot-load"
422    );
423    mapped
424}
425
426/// Borrows the engine handle for the authorized authoring operation, reusing
427/// the deploy guard's engine accessor.
428fn engine_handle(state: &ServerState) -> Result<Arc<aion::Engine>, AuthoringApiError> {
429    state
430        .deploy_guard()
431        .engine()
432        .map(Arc::clone)
433        .map_err(|error| AuthoringApiError::Wire(error.to_wire_error()))
434}
435
436#[cfg(test)]
437mod tests {
438    use aion_toolchain::error::ToolchainError;
439
440    use super::{AuthoringApiError, map_toolchain_error};
441
442    /// The exact `gleam` output that opened #125, from run 3 of the 2026-08-01
443    /// workspace battery.
444    const HEX_OUTAGE: &str = "  Resolving versions\nerror: HTTP error\n\nA HTTP request \
445         failed.\n\n    error sending request for url \
446         (https://hex.pm/api/packages/gleam_stdlib/releases/1.0.3)\n";
447
448    /// 🔴 THE INVERTED CONTROL FOR #125. Before the fix, a registry outage
449    /// arrived here as `ToolchainError::TypeCheck` and left as
450    /// `AuthoringApiError::TypeError` — an inline **400** telling the author
451    /// their submission was at fault, for a build that never reached the
452    /// compiler. Two untrue statements in one response: that type-checking
453    /// produced these diagnostics, and that the caller's input was wrong.
454    #[test]
455    fn a_registry_outage_is_never_reported_to_the_author_as_a_type_error() {
456        let mapped = map_toolchain_error(ToolchainError::DependencyLayer {
457            diagnostics: HEX_OUTAGE.to_owned(),
458        });
459        assert!(
460            !matches!(mapped, AuthoringApiError::TypeError(_)),
461            "a registry outage must never be an inline 400 type error: the source was never \
462             compiled, so nothing about it has been established"
463        );
464        assert!(
465            matches!(mapped, AuthoringApiError::Unavailable(_)),
466            "a registry outage is a transient environment failure — the retryable 503"
467        );
468        assert_eq!(mapped.outcome(), "unavailable");
469    }
470
471    /// The positive arm, under the same mapping. Without it, the assertion
472    /// above would still pass if `map_toolchain_error` stopped producing
473    /// `TypeError` for anything at all — a vacuous absence check.
474    #[test]
475    fn a_real_type_error_is_still_the_inline_400() {
476        let mapped = map_toolchain_error(ToolchainError::TypeCheck {
477            diagnostics: "error: Type mismatch\n  expected Int, got String".to_owned(),
478        });
479        assert!(
480            matches!(
481                &mapped,
482                AuthoringApiError::TypeError(diagnostics) if diagnostics.contains("Type mismatch")
483            ),
484            "a genuine type error must stay the inline 400 carrying its diagnostics, got {mapped:?}"
485        );
486    }
487
488    /// The registry diagnostics must survive the mapping: an operator debugging
489    /// a 503 needs `gleam`'s own words, and a class label alone would send them
490    /// hunting.
491    #[test]
492    fn the_registry_failure_carries_gleams_own_output_to_the_operator() {
493        let mapped = map_toolchain_error(ToolchainError::DependencyLayer {
494            diagnostics: HEX_OUTAGE.to_owned(),
495        });
496        // Three separate assertions rather than one compound: each failure mode
497        // reports distinctly, so a red says WHICH guarantee broke.
498        assert!(
499            matches!(&mapped, AuthoringApiError::Unavailable(_)),
500            "a registry outage must be the retryable class, got {mapped:?}"
501        );
502        assert!(
503            matches!(&mapped, AuthoringApiError::Unavailable(wire)
504                if wire.message.contains("hex.pm")),
505            "the registry endpoint must survive into the wire error: {mapped:?}"
506        );
507        assert!(
508            matches!(&mapped, AuthoringApiError::Unavailable(wire)
509                if wire.message.contains("never compiled")),
510            "the message must say the source was never compiled: {mapped:?}"
511        );
512    }
513}