aion_toolchain/compile.rs
1//! Core authoring compile loop: stage an isolated working copy of the project
2//! template, write the submitted source into it, run `gleam build` there, and
3//! package on success.
4//!
5//! This module is the clean generalization of the `gleam build` shell-out
6//! already used by the local `aion package` command: it spawns the external
7//! `gleam` binary (no embedded compiler), but captures the compiler's output
8//! instead of inheriting stdio so the diagnostics can travel back over the
9//! wire, and it accepts a configurable binary path so the server can be
10//! pointed at an operator-provided `gleam`.
11//!
12//! # Per-submission isolation
13//!
14//! The configured project root is treated as a **read-only template**. Each
15//! call stages its own throwaway working copy (a [`Workspace`]) and writes,
16//! builds, and packages entirely inside that copy, so concurrent submissions
17//! are fully isolated — no shared entry-file, no shared `build/` directory, no
18//! shared `.aion` output, no global lock, and no pool-size cap (ADR-001). The
19//! working copy is removed when the [`Workspace`] drops, on every path.
20//!
21//! The toolchain never rewrites the author's source: it writes the submitted
22//! bytes verbatim into the entry module's file and packages the build output.
23//! The determinism boundary (invariant 2) is the author's responsibility and
24//! stays untouched here.
25
26use std::path::{Path, PathBuf};
27use std::process::Command;
28
29use aion_package::{Package, PackageOptions, WorkflowVersion, package_project};
30
31use crate::error::ToolchainError;
32use crate::gleam_failure::{GleamFailureLayer, classify_gleam_failure};
33use crate::project;
34use crate::workspace::Workspace;
35
36/// A request to compile, type-check, and package submitted Gleam source.
37pub struct CompileRequest<'a> {
38 /// The built Gleam workflow project **template** the submission is built
39 /// against. It must contain `gleam.toml`, `workflow.toml`, the `aion_flow`
40 /// dependency, and `schemas/` — exactly as `aion new` produces one. It is
41 /// read-only at request time: the toolchain copies it into a fresh
42 /// per-submission workspace and never writes to or builds in it.
43 pub template_root: &'a Path,
44 /// Path to the external `gleam` binary the toolchain spawns. There is no
45 /// default: the caller supplies it (the server resolves it from the
46 /// operator-configured `[authoring].gleam_path`).
47 pub gleam_path: &'a Path,
48 /// The submitted Gleam source written verbatim to the workspace copy's
49 /// single entry-module source file before building.
50 pub source: &'a str,
51}
52
53/// A compiled, type-checked, and packaged workflow.
54#[derive(Clone, Debug)]
55pub struct CompiledWorkflow {
56 /// The verified `.aion` package, re-loaded from disk after writing.
57 pub package: Package,
58 /// The canonical version record of the verified package.
59 pub version: WorkflowVersion,
60 /// The workflow type (the manifest entry module).
61 pub workflow_type: String,
62 /// The absolute path of the written `.aion` archive.
63 pub output_path: PathBuf,
64}
65
66/// Compiles, type-checks, and packages submitted Gleam source against a
67/// read-only project template, in a fresh per-submission workspace.
68///
69/// Validates the template is a usable single-workflow Gleam project, stages an
70/// isolated working copy of it (a [`Workspace`] — a sibling temp dir that is
71/// removed on drop), writes `request.source` into the working copy's single
72/// entry-module source file, runs `gleam build` in the working copy (capturing
73/// its output), and — only on a zero exit — packages the working copy into a
74/// verified `.aion`. The template is never written to or built in, so
75/// concurrent submissions are fully isolated.
76///
77/// This is synchronous and blocks on `gleam build` and packaging, both of
78/// which can run for seconds. Async callers MUST wrap it in a blocking task
79/// (for example `tokio::task::spawn_blocking`).
80///
81/// # Errors
82///
83/// Returns [`ToolchainError::InvalidProject`] when the template is not a usable
84/// single-workflow Gleam project, the entry module name is unsafe, or the
85/// template has no parent directory to host the workspace,
86/// [`ToolchainError::Io`] when the working copy cannot be staged or the source
87/// cannot be written, [`ToolchainError::GleamSpawn`] when the `gleam` binary
88/// cannot be spawned, [`ToolchainError::DependencyLayer`] when `gleam` failed
89/// inside its own dependency layer (so the source was never compiled),
90/// [`ToolchainError::TypeCheck`] (carrying the verbatim compiler diagnostics)
91/// when the build exits non-zero for any other reason, and
92/// [`ToolchainError::Packaging`] when the built project cannot be assembled
93/// into a verified archive.
94pub fn compile_source(request: &CompileRequest<'_>) -> Result<CompiledWorkflow, ToolchainError> {
95 compile_source_with_entry(request, None)
96}
97
98/// Compiles and packages submitted source under an explicit logical entry
99/// module instead of the frozen module declared by the project template.
100///
101/// The override is applied only inside the per-submission workspace: the
102/// template remains read-only, its other manifest policy is preserved, and the
103/// replaced template entry source is excluded from the resulting package. A
104/// document-owned root package name prevents the frozen template name from
105/// entering compiled BEAM paths. A clean root-package build prevents copied
106/// output from leaking stale modules while retaining dependency output. The
107/// generated document-package `@@main` bootstrap is removed before packaging
108/// because it is application shell machinery, not workflow runtime code. The
109/// canonical document name therefore owns module identity and the routing key.
110///
111/// # Errors
112///
113/// Returns the same errors as [`compile_source`], and
114/// [`ToolchainError::InvalidProject`] when `entry_module` is not a supported
115/// Gleam logical module name.
116pub fn compile_source_for_entry(
117 request: &CompileRequest<'_>,
118 entry_module: &str,
119) -> Result<CompiledWorkflow, ToolchainError> {
120 compile_source_with_entry(request, Some(entry_module))
121}
122
123fn compile_source_with_entry(
124 request: &CompileRequest<'_>,
125 requested_entry: Option<&str>,
126) -> Result<CompiledWorkflow, ToolchainError> {
127 // Validate the template up front so a misconfigured project root fails
128 // before the cost of staging a working copy.
129 project::validate_project_root(request.template_root)?;
130 let template_entry = project::single_entry_module(request.template_root)?;
131
132 // Every submission gets its own isolated working copy; the template is
133 // never touched. The workspace (and the captured source within it) is
134 // removed when `workspace` drops at the end of this function — on the
135 // success path and on every `?` early return alike.
136 let workspace = Workspace::stage(request.template_root)?;
137 let workspace_root = workspace.root();
138 let canonical_requested = requested_entry
139 .map(project::canonical_entry_module)
140 .transpose()?;
141 let explicit_entry = canonical_requested.is_some();
142 let entry_module = match &canonical_requested {
143 Some(entry) => entry.as_str(),
144 None => template_entry.as_str(),
145 };
146 if explicit_entry {
147 project::retarget_single_entry_module(workspace_root, entry_module)?;
148 let template_source = project::entry_module_source_path(workspace_root, &template_entry)?;
149 project::remove_entry_source(&template_source)?;
150 // The staged template may be prebuilt. Gleam does not prune BEAMs for
151 // deleted sources, so root-package incremental reuse would retain the
152 // frozen entry. Remove the OLD root output before renaming the package;
153 // dependency output is safe to preserve.
154 project::remove_staged_root_build(workspace_root)?;
155 // Gleam embeds this package name in the entry BEAM's generated-source
156 // path, so it must be document-owned before the clean root build.
157 project::retarget_root_package(workspace_root, entry_module)?;
158 }
159 let source_path = project::entry_module_source_path(workspace_root, entry_module)?;
160 project::write_entry_source(&source_path, request.source)?;
161 if explicit_entry {
162 build_project(workspace_root, request.gleam_path)?;
163 // Even under the document-owned package name, the generated application
164 // bootstrap is neither the workflow entry nor a dependency.
165 project::remove_generated_root_main_beam(workspace_root)?;
166 package_built_project(workspace_root)
167 } else {
168 compile_built_project(workspace_root, request.gleam_path)
169 }
170}
171
172/// Runs `gleam build` against `project_root` then packages it.
173fn compile_built_project(
174 project_root: &Path,
175 gleam_path: &Path,
176) -> Result<CompiledWorkflow, ToolchainError> {
177 build_project(project_root, gleam_path)?;
178 package_built_project(project_root)
179}
180
181/// Compiles and type-checks an on-disk Gleam workflow project in place by
182/// spawning the external `gleam` binary against `project_root`, capturing its
183/// diagnostics instead of inheriting stdio.
184///
185/// This is the single shell-out the toolchain owns: [`compile_source`] calls
186/// it against a per-submission workspace copy, and the local `aion dev` watch
187/// loop calls it directly against the author's project on disk. Neither path
188/// reinvents the `gleam build` invocation or its diagnostic capture.
189///
190/// A non-zero exit carries the verbatim compiler output (stderr, with any
191/// stdout appended): Gleam writes errors to stderr, but context may split
192/// across both streams, so both are captured. It is then CLASSIFIED, because a
193/// non-zero exit is not on its own evidence that the source is at fault —
194/// `gleam build` resolves and fetches dependencies before compiling anything,
195/// so a registry outage exits non-zero having never reached the compiler
196/// ([`ToolchainError::DependencyLayer`], #125).
197///
198/// This is synchronous and blocks on `gleam build`, which can run for seconds;
199/// async callers MUST wrap it in a blocking task.
200///
201/// # Errors
202///
203/// Returns [`ToolchainError::GleamSpawn`] when the `gleam` binary at
204/// `gleam_path` cannot be spawned, [`ToolchainError::DependencyLayer`] when the
205/// non-zero exit came from `gleam`'s own dependency layer, and
206/// [`ToolchainError::TypeCheck`] (carrying the verbatim compiler diagnostics)
207/// for any other non-zero exit.
208pub fn build_project(project_root: &Path, gleam_path: &Path) -> Result<(), ToolchainError> {
209 let output = Command::new(gleam_path)
210 .arg("build")
211 .current_dir(project_root)
212 .output()
213 .map_err(|source| ToolchainError::GleamSpawn {
214 gleam_path: gleam_path.to_path_buf(),
215 source,
216 })?;
217 if output.status.success() {
218 return Ok(());
219 }
220 // 🔴 A non-zero exit is not on its own evidence that the source is at
221 // fault. `gleam build` resolves and fetches dependencies before it compiles
222 // anything, so a registry outage exits non-zero having never reached the
223 // compiler. Reporting that as `TypeCheck` tells an author their code failed
224 // to type-check when it was never type-checked (#125).
225 let diagnostics = combine_diagnostics(&output.stderr, &output.stdout);
226 match classify_gleam_failure(&diagnostics) {
227 GleamFailureLayer::DependencyLayer => Err(ToolchainError::DependencyLayer { diagnostics }),
228 GleamFailureLayer::NotDependencyLayer => Err(ToolchainError::TypeCheck { diagnostics }),
229 }
230}
231
232/// Joins captured stderr and stdout into the inline diagnostics string.
233///
234/// Stderr leads (Gleam's errors land there); stdout is appended only when it
235/// carries content, separated by a blank line.
236fn combine_diagnostics(stderr: &[u8], stdout: &[u8]) -> String {
237 let stderr = String::from_utf8_lossy(stderr);
238 let stdout = String::from_utf8_lossy(stdout);
239 let stderr_trimmed = stderr.trim_end();
240 let stdout_trimmed = stdout.trim_end();
241 match (stderr_trimmed.is_empty(), stdout_trimmed.is_empty()) {
242 (false, false) => format!("{stderr_trimmed}\n\n{stdout_trimmed}"),
243 (false, true) => stderr_trimmed.to_owned(),
244 (true, false) => stdout_trimmed.to_owned(),
245 (true, true) => {
246 "gleam build failed with no diagnostic output on stderr or stdout".to_owned()
247 }
248 }
249}
250
251/// Packages the built project into a verified single-workflow `.aion`.
252fn package_built_project(project_root: &Path) -> Result<CompiledWorkflow, ToolchainError> {
253 let report = package_project(project_root, &PackageOptions::default())?;
254 let mut built = report.packages;
255 let packaged = match built.len() {
256 1 => built.remove(0),
257 count => {
258 return Err(ToolchainError::InvalidProject {
259 message: format!(
260 "authoring project packaged {count} workflows; source submission requires exactly one"
261 ),
262 });
263 }
264 };
265 Ok(CompiledWorkflow {
266 workflow_type: packaged.workflow_type,
267 output_path: packaged.output_path,
268 version: packaged.version,
269 package: packaged.package,
270 })
271}
272
273#[cfg(test)]
274mod tests {
275 use super::combine_diagnostics;
276
277 #[test]
278 fn diagnostics_prefer_stderr_and_append_stdout() {
279 assert_eq!(combine_diagnostics(b"type error\n", b""), "type error");
280 assert_eq!(combine_diagnostics(b"", b"compiling\n"), "compiling");
281 assert_eq!(
282 combine_diagnostics(b"type error\n", b"compiling demo\n"),
283 "type error\n\ncompiling demo"
284 );
285 assert_eq!(
286 combine_diagnostics(b"", b""),
287 "gleam build failed with no diagnostic output on stderr or stdout"
288 );
289 }
290}