1use 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#[derive(Clone, Debug, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct CompileSourceRequest {
34 pub source: String,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43pub struct CompileSourceResponse {
44 pub workflow_type: String,
46 pub content_hash: String,
48 pub deployed_entry_module: String,
50 pub entry_function: String,
52 pub freshly_loaded: bool,
54 pub route_changed: bool,
56}
57
58pub 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
107fn 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
136fn 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
153async 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
181fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
186 match error {
187 ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
188 ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
189 AuthoringApiError::Wire(
192 WireError::backend(error.to_string()).with_error_type("Toolchain"),
193 )
194 }
195 ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
196 AuthoringApiError::Wire(
199 WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
200 )
201 }
202 }
203}
204
205fn 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
235fn 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}