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 = 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
260pub(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
294fn 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
311async 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
344fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
349 match error {
350 ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
351 ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
352 AuthoringApiError::Wire(
355 WireError::backend(error.to_string()).with_error_type("Toolchain"),
356 )
357 }
358 ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
359 AuthoringApiError::Wire(
362 WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
363 )
364 }
365 }
366}
367
368fn 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
399fn 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}