1use 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#[derive(Clone, Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CompileSourceRequest {
36 pub source: String,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
45pub struct CompileSourceResponse {
46 pub workflow_type: String,
48 pub content_hash: String,
50 pub deployed_entry_module: String,
52 pub entry_function: String,
54 pub freshly_loaded: bool,
56 pub route_changed: bool,
58}
59
60pub 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
85pub 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
102pub 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
159pub(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
175pub(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 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
272pub(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
306fn 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
323async 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
356fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
363 match error {
364 ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
365 ToolchainError::DependencyLayer { .. } => {
366 AuthoringApiError::Unavailable(
375 WireError::backend(error.to_string()).with_error_type("GleamRegistry"),
376 )
377 }
378 ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
379 AuthoringApiError::Wire(
382 WireError::backend(error.to_string()).with_error_type("Toolchain"),
383 )
384 }
385 ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
386 AuthoringApiError::Wire(
389 WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
390 )
391 }
392 }
393}
394
395fn 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
426fn 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 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 #[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 #[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 #[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 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}