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_proto::WireError;
19use aion_toolchain::{CompileRequest, ToolchainError, compile_source};
20use serde::{Deserialize, Serialize};
21
22use super::error::AuthoringApiError;
23use crate::config::{AUTHORING_GLEAM_PATH_EMPTY, AUTHORING_PROJECT_ROOT_REQUIRED};
24use crate::{CallerIdentity, ServerState};
25
26/// Request to compile, type-check, and hot-load submitted Gleam source.
27///
28/// Strict parsing (`deny_unknown_fields`, consistent with the server config
29/// surfaces): an unrecognised field is a 400, never silently ignored, so a
30/// typo in the submission body fails loudly instead of being dropped.
31#[derive(Clone, Debug, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct CompileSourceRequest {
34    /// The Gleam workflow source written verbatim into a fresh per-submission
35    /// working copy of the server's configured authoring project template,
36    /// into its single entry-module file before building. The toolchain never
37    /// rewrites it.
38    pub source: String,
39}
40
41/// Response for a successful compile-and-hot-load.
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43pub struct CompileSourceResponse {
44    /// The workflow type (the manifest entry module) that was loaded.
45    pub workflow_type: String,
46    /// The content hash of the loaded package version.
47    pub content_hash: String,
48    /// The deployed (content-hash-namespaced) entry module name.
49    pub deployed_entry_module: String,
50    /// The entry function spawned for this version.
51    pub entry_function: String,
52    /// True when this call registered the version; false on idempotent re-load.
53    pub freshly_loaded: bool,
54    /// True when this call re-pointed the type's route at the version.
55    pub route_changed: bool,
56}
57
58/// Compiles, type-checks, and hot-loads submitted Gleam source.
59///
60/// # Errors
61///
62/// Returns [`AuthoringApiError::Wire`] for authorization denials and
63/// misconfiguration, [`AuthoringApiError::Unavailable`] during drain or
64/// engine shutdown, [`AuthoringApiError::TypeError`] (carrying the verbatim
65/// gleam diagnostics) when the source does not compile, and
66/// [`AuthoringApiError::Wire`] for spawn, packaging, or load failures.
67pub async fn compile_and_load(
68    state: &ServerState,
69    caller: &CallerIdentity,
70    transport: &'static str,
71    request: CompileSourceRequest,
72) -> Result<CompileSourceResponse, AuthoringApiError> {
73    authorize_mutation(state, caller, transport)?;
74    let (gleam_path, template_root) = authoring_paths(state)?;
75
76    let compiled = run_compile(gleam_path, template_root, request.source).await?;
77    let engine = engine_handle(state)?;
78    match engine.load_package(compiled.package).await {
79        Ok(outcome) => {
80            let workflow_type = outcome.record.workflow_type().to_owned();
81            let content_hash = outcome.record.version().to_string();
82            tracing::info!(
83                operation = "authoring.compile",
84                subject = caller.subject(),
85                grant_source = caller.grant_source().label(),
86                transport,
87                workflow_type = %workflow_type,
88                content_hash = %content_hash,
89                outcome = "loaded",
90                freshly_loaded = outcome.freshly_loaded,
91                route_changed = outcome.route_changed,
92                "authoring compile-and-load applied"
93            );
94            Ok(CompileSourceResponse {
95                workflow_type,
96                content_hash,
97                deployed_entry_module: outcome.record.deployed_entry_module().to_owned(),
98                entry_function: outcome.record.entry_function().to_owned(),
99                freshly_loaded: outcome.freshly_loaded,
100                route_changed: outcome.route_changed,
101            })
102        }
103        Err(error) => Err(map_load_failure(caller, transport, error)),
104    }
105}
106
107/// Authorization plus drain gate, reusing the deploy guard: hot-loading new
108/// code is new-work admission, gated exactly like a deploy mutation (ADR-002:
109/// no second authorization mechanism).
110fn authorize_mutation(
111    state: &ServerState,
112    caller: &CallerIdentity,
113    transport: &'static str,
114) -> Result<(), AuthoringApiError> {
115    let guard = state.deploy_guard();
116    if let Err(error) = guard.authorize(caller) {
117        let wire = error.to_wire_error();
118        tracing::warn!(
119            operation = "authoring.compile",
120            subject = caller.subject(),
121            grant_source = caller.grant_source().label(),
122            transport,
123            reason = %wire.message,
124            "authoring operation denied"
125        );
126        return Err(AuthoringApiError::Wire(wire));
127    }
128    if state.drain_state().is_draining() {
129        return Err(AuthoringApiError::Unavailable(WireError::backend(
130            "server is draining and not accepting authoring submissions",
131        )));
132    }
133    Ok(())
134}
135
136/// Resolves the operator-configured authoring paths, failing loudly if the
137/// surface was mounted without them (a wiring bug, never a caller error).
138fn authoring_paths(state: &ServerState) -> Result<(PathBuf, PathBuf), AuthoringApiError> {
139    let authoring = &state.runtime_config().authoring;
140    let Some(gleam_path) = authoring.gleam_path.clone() else {
141        return Err(AuthoringApiError::Wire(WireError::backend(
142            AUTHORING_GLEAM_PATH_EMPTY,
143        )));
144    };
145    let Some(project_root) = authoring.project_root.clone() else {
146        return Err(AuthoringApiError::Wire(WireError::backend(
147            AUTHORING_PROJECT_ROOT_REQUIRED,
148        )));
149    };
150    Ok((gleam_path, project_root))
151}
152
153/// Runs the synchronous, multi-second compile-and-package off the async
154/// runtime in a blocking task, then maps the toolchain outcome onto the
155/// authoring wire classes.
156///
157/// The toolchain stages its own per-submission working copy of the read-only
158/// `template_root`, so concurrent blocking tasks never collide on the template.
159async fn run_compile(
160    gleam_path: PathBuf,
161    template_root: PathBuf,
162    source: String,
163) -> Result<aion_toolchain::CompiledWorkflow, AuthoringApiError> {
164    let join = tokio::task::spawn_blocking(move || {
165        compile_source(&CompileRequest {
166            template_root: &template_root,
167            gleam_path: &gleam_path,
168            source: &source,
169        })
170    })
171    .await;
172    match join {
173        Ok(Ok(compiled)) => Ok(compiled),
174        Ok(Err(error)) => Err(map_toolchain_error(error)),
175        Err(join_error) => Err(AuthoringApiError::Wire(WireError::backend(format!(
176            "authoring compile task failed to run: {join_error}"
177        )))),
178    }
179}
180
181/// Maps a toolchain failure onto the authoring wire classes.
182///
183/// A type error is the inline 400; a spawn failure or packaging fault is a
184/// backend/invalid-input wire error naming the cause.
185fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
186    match error {
187        ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
188        ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
189            // Operator-side faults (binary unspawnable, project filesystem
190            // unwritable): backend errors, not caller-correctable input.
191            AuthoringApiError::Wire(
192                WireError::backend(error.to_string()).with_error_type("Toolchain"),
193            )
194        }
195        ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
196            // The source compiled but the project could not be assembled, or
197            // the project layout is unusable: a configuration/input problem.
198            AuthoringApiError::Wire(
199                WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
200            )
201        }
202    }
203}
204
205/// Maps an engine load failure onto the authoring wire classes, mirroring the
206/// deploy load mapping.
207fn map_load_failure(
208    caller: &CallerIdentity,
209    transport: &'static str,
210    error: EngineError,
211) -> AuthoringApiError {
212    let mapped = match error {
213        EngineError::ShuttingDown => AuthoringApiError::Unavailable(
214            WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
215        ),
216        EngineError::Load { .. } => AuthoringApiError::Wire(
217            WireError::invalid_input(error.to_string()).with_error_type("Load"),
218        ),
219        EngineError::Package(_) => AuthoringApiError::Wire(
220            WireError::invalid_input(error.to_string()).with_error_type("Package"),
221        ),
222        other => AuthoringApiError::Wire(crate::ServerError::from(other).to_wire_error()),
223    };
224    tracing::info!(
225        operation = "authoring.compile",
226        subject = caller.subject(),
227        grant_source = caller.grant_source().label(),
228        transport,
229        outcome = mapped.outcome(),
230        "authoring compile-and-load refused at hot-load"
231    );
232    mapped
233}
234
235/// Borrows the engine handle for the authorized authoring operation, reusing
236/// the deploy guard's engine accessor.
237fn engine_handle(state: &ServerState) -> Result<Arc<aion::Engine>, AuthoringApiError> {
238    state
239        .deploy_guard()
240        .engine()
241        .map(Arc::clone)
242        .map_err(|error| AuthoringApiError::Wire(error.to_wire_error()))
243}