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 = timeout;
239    let source = package
240        .source()
241        .iter()
242        .map(|(name, bytes)| (name.clone(), bytes.clone()));
243    let bytes = PackageBuilder::with_source(manifest, package.beams().clone(), source)
244        .with_explicit_timeout_identity()
245        .write_to_bytes()
246        .map_err(|error| package_options_error(&error))?;
247    Package::load_from_bytes(bytes, ExtractionLimits::unbounded())
248        .map_err(|error| package_options_error(&error))
249}
250
251fn package_options_error(error: &aion_package::PackageError) -> AuthoringApiError {
252    AuthoringApiError::Wire(
253        WireError::invalid_input(format!(
254            "AWL manifest options could not be applied: {error}"
255        ))
256        .with_error_type("Package"),
257    )
258}
259
260/// Authorization plus drain gate, reusing the deploy guard: hot-loading new
261/// code is new-work admission, gated exactly like a deploy mutation (ADR-002:
262/// no second authorization mechanism).
263pub(crate) fn admit_mutation(
264    state: &ServerState,
265    caller: &CallerIdentity,
266    transport: &'static str,
267    operation: &'static str,
268) -> Result<(), AuthoringApiError> {
269    let guard = state.deploy_guard();
270    if let Err(error) = guard.authorize(caller) {
271        let wire = error.to_wire_error();
272        tracing::warn!(
273            operation,
274            subject = caller.subject(),
275            grant_source = caller.grant_source().label(),
276            transport,
277            reason = %wire.message,
278            "authoring operation denied"
279        );
280        return Err(AuthoringApiError::Wire(wire));
281    }
282    ensure_not_draining(state)
283}
284
285fn ensure_not_draining(state: &ServerState) -> Result<(), AuthoringApiError> {
286    if state.drain_state().is_draining() {
287        return Err(AuthoringApiError::Unavailable(WireError::backend(
288            "server is draining and not accepting authoring submissions",
289        )));
290    }
291    Ok(())
292}
293
294/// Resolves the operator-configured authoring paths, failing loudly if the
295/// surface was mounted without them (a wiring bug, never a caller error).
296fn authoring_paths(state: &ServerState) -> Result<(PathBuf, PathBuf), AuthoringApiError> {
297    let authoring = &state.runtime_config().authoring;
298    let Some(gleam_path) = authoring.gleam_path.clone() else {
299        return Err(AuthoringApiError::Wire(WireError::backend(
300            AUTHORING_GLEAM_PATH_EMPTY,
301        )));
302    };
303    let Some(project_root) = authoring.project_root.clone() else {
304        return Err(AuthoringApiError::Wire(WireError::backend(
305            AUTHORING_PROJECT_ROOT_REQUIRED,
306        )));
307    };
308    Ok((gleam_path, project_root))
309}
310
311/// Runs the synchronous, multi-second compile-and-package off the async
312/// runtime in a blocking task, then maps the toolchain outcome onto the
313/// authoring wire classes.
314///
315/// The toolchain stages its own per-submission working copy of the read-only
316/// `template_root`, so concurrent blocking tasks never collide on the template.
317async fn run_compile(
318    gleam_path: PathBuf,
319    template_root: PathBuf,
320    source: String,
321    workflow_type: Option<String>,
322) -> Result<aion_toolchain::CompiledWorkflow, AuthoringApiError> {
323    let join = tokio::task::spawn_blocking(move || {
324        let request = CompileRequest {
325            template_root: &template_root,
326            gleam_path: &gleam_path,
327            source: &source,
328        };
329        workflow_type.map_or_else(
330            || compile_source(&request),
331            |entry_module| compile_source_for_entry(&request, &entry_module),
332        )
333    })
334    .await;
335    match join {
336        Ok(Ok(compiled)) => Ok(compiled),
337        Ok(Err(error)) => Err(map_toolchain_error(error)),
338        Err(join_error) => Err(AuthoringApiError::Wire(WireError::backend(format!(
339            "authoring compile task failed to run: {join_error}"
340        )))),
341    }
342}
343
344/// Maps a toolchain failure onto the authoring wire classes.
345///
346/// A type error is the inline 400; a spawn failure or packaging fault is a
347/// backend/invalid-input wire error naming the cause.
348fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
349    match error {
350        ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
351        ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
352            // Operator-side faults (binary unspawnable, project filesystem
353            // unwritable): backend errors, not caller-correctable input.
354            AuthoringApiError::Wire(
355                WireError::backend(error.to_string()).with_error_type("Toolchain"),
356            )
357        }
358        ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
359            // The source compiled but the project could not be assembled, or
360            // the project layout is unusable: a configuration/input problem.
361            AuthoringApiError::Wire(
362                WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
363            )
364        }
365    }
366}
367
368/// Maps an engine load failure onto the authoring wire classes, mirroring the
369/// deploy load mapping.
370fn map_load_failure(
371    caller: &CallerIdentity,
372    transport: &'static str,
373    operation: &'static str,
374    error: EngineError,
375) -> AuthoringApiError {
376    let mapped = match error {
377        EngineError::ShuttingDown => AuthoringApiError::Unavailable(
378            WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
379        ),
380        EngineError::Load { .. } => AuthoringApiError::Wire(
381            WireError::invalid_input(error.to_string()).with_error_type("Load"),
382        ),
383        EngineError::Package(_) => AuthoringApiError::Wire(
384            WireError::invalid_input(error.to_string()).with_error_type("Package"),
385        ),
386        other => AuthoringApiError::Wire(crate::ServerError::from(other).to_wire_error()),
387    };
388    tracing::info!(
389        operation,
390        subject = caller.subject(),
391        grant_source = caller.grant_source().label(),
392        transport,
393        outcome = mapped.outcome(),
394        "authoring compile-and-load refused at hot-load"
395    );
396    mapped
397}
398
399/// Borrows the engine handle for the authorized authoring operation, reusing
400/// the deploy guard's engine accessor.
401fn engine_handle(state: &ServerState) -> Result<Arc<aion::Engine>, AuthoringApiError> {
402    state
403        .deploy_guard()
404        .engine()
405        .map(Arc::clone)
406        .map_err(|error| AuthoringApiError::Wire(error.to_wire_error()))
407}