use std::{
path::{Path, PathBuf},
process::Command,
};
use aion_package::{ExcludedModule, PackageOptions, ProjectReport, package_project};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::Value;
use crate::output::to_value;
#[derive(Serialize)]
struct PackageOutput<'a> {
packages: Vec<PackagedOutput>,
excluded: &'a [ExcludedModule],
#[serde(skip_serializing_if = "Vec::is_empty")]
unstartable: Vec<UnstartableWorkflow>,
}
#[derive(Serialize)]
struct UnstartableWorkflow {
workflow_type: String,
unscoped_activities: Vec<String>,
reason: &'static str,
remedy: &'static str,
}
#[derive(Serialize)]
struct PackagedOutput {
workflow_type: String,
output: String,
version: String,
deployed_name: String,
modules: usize,
}
pub(crate) fn run(path: &Path, out: Option<&Path>, build: bool) -> Result<Value> {
if build {
run_gleam_build(path)?;
}
let options = PackageOptions {
output_override: out.map(absolute_out).transpose()?,
};
let report = package_project(path, &options)
.with_context(|| format!("failed to package workflow project at {}", path.display()))?;
to_value(report_output(&report))
}
fn absolute_out(out: &Path) -> Result<PathBuf> {
std::path::absolute(out)
.with_context(|| format!("failed to resolve --out path {}", out.display()))
}
fn run_gleam_build(path: &Path) -> Result<()> {
let status = Command::new("gleam")
.arg("build")
.current_dir(path)
.status()
.with_context(|| format!("failed to run `gleam build` in {}", path.display()))?;
if !status.success() {
bail!("`gleam build` failed in {} with {status}", path.display());
}
Ok(())
}
fn report_output(report: &ProjectReport) -> PackageOutput<'_> {
PackageOutput {
packages: report
.packages
.iter()
.map(|packaged| PackagedOutput {
workflow_type: packaged.workflow_type.clone(),
output: packaged.output_path.display().to_string(),
version: packaged.version.content_hash.to_string(),
deployed_name: packaged.package.deployed_entry_module(),
modules: packaged.package.beams().len(),
})
.collect(),
excluded: &report.excluded,
unstartable: report.packages.iter().filter_map(unstartable).collect(),
}
}
fn unstartable(packaged: &aion_package::PackagedWorkflow) -> Option<UnstartableWorkflow> {
let contract = packaged.package.contract().ok()?;
unstartable_contract(&packaged.workflow_type, contract)
}
fn unstartable_contract(
workflow_type: &str,
contract: &aion_package::PackageContract,
) -> Option<UnstartableWorkflow> {
if contract.unscoped_activities.is_empty() {
return None;
}
let mut unscoped_activities = contract.unscoped_activities.clone();
unscoped_activities.sort();
Some(UnstartableWorkflow {
workflow_type: workflow_type.to_owned(),
unscoped_activities,
reason: "a project manifest records activities as bare names and has no field for a \
task queue, so every activity above is committed to the package contract as \
UNSCOPED. A server that routes activities through task queues refuses to \
START this workflow with NO_QUEUE_DECLARATION. Packaging and deploying both \
still succeed, so nothing else will tell you",
remedy: "author the workflow as an AWL document, which declares its worker queue and \
compiles to a scoped contract, and deploy that. There is no manifest or Gleam \
change that scopes a project-manifest activity — `activity.task_queue` sets \
the DISPATCH queue on the in-process activity value and never reaches the \
package contract, so it does not clear this refusal",
})
}
#[cfg(test)]
mod tests {
use aion_package::PackageContract;
use serde_json::json;
use super::unstartable_contract;
fn contract(unscoped: &[&str]) -> PackageContract {
PackageContract {
input_schema: json!({ "type": "object" }),
output_schema: json!({ "type": "object" }),
workers: Vec::new(),
children: Vec::new(),
signals: Vec::new(),
additional_workflows: Vec::new(),
unscoped_activities: unscoped.iter().map(|name| (*name).to_owned()).collect(),
workloop: None,
}
}
#[test]
fn a_scoped_contract_is_silent() {
assert!(
unstartable_contract("staged_rounds", &contract(&[])).is_none(),
"a contract with no unscoped activities must produce no warning"
);
}
#[test]
fn an_unscoped_contract_names_every_activity_in_sorted_order() {
let reported = unstartable_contract(
"assistant",
&contract(&["assistant_provision", "assistant"]),
);
let Some(reported) = reported else {
unreachable!("an unscoped contract must produce a warning")
};
assert_eq!(reported.workflow_type, "assistant");
assert_eq!(
reported.unscoped_activities,
vec!["assistant".to_owned(), "assistant_provision".to_owned()]
);
assert!(
reported.reason.contains("NO_QUEUE_DECLARATION"),
"the warning must name the exact refusal it predicts, so a search \
for that error reaches this message"
);
assert!(
reported.remedy.contains("activity.task_queue"),
"the remedy must name the API that LOOKS like the fix and is not — \
that dead end is what makes this expensive"
);
}
}