use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use aion_client::{Client, ClientBuilder, ClientError, ListRequest};
use aion_core::{Payload, WorkflowStatus};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde_json::Value;
use crate::output::{
AcknowledgementOutput, QueryOutput, RetireOutput, describe_output, pause_output, print_json,
reopen_output, resume_output, to_value,
};
use crate::payload::{
json_payload, parse_run_id, parse_status, parse_workflow_id, payload_to_json,
};
use crate::start::{start_workflow, start_workflow_handle};
mod awl;
mod awl_doc;
mod awl_guide;
mod awl_recipe;
mod awl_scaffold;
mod boot_narration;
mod check_deterministic;
mod console;
mod deploy;
mod dev;
mod generate;
mod handover;
mod harness;
mod history;
mod input;
mod inspect;
mod launch;
mod new;
mod one_motion;
mod output;
mod package;
mod payload;
mod render;
mod server;
mod server_restart;
mod server_status;
mod server_stop;
mod settings;
mod start;
mod store_harden;
mod tail;
mod update;
mod update_index;
mod worker_agent;
mod worker_awl;
mod worker_managed;
mod worker_shell;
mod worker_surface;
const DEFAULT_ENDPOINT: &str = "127.0.0.1:50051";
const DEFAULT_NAMESPACE: &str = "default";
const DEFAULT_SUBJECT: &str = "cli-user";
const QUERY_DEADLINE: Duration = Duration::from_secs(5);
#[derive(Debug, Parser)]
#[command(
name = "aion",
version,
about = "Operate Aion workflows over gRPC and run the Aion workflow server. \
Bare `aion` starts the server in the background and opens the ops console."
)]
struct Cli {
#[arg(long, global = true)]
endpoint: Option<String>,
#[arg(long, global = true, default_value = DEFAULT_NAMESPACE)]
namespace: String,
#[arg(long, global = true, default_value = DEFAULT_SUBJECT)]
subject: String,
#[arg(long, global = true)]
token: Option<String>,
#[arg(long, global = true)]
pretty: bool,
#[arg(long)]
foreground: bool,
#[arg(long)]
no_open: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Server(server::ServerCommand),
Stop(crate::server_stop::StopArgs),
Update(crate::update::UpdateArgs),
Store(store_harden::StoreCommand),
Dev(dev::DevArgs),
Awl {
#[command(subcommand)]
command: awl::AwlCommand,
},
Worker {
#[command(subcommand)]
command: WorkerCommand,
},
#[command(flatten)]
Client(ClientCommand),
}
#[derive(Debug, Subcommand)]
enum WorkerCommand {
Shell(worker_shell::ShellArgs),
Awl(worker_awl::AwlWorkerArgs),
Agent(Box<worker_agent::AgentArgs>),
Status,
Start {
name: String,
},
Stop {
name: String,
},
Restart {
name: String,
},
}
#[derive(Debug, Subcommand)]
enum ClientCommand {
New(new::NewArgs),
Generate {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
check: bool,
},
Check {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
deterministic: bool,
},
Input {
workflow_type: String,
#[arg(default_value = ".")]
path: PathBuf,
},
Package {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
build: bool,
},
Deploy {
archive: PathBuf,
},
Run {
file: PathBuf,
#[arg(long)]
input: String,
#[arg(long, value_parser = one_motion::parse_await_timeout)]
timeout: Option<Duration>,
},
Versions {
#[arg(long)]
workflow_type: Option<String>,
},
Route {
workflow_type: String,
content_hash: String,
},
Unload {
workflow_type: String,
content_hash: String,
},
#[command(flatten)]
Remote(RemoteCommand),
}
#[derive(Debug, Subcommand)]
enum RemoteCommand {
Start {
workflow_type: String,
#[arg(long, default_value = "null")]
input: String,
#[arg(long, conflicts_with = "input")]
input_file: Option<PathBuf>,
#[arg(long = "name")]
display_name: Option<String>,
},
Signal {
workflow_id: String,
signal_name: String,
#[arg(long)]
run_id: Option<String>,
#[arg(long, default_value = "null")]
payload: String,
},
Query {
workflow_id: String,
query_name: String,
#[arg(long)]
run_id: Option<String>,
#[arg(long, default_value = "null")]
arguments: String,
},
Cancel {
workflow_id: String,
#[arg(long, default_value = "cancelled by aion")]
reason: String,
#[arg(long)]
run_id: Option<String>,
},
Retire {
workflow_id: String,
#[arg(long, default_value = "retired by aion")]
reason: String,
},
Reopen {
workflow_id: String,
#[arg(long)]
run_id: Option<String>,
},
Pause {
workflow_id: String,
#[arg(long, default_value = "")]
reason: String,
#[arg(long)]
run_id: Option<String>,
},
Resume {
workflow_id: String,
#[arg(long)]
run_id: Option<String>,
},
List {
#[arg(long, value_parser = parse_status)]
status: Option<WorkflowStatus>,
#[arg(long)]
limit: Option<u32>,
#[arg(long, requires = "limit")]
cursor: Option<String>,
},
Describe {
workflow_id: String,
#[arg(long)]
run_id: Option<String>,
#[arg(long)]
raw: bool,
},
Tail(tail::TailArgs),
Inspect {
workflow_id: String,
#[arg(long)]
run_id: Option<String>,
#[arg(long)]
from: Option<u64>,
#[arg(long)]
mock: Option<String>,
},
}
const MIGRATED_LAUNCH_FLAGS: [(&str, &str); 14] = [
("--harness", "kind"),
("--concurrency", "concurrency"),
("--reconnect-initial-backoff", "reconnect_initial_backoff"),
("--reconnect-max-backoff", "reconnect_max_backoff"),
("--reconnect-max-attempts", "reconnect_max_attempts"),
("--acp-command", "command"),
("--acp-arg", "args"),
("--acp-cwd", "cwd"),
("--acp-permission", "permission"),
("--acp-env", "env_pass"),
("--acp-exit-grace", "exit_grace"),
("--norn-binary", "binary"),
("--norn-arg", "args"),
("--norn-unset-env", "env_pass"),
];
const MIGRATED_FLAG_VERB: [&str; 2] = ["worker", "agent"];
fn invokes_migrated_flag_verb(argv: &[String]) -> bool {
argv.windows(MIGRATED_FLAG_VERB.len())
.any(|pair| pair == MIGRATED_FLAG_VERB)
}
fn migrated_flag_advice(argv: &[String]) -> Option<String> {
if !invokes_migrated_flag_verb(argv) {
return None;
}
let supplied: Vec<&(&str, &str)> = MIGRATED_LAUNCH_FLAGS
.iter()
.filter(|(flag, _)| {
argv.iter().any(|argument| {
argument == flag
|| argument
.split_once('=')
.is_some_and(|(head, _)| head == *flag)
})
})
.collect();
if supplied.is_empty() {
return None;
}
let moved = supplied
.iter()
.map(|(flag, field)| format!(" {flag} -> `{field}` in the document's `harness` section"))
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"\nThese are not unknown arguments — they are DELETED ones. An agent worker's launch \
is declared in the `.awl` document it serves, in the `harness` section of its \
`worker` block, and there is no flag for any of it:\n\n{moved}\n\nIf you typed this \
command: write the section into the document, then re-run without those flags. \
`aion awl check <FILE.awl>` names every setting the section is still missing, scoped \
to the kind the section declares.\n\nIf you did NOT type this command, a managed-worker \
deployment replayed it. A `builtin` worker-deployment record stores the argv it was \
created with, verbatim and durably, and the supervisor replays exactly that on every \
restart — so a record minted before the launch settings moved will fail this way on \
every restart until its budget is spent, and editing the document will not change it. \
The record is the thing to fix: add the `harness` section to the document, then \
re-mint the deployment (`PUT /worker-deployments/<name>`, or the ops console's deploy \
form) so its stored argv no longer carries these flags."
))
}
fn parse_or_refuse() -> Result<Cli, ExitCode> {
match Cli::try_parse() {
Ok(cli) => Ok(cli),
Err(error) => {
let advice = migrated_flag_advice(&std::env::args().collect::<Vec<_>>());
drop(error.print());
if let Some(advice) = advice {
eprintln!("{advice}");
}
Err(ExitCode::from(u8::try_from(error.exit_code()).unwrap_or(2)))
}
}
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = match parse_or_refuse() {
Ok(cli) => cli,
Err(code) => return code,
};
if let Some(refusal) = launcher_flag_refusal(&cli) {
eprintln!("{refusal}");
return ExitCode::from(2);
}
match cli.command {
None => {
if let Some(refusal) = server_endpoint_refusal(cli.endpoint.as_deref()) {
eprintln!("{refusal}");
return ExitCode::from(2);
}
launch::run(cli.foreground, cli.no_open).await
}
Some(Command::Server(ref command)) => server_command(command, &cli).await,
Some(Command::Stop(ref args)) => {
if let Some(refusal) =
control_verb_endpoint_refusal("stop", "stop", cli.endpoint.as_deref())
{
eprintln!("{refusal}");
return ExitCode::from(2);
}
server_stop::run(args)
}
Some(Command::Update(ref args)) => {
if let Some(refusal) =
control_verb_endpoint_refusal("update", "update", cli.endpoint.as_deref())
{
eprintln!("{refusal}");
return ExitCode::from(2);
}
update::run(args).await
}
Some(Command::Store(ref command)) => match command.command {
store_harden::StoreSubcommand::Harden(ref args) => store_harden::harden(args),
},
Some(Command::Dev(ref args)) => {
let result = dev::run(args, deploy_target(&cli))
.await
.and_then(|value| print_json(&value, cli.pretty));
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{}", render::render_error(&error));
ExitCode::FAILURE
}
}
}
Some(Command::Awl { ref command }) => awl::run(command),
Some(Command::Worker { ref command }) => {
let result = match command {
WorkerCommand::Shell(args) => worker_shell::run(args, &cli.endpoint()).await,
WorkerCommand::Awl(args) => worker_awl::run(args, &cli.endpoint()).await,
WorkerCommand::Agent(args) => worker_agent::run(args, &cli.namespace).await,
WorkerCommand::Status => worker_managed::status(&deploy_target(&cli))
.await
.and_then(|value| print_json(&value, cli.pretty)),
WorkerCommand::Start { name } => worker_managed::start(&deploy_target(&cli), name)
.await
.and_then(|value| print_json(&value, cli.pretty)),
WorkerCommand::Stop { name } => worker_managed::stop(&deploy_target(&cli), name)
.await
.and_then(|value| print_json(&value, cli.pretty)),
WorkerCommand::Restart { name } => {
worker_managed::restart(&deploy_target(&cli), name)
.await
.and_then(|value| print_json(&value, cli.pretty))
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{}", render::render_error(&error));
ExitCode::FAILURE
}
}
}
Some(Command::Client(ref command)) => run_client_command(&cli, command).await,
}
}
async fn run_client_command(cli: &Cli, command: &ClientCommand) -> ExitCode {
let result = run(cli, command).await.and_then(|value| {
if matches!(command, ClientCommand::Remote(RemoteCommand::Tail(_))) {
Ok(())
} else {
print_json(&value, cli.pretty)
}
});
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
report_client_error(&error);
ExitCode::FAILURE
}
}
}
async fn run(cli: &Cli, command: &ClientCommand) -> Result<Value> {
match command {
ClientCommand::New(args) => new::run(args),
ClientCommand::Generate { path, check } => generate::run(path, *check),
ClientCommand::Check {
path,
deterministic,
} => check_deterministic::run(path, *deterministic),
ClientCommand::Input {
workflow_type,
path,
} => input::run(path, workflow_type),
ClientCommand::Package { path, out, build } => package::run(path, out.as_deref(), *build),
ClientCommand::Deploy { archive } => {
if one_motion::is_awl(archive) {
let (_, _, bytes) = one_motion::package_file(archive)?;
deploy::deploy_bytes(&deploy_target(cli), bytes).await
} else {
deploy::deploy(&deploy_target(cli), archive).await
}
}
ClientCommand::Run {
file,
input,
timeout,
} => {
let prepared = one_motion::prepare_run(file, input)?;
deploy::deploy_bytes(&deploy_target(cli), prepared.archive).await?;
let client = build_client(cli).await?;
let handle =
start_workflow_handle(&client, &prepared.workflow_name, prepared.input).await?;
one_motion::await_result(handle, &prepared.workflow_name, *timeout).await
}
ClientCommand::Versions { workflow_type } => {
deploy::versions(&deploy_target(cli), workflow_type.as_deref()).await
}
ClientCommand::Route {
workflow_type,
content_hash,
} => deploy::route(&deploy_target(cli), workflow_type, content_hash).await,
ClientCommand::Unload {
workflow_type,
content_hash,
} => deploy::unload(&deploy_target(cli), workflow_type, content_hash).await,
ClientCommand::Remote(command) => {
let client = build_client(cli).await?;
execute(&client, command, cli).await
}
}
}
fn deploy_target(cli: &Cli) -> deploy::DeployTarget {
deploy::DeployTarget::new(
cli.endpoint(),
cli.subject.clone(),
deploy::resolve_token(cli.token.clone()),
)
}
fn report_client_error(error: &anyhow::Error) {
if let Some(diagnostic) = error.downcast_ref::<aion_awl::CompileError>() {
eprintln!("{diagnostic}");
} else if let Some(refusal) = error.downcast_ref::<one_motion::InputValidationError>() {
eprintln!("{refusal}");
} else {
eprintln!("{}", render::render_error(error));
}
}
async fn build_client(cli: &Cli) -> Result<Client> {
ClientBuilder::new(cli.endpoint())
.with_namespace(cli.namespace.clone())
.with_subject(cli.subject.clone())
.with_authorized_namespaces([cli.namespace.clone()])
.build()
.await
.context("failed to connect to Aion server")
}
async fn execute(client: &Client, command: &RemoteCommand, cli: &Cli) -> Result<Value> {
match command {
RemoteCommand::Start {
workflow_type,
input,
input_file,
display_name,
} => {
let input = resolve_start_input(input, input_file.as_deref())?;
start_workflow(client, workflow_type, input, display_name.clone()).await
}
RemoteCommand::Signal {
workflow_id,
signal_name,
run_id,
payload,
} => signal_workflow(client, workflow_id, signal_name, run_id.as_deref(), payload).await,
RemoteCommand::Query {
workflow_id,
query_name,
run_id,
arguments,
} => {
query_workflow(
client,
workflow_id,
query_name,
run_id.as_deref(),
arguments,
)
.await
}
RemoteCommand::Cancel {
workflow_id,
reason,
run_id,
} => cancel_workflow(client, workflow_id, reason, run_id.as_deref()).await,
RemoteCommand::Retire {
workflow_id,
reason,
} => retire_workloop(client, workflow_id, reason).await,
RemoteCommand::Reopen {
workflow_id,
run_id,
} => reopen_workflow(client, workflow_id, run_id.as_deref()).await,
RemoteCommand::Pause {
workflow_id,
reason,
run_id,
} => pause_workflow(client, workflow_id, reason, run_id.as_deref()).await,
RemoteCommand::Resume {
workflow_id,
run_id,
} => resume_workflow(client, workflow_id, run_id.as_deref()).await,
RemoteCommand::List {
status,
limit,
cursor,
} => list_workflows(client, *status, *limit, cursor.clone()).await,
RemoteCommand::Describe {
workflow_id,
run_id,
raw,
} => describe_workflow(client, workflow_id, run_id.as_deref(), *raw).await,
RemoteCommand::Tail(args) => tail::dispatch(client, args, cli).await,
RemoteCommand::Inspect {
workflow_id,
run_id,
from,
mock,
} => {
inspect::run(
client,
workflow_id,
run_id.as_deref(),
*from,
mock.as_deref(),
)
.await
}
}
}
fn resolve_start_input(input: &str, input_file: Option<&Path>) -> Result<Payload> {
match input_file {
Some(path) => {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read --input-file {}", path.display()))?;
json_payload(&raw)
.with_context(|| format!("invalid --input-file {} JSON", path.display()))
}
None => json_payload(input).context("invalid --input JSON"),
}
}
async fn signal_workflow(
client: &Client,
workflow_id: &str,
signal_name: &str,
run_id: Option<&str>,
payload: &str,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
let payload = json_payload(payload).context("invalid --payload JSON")?;
client
.signal(
&workflow_id,
run_id.as_ref(),
signal_name.to_owned(),
payload,
)
.await
.context("failed to signal workflow")?;
to_value(AcknowledgementOutput {
workflow_id: &workflow_id.to_string(),
accepted: true,
})
}
async fn query_workflow(
client: &Client,
workflow_id: &str,
query_name: &str,
run_id: Option<&str>,
arguments: &str,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
let arguments = json_payload(arguments).context("invalid query arguments")?;
let payload = client
.query(
&workflow_id,
run_id.as_ref(),
query_name.to_owned(),
arguments,
QUERY_DEADLINE,
)
.await
.context("failed to query workflow")?;
to_value(QueryOutput {
result: payload_to_json(&payload)?,
})
}
async fn cancel_workflow(
client: &Client,
workflow_id: &str,
reason: &str,
run_id: Option<&str>,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
client
.cancel(&workflow_id, run_id.as_ref(), reason.to_owned())
.await
.context("failed to cancel workflow")?;
to_value(AcknowledgementOutput {
workflow_id: &workflow_id.to_string(),
accepted: true,
})
}
async fn retire_workloop(client: &Client, workflow_id: &str, reason: &str) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let recorded = client
.retire_workloop(&workflow_id, reason.to_owned())
.await
.context("failed to retire workloop")?;
to_value(RetireOutput {
workflow_id: &workflow_id.to_string(),
retired: true,
reason: &recorded,
})
}
async fn reopen_workflow(
client: &Client,
workflow_id: &str,
run_id: Option<&str>,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
let outcome = client
.reopen(&workflow_id, run_id.as_ref())
.await
.map_err(|error| reopen_error(&workflow_id.to_string(), error))?;
to_value(reopen_output(&workflow_id.to_string(), &outcome))
}
fn reopen_error(workflow_id: &str, error: ClientError) -> anyhow::Error {
match error {
ClientError::InvalidState { detail } => {
anyhow::anyhow!(
"workflow {workflow_id} is not reopenable: {}",
detail.message
)
}
ClientError::NotFound { .. } => anyhow::anyhow!("workflow {workflow_id} not found"),
other => anyhow::anyhow!("failed to reopen workflow {workflow_id}: {other}"),
}
}
async fn pause_workflow(
client: &Client,
workflow_id: &str,
reason: &str,
run_id: Option<&str>,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
let outcome = client
.pause(&workflow_id, run_id.as_ref(), reason.to_owned())
.await
.map_err(|error| pause_error(&workflow_id.to_string(), error))?;
to_value(pause_output(&workflow_id.to_string(), &outcome))
}
async fn resume_workflow(
client: &Client,
workflow_id: &str,
run_id: Option<&str>,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
let outcome = client
.resume(&workflow_id, run_id.as_ref())
.await
.map_err(|error| resume_error(&workflow_id.to_string(), error))?;
to_value(resume_output(&workflow_id.to_string(), &outcome))
}
fn pause_error(workflow_id: &str, error: ClientError) -> anyhow::Error {
match error {
ClientError::InvalidState { detail } => {
anyhow::anyhow!(
"workflow {workflow_id} cannot be paused: {}",
detail.message
)
}
ClientError::NotFound { .. } => anyhow::anyhow!("workflow {workflow_id} not found"),
other => anyhow::anyhow!("failed to pause workflow {workflow_id}: {other}"),
}
}
fn resume_error(workflow_id: &str, error: ClientError) -> anyhow::Error {
match error {
ClientError::InvalidState { detail } => {
anyhow::anyhow!(
"workflow {workflow_id} cannot be resumed: {}",
detail.message
)
}
ClientError::NotFound { .. } => anyhow::anyhow!("workflow {workflow_id} not found"),
other => anyhow::anyhow!("failed to resume workflow {workflow_id}: {other}"),
}
}
const LIST_WALK_CHUNK: u32 = 500;
async fn list_workflows(
client: &Client,
status: Option<WorkflowStatus>,
limit: Option<u32>,
cursor: Option<String>,
) -> Result<Value> {
let request = |cursor: Option<String>, limit: u32| ListRequest {
filter: aion_core::WorkflowListFilter {
statuses: status.into_iter().collect(),
..aion_core::WorkflowListFilter::default()
},
sort: aion_core::WorkflowSort {
field: aion_core::WorkflowSortField::StartedAt,
direction: aion_core::SortDirection::Desc,
},
cursor,
limit,
request_id: None,
};
if let Some(limit) = limit {
let page = client
.list(request(cursor, limit))
.await
.context("failed to list workflows")?;
return to_value(page);
}
let mut walked = client
.list(request(None, LIST_WALK_CHUNK))
.await
.context("failed to list workflows")?;
while let Some(next) = walked.next_cursor.take() {
let page = client
.list(request(Some(next), LIST_WALK_CHUNK))
.await
.context("failed to list workflows")?;
walked.items.extend(page.items);
walked.next_cursor = page.next_cursor;
walked.count = page.count;
walked.provenance = page.provenance;
}
to_value(walked)
}
async fn describe_workflow(
client: &Client,
workflow_id: &str,
run_id: Option<&str>,
raw: bool,
) -> Result<Value> {
let workflow_id = parse_workflow_id(workflow_id)?;
let run_id = run_id.map(parse_run_id).transpose()?;
let description = client
.describe(&workflow_id, run_id.as_ref())
.await
.context("failed to describe workflow")?;
describe_output(
description.summary,
description.run_id,
description.history_head_seq,
description.terminal_event,
description.provenance,
raw,
)
}
async fn server_command(command: &server::ServerCommand, cli: &Cli) -> ExitCode {
match &command.command {
Some(server::ServerSubcommand::Stop(args)) => {
if let Some(refusal) =
control_verb_endpoint_refusal("stop", "stop", cli.endpoint.as_deref())
{
eprintln!("{refusal}");
return ExitCode::from(2);
}
server_stop::run(args)
}
Some(server::ServerSubcommand::Status(args)) => {
if let Some(refusal) =
control_verb_endpoint_refusal("status", "report status", cli.endpoint.as_deref())
{
eprintln!("{refusal}");
return ExitCode::from(2);
}
server_status::run(args).await
}
Some(server::ServerSubcommand::Restart(args)) => {
if let Some(refusal) =
control_verb_endpoint_refusal("restart", "restart", cli.endpoint.as_deref())
{
eprintln!("{refusal}");
return ExitCode::from(2);
}
server_restart::run(args).await
}
None => {
let args = &command.run;
if let Some(refusal) = server_endpoint_refusal(cli.endpoint.as_deref()) {
eprintln!("{refusal}");
return ExitCode::from(2);
}
harness::announce_composed_harness();
if args.open() {
server::spawn_browser_open(&args.clone().into());
}
aion_server::run(args.clone().into()).await
}
}
}
fn control_verb_endpoint_refusal(
verb: &str,
action: &str,
endpoint: Option<&str>,
) -> Option<String> {
endpoint.map(|endpoint| {
format!(
"refusing to {action}: `--endpoint {endpoint}` names a server a client dials, and \
`aion server {verb}` is local — it reads this machine's AION_HOME pid record and \
acts on that process. To operate a remote server, run the verb on that machine \
(its home decides which server answers)."
)
})
}
fn server_endpoint_refusal(endpoint: Option<&str>) -> Option<String> {
endpoint.map(|endpoint| {
format!(
"refusing to start: `--endpoint {endpoint}` names an address a CLIENT dials, and \
`aion server` cannot bind it. Use `--grpc-address <ADDR>` (or \
AION_SERVER_GRPC_ADDRESS, or [server].grpc_address) to choose the gRPC listener, \
and `--listen-address <ADDR>` for HTTP."
)
})
}
fn launcher_flag_refusal(cli: &Cli) -> Option<String> {
if cli.command.is_none() || (!cli.foreground && !cli.no_open) {
return None;
}
let named = match (cli.foreground, cli.no_open) {
(true, true) => "`--foreground` and `--no-open` configure",
(true, false) => "`--foreground` configures",
(false, _) => "`--no-open` configures",
};
Some(format!(
"refusing to start: {named} the bare `aion` launcher and cannot be combined with a \
subcommand. `aion server` already runs in the foreground, and `aion server --open` \
opts into the browser."
))
}
impl Cli {
fn endpoint(&self) -> String {
normalize_endpoint(self.endpoint.as_deref().unwrap_or(DEFAULT_ENDPOINT))
}
}
fn normalize_endpoint(endpoint: &str) -> String {
if endpoint.contains("://") {
endpoint.to_owned()
} else {
format!("http://{endpoint}")
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use aion_core::Payload;
use aion_server::config::CliOverrides;
use clap::{CommandFactory, Parser};
use serde_json::{Value, json};
use crate::{payload::json_payload, tail};
use super::{
Cli, ClientCommand, Command, DEFAULT_ENDPOINT, RemoteCommand, awl,
control_verb_endpoint_refusal, normalize_endpoint, payload_to_json, resolve_start_input,
server_endpoint_refusal,
};
const WORKFLOW_ID: &str = "00000000-0000-0000-0000-000000000001";
const RUN_ID: &str = "00000000-0000-0000-0000-000000000002";
#[test]
fn start_reads_input_payload_from_file() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("payload.json");
std::fs::write(&path, r#"{"order_id":42}"#)?;
let cli = Cli::try_parse_from([
std::ffi::OsStr::new("aion"),
std::ffi::OsStr::new("start"),
std::ffi::OsStr::new("order_saga"),
std::ffi::OsStr::new("--input-file"),
path.as_os_str(),
])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Start {
input,
input_file,
..
}))) = cli.command
else {
anyhow::bail!("expected start command");
};
assert_eq!(input, "null");
assert_eq!(input_file.as_deref(), Some(path.as_path()));
let payload = resolve_start_input(&input, input_file.as_deref())?;
assert_eq!(payload_to_json(&payload)?, json!({ "order_id": 42 }));
Ok(())
}
#[test]
fn start_without_input_file_keeps_inline_input() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "start", "order_saga"])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Start {
input,
input_file,
..
}))) = cli.command
else {
anyhow::bail!("expected start command");
};
assert!(input_file.is_none());
let payload = resolve_start_input(&input, input_file.as_deref())?;
assert_eq!(payload_to_json(&payload)?, Value::Null);
Ok(())
}
#[test]
fn start_reads_the_display_name_flag() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"start",
"order_saga",
"--name",
"Nightly settlement",
])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Start {
display_name, ..
}))) = cli.command
else {
anyhow::bail!("expected start command");
};
assert_eq!(display_name.as_deref(), Some("Nightly settlement"));
let cli = Cli::try_parse_from(["aion", "start", "order_saga"])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Start {
display_name, ..
}))) = cli.command
else {
anyhow::bail!("expected start command");
};
assert_eq!(display_name, None, "an unnamed start selects no label");
Ok(())
}
#[test]
fn start_rejects_input_and_input_file_together() {
assert!(
Cli::try_parse_from([
"aion",
"start",
"order_saga",
"--input",
"{}",
"--input-file",
"payload.json",
])
.is_err()
);
}
#[test]
fn start_missing_input_file_error_names_the_path() -> anyhow::Result<()> {
let path = Path::new("/definitely/not/there/payload.json");
let Err(error) = resolve_start_input("null", Some(path)) else {
anyhow::bail!("missing file must error");
};
assert!(
format!("{error:#}").contains("/definitely/not/there/payload.json"),
"error should name the path: {error:#}"
);
Ok(())
}
#[test]
fn start_malformed_input_file_json_error_names_the_flag_and_path() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("payload.json");
std::fs::write(&path, "{not json")?;
let Err(error) = resolve_start_input("null", Some(&path)) else {
anyhow::bail!("malformed file JSON must error");
};
let message = format!("{error:#}");
assert!(
message.contains(&format!("invalid --input-file {} JSON", path.display())),
"error should name --input-file and the path: {message}"
);
assert!(
!message.contains("invalid --input JSON"),
"error must not blame the inline flag: {message}"
);
Ok(())
}
#[test]
fn start_malformed_inline_input_json_error_names_the_flag() -> anyhow::Result<()> {
let Err(error) = resolve_start_input("{not json", None) else {
anyhow::bail!("malformed inline JSON must error");
};
let message = format!("{error:#}");
assert!(
message.contains("invalid --input JSON"),
"error should name --input: {message}"
);
Ok(())
}
#[test]
fn describe_accepts_optional_run_id() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "describe", WORKFLOW_ID, "--run-id", RUN_ID])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Describe {
workflow_id,
run_id,
raw,
}))) = cli.command
else {
anyhow::bail!("expected describe command");
};
assert_eq!(workflow_id, WORKFLOW_ID);
assert_eq!(run_id.as_deref(), Some(RUN_ID));
assert!(!raw);
Ok(())
}
#[test]
fn tail_accepts_parent_id_without_any_child_id() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "tail", WORKFLOW_ID, "--no-follow"])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Tail(args)))) = cli.command
else {
anyhow::bail!("expected tail command");
};
assert_eq!(args.workflow_id, WORKFLOW_ID);
assert_eq!(args.run_id, None);
assert_eq!(args.http_endpoint, tail::default_http_endpoint());
assert!(args.no_follow);
Ok(())
}
#[test]
fn describe_accepts_raw_payload_flag() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "describe", WORKFLOW_ID, "--raw"])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Describe { raw, .. }))) =
cli.command
else {
anyhow::bail!("expected describe command");
};
assert!(raw);
Ok(())
}
#[test]
fn reopen_parses_workflow_id_and_optional_run_id() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "reopen", WORKFLOW_ID])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Reopen {
workflow_id,
run_id,
}))) = cli.command
else {
anyhow::bail!("expected reopen command");
};
assert_eq!(workflow_id, WORKFLOW_ID);
assert!(run_id.is_none());
let cli = Cli::try_parse_from(["aion", "reopen", WORKFLOW_ID, "--run-id", RUN_ID])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Reopen { run_id, .. }))) =
cli.command
else {
anyhow::bail!("expected reopen command");
};
assert_eq!(run_id.as_deref(), Some(RUN_ID));
Ok(())
}
#[test]
fn reopen_help_documents_the_subcommand() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(reopen) = command.find_subcommand_mut("reopen") else {
anyhow::bail!("reopen subcommand should be registered");
};
let help = reopen.render_long_help().to_string();
assert!(help.contains("--run-id"));
assert!(help.contains("reopen"));
Ok(())
}
#[test]
fn new_parses_name_template_and_worker() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"new",
"my_flow",
"--template",
"saga",
"--worker",
"rust",
])?;
let Some(Command::Client(ClientCommand::New(_))) = cli.command else {
anyhow::bail!("expected new command");
};
let cli = Cli::try_parse_from(["aion", "new", "my_flow"])?;
let Some(Command::Client(ClientCommand::New(_))) = cli.command else {
anyhow::bail!("expected new command");
};
assert!(Cli::try_parse_from(["aion", "new", "x", "--template", "nope"]).is_err());
assert!(Cli::try_parse_from(["aion", "new", "x", "--worker", "cobol"]).is_err());
Ok(())
}
#[test]
fn new_help_documents_templates_and_refusal() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(new) = command.find_subcommand_mut("new") else {
anyhow::bail!("new subcommand should be registered");
};
let help = new.render_long_help().to_string();
assert!(help.contains("--template"));
assert!(help.contains("awl"));
assert!(
help.contains("[default: awl]"),
"help must render the awl default: {help}"
);
assert!(help.contains("hello-world"));
assert!(help.contains("approval-flow"));
assert!(help.contains("saga"));
assert!(help.contains("--worker"));
assert!(help.contains("non-empty directory"));
Ok(())
}
#[test]
fn package_defaults_to_current_directory_without_build_or_out() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "package"])?;
let Some(Command::Client(ClientCommand::Package { path, out, build })) = cli.command else {
anyhow::bail!("expected package command");
};
assert_eq!(path, Path::new("."));
assert!(out.is_none());
assert!(!build);
Ok(())
}
#[test]
fn package_accepts_path_out_and_build() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"package",
"examples/hello-world",
"--out",
"dist/hello.aion",
"--build",
])?;
let Some(Command::Client(ClientCommand::Package { path, out, build })) = cli.command else {
anyhow::bail!("expected package command");
};
assert_eq!(path, Path::new("examples/hello-world"));
assert_eq!(out.as_deref(), Some(Path::new("dist/hello.aion")));
assert!(build);
Ok(())
}
#[test]
fn package_help_documents_local_only_behaviour() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(package) = command.find_subcommand_mut("package") else {
anyhow::bail!("package subcommand should be registered");
};
let help = package.render_long_help().to_string();
assert!(help.contains("--build"));
assert!(help.contains("--out"));
assert!(help.contains("never connects to a server"));
Ok(())
}
#[test]
fn check_parses_path_and_requires_the_deterministic_flag() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "check", "examples/order-saga", "--deterministic"])?;
let Some(Command::Client(ClientCommand::Check {
path,
deterministic,
})) = cli.command
else {
anyhow::bail!("expected check command");
};
assert_eq!(path, Path::new("examples/order-saga"));
assert!(deterministic);
let cli = Cli::try_parse_from(["aion", "check"])?;
let Some(Command::Client(ClientCommand::Check {
path,
deterministic,
})) = cli.command
else {
anyhow::bail!("expected check command");
};
assert_eq!(path, Path::new("."));
assert!(!deterministic);
Ok(())
}
#[test]
fn awl_check_and_fmt_parse_the_file_path() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "awl", "check", "flows/report.awl"])?;
let Some(Command::Awl {
command: awl::AwlCommand::Check { file },
}) = cli.command
else {
anyhow::bail!("expected awl check command");
};
assert_eq!(file, Path::new("flows/report.awl"));
let cli = Cli::try_parse_from(["aion", "awl", "fmt", "flows/report.awl"])?;
let Some(Command::Awl {
command: awl::AwlCommand::Fmt { file },
}) = cli.command
else {
anyhow::bail!("expected awl fmt command");
};
assert_eq!(file, Path::new("flows/report.awl"));
Ok(())
}
#[test]
fn awl_commands_require_a_file_and_fmt_has_no_check_mode() {
assert!(Cli::try_parse_from(["aion", "awl", "check"]).is_err());
assert!(Cli::try_parse_from(["aion", "awl", "fmt"]).is_err());
assert!(Cli::try_parse_from(["aion", "awl", "fmt", "f.awl", "--check"]).is_err());
}
#[test]
fn awl_emit_parses_the_file_path_and_optional_output() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "awl", "emit", "flows/report.awl"])?;
let Some(Command::Awl {
command:
awl::AwlCommand::Emit {
file,
output,
target,
},
}) = cli.command
else {
anyhow::bail!("expected awl emit command");
};
assert_eq!(file, Path::new("flows/report.awl"));
assert_eq!(output, None);
assert_eq!(target, awl::EmitTarget::Gleam);
let cli = Cli::try_parse_from([
"aion",
"awl",
"emit",
"flows/report.awl",
"-o",
"flows/report.beam",
"--target",
"beam",
])?;
let Some(Command::Awl {
command:
awl::AwlCommand::Emit {
file,
output,
target,
},
}) = cli.command
else {
anyhow::bail!("expected awl emit command");
};
assert_eq!(file, Path::new("flows/report.awl"));
assert_eq!(output, Some(PathBuf::from("flows/report.beam")));
assert_eq!(target, awl::EmitTarget::Beam);
Ok(())
}
#[test]
fn awl_emit_requires_a_file() {
assert!(Cli::try_parse_from(["aion", "awl", "emit"]).is_err());
}
#[test]
fn input_parses_workflow_type_and_path() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "input", "order_saga", "examples/order-saga"])?;
let Some(Command::Client(ClientCommand::Input {
workflow_type,
path,
})) = cli.command
else {
anyhow::bail!("expected input command");
};
assert_eq!(workflow_type, "order_saga");
assert_eq!(path, Path::new("examples/order-saga"));
let cli = Cli::try_parse_from(["aion", "input", "order_saga"])?;
let Some(Command::Client(ClientCommand::Input { path, .. })) = cli.command else {
anyhow::bail!("expected input command");
};
assert_eq!(path, Path::new("."));
Ok(())
}
#[test]
fn check_help_documents_the_determinism_gate() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(check) = command.find_subcommand_mut("check") else {
anyhow::bail!("check subcommand should be registered");
};
let help = check.render_long_help().to_string();
assert!(help.contains("--deterministic"));
assert!(help.contains("wall-clock"));
assert!(help.contains("Exits non-zero"));
Ok(())
}
#[test]
fn inspect_parses_workflow_run_from_and_mock() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "inspect", WORKFLOW_ID])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Inspect {
workflow_id,
run_id,
from,
mock,
}))) = cli.command
else {
anyhow::bail!("expected inspect command");
};
assert_eq!(workflow_id, WORKFLOW_ID);
assert!(run_id.is_none());
assert!(from.is_none());
assert!(mock.is_none());
let cli = Cli::try_parse_from(["aion", "inspect", WORKFLOW_ID, "--run-id", RUN_ID])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Inspect { run_id, .. }))) =
cli.command
else {
anyhow::bail!("expected inspect command");
};
assert_eq!(run_id.as_deref(), Some(RUN_ID));
let cli = Cli::try_parse_from([
"aion",
"inspect",
WORKFLOW_ID,
"--from",
"3",
"--mock",
r#"{"kind":"timer_fired"}"#,
])?;
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Inspect {
from, mock, ..
}))) = cli.command
else {
anyhow::bail!("expected inspect command");
};
assert_eq!(from, Some(3));
assert_eq!(mock.as_deref(), Some(r#"{"kind":"timer_fired"}"#));
Ok(())
}
#[test]
fn inspect_help_documents_oplog_read_and_what_if() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(inspect) = command.find_subcommand_mut("inspect") else {
anyhow::bail!("inspect subcommand should be registered");
};
let help = inspect.render_long_help().to_string();
assert!(help.contains("no debug-only log and no second store"));
assert!(help.contains("random()"));
assert!(help.contains("divergent command"));
assert!(help.contains("--from"));
assert!(help.contains("--mock"));
assert!(help.contains("what-if"));
Ok(())
}
#[test]
fn describe_help_documents_raw_payload_flag() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(describe) = command.find_subcommand_mut("describe") else {
anyhow::bail!("describe subcommand should be registered");
};
let help = describe.render_long_help().to_string();
assert!(help.contains("--raw"));
assert!(help.contains("Show raw payload byte arrays instead of decoded payloads"));
Ok(())
}
#[test]
fn query_arguments_default_to_the_null_document_and_accept_a_json_flag() -> anyhow::Result<()> {
let omitted = Cli::try_parse_from(vec!["aion", "query", WORKFLOW_ID, "state"])?;
let supplied = Cli::try_parse_from(vec![
"aion",
"query",
WORKFLOW_ID,
"state",
"--arguments",
"{\"n\":7}",
])?;
for (cli, expected) in [(omitted, "null"), (supplied, "{\"n\":7}")] {
let Some(Command::Client(ClientCommand::Remote(RemoteCommand::Query {
arguments,
..
}))) = cli.command
else {
anyhow::bail!("expected the query command");
};
assert_eq!(arguments, expected);
let expected_value: Value = serde_json::from_str(expected)?;
assert_eq!(
json_payload(&arguments)?,
Payload::from_json(&expected_value)?
);
}
Ok(())
}
#[test]
fn workflow_operations_allow_omitted_run_id() -> anyhow::Result<()> {
let commands = [
vec!["aion", "describe", WORKFLOW_ID],
vec!["aion", "tail", WORKFLOW_ID, "--no-follow"],
vec!["aion", "signal", WORKFLOW_ID, "poke", "--payload", "{}"],
vec!["aion", "cancel", WORKFLOW_ID],
vec!["aion", "reopen", WORKFLOW_ID],
vec!["aion", "query", WORKFLOW_ID, "state"],
];
for args in commands {
let cli = Cli::try_parse_from(args)?;
let Some(command) = cli.command else {
anyhow::bail!("expected a subcommand");
};
match command {
Command::Client(ClientCommand::Remote(
RemoteCommand::Describe { run_id, .. }
| RemoteCommand::Signal { run_id, .. }
| RemoteCommand::Cancel { run_id, .. }
| RemoteCommand::Reopen { run_id, .. }
| RemoteCommand::Pause { run_id, .. }
| RemoteCommand::Resume { run_id, .. }
| RemoteCommand::Query { run_id, .. },
)) => assert!(run_id.is_none()),
Command::Client(ClientCommand::Remote(RemoteCommand::Tail(args))) => {
assert!(args.run_id.is_none());
}
Command::Server(_)
| Command::Stop(_)
| Command::Update(_)
| Command::Store(_)
| Command::Dev(_)
| Command::Awl { .. }
| Command::Worker { .. }
| Command::Client(
ClientCommand::Remote(
RemoteCommand::Start { .. }
| RemoteCommand::List { .. }
| RemoteCommand::Inspect { .. }
| RemoteCommand::Retire { .. },
)
| ClientCommand::New(_)
| ClientCommand::Generate { .. }
| ClientCommand::Check { .. }
| ClientCommand::Input { .. }
| ClientCommand::Package { .. }
| ClientCommand::Deploy { .. }
| ClientCommand::Run { .. }
| ClientCommand::Versions { .. }
| ClientCommand::Route { .. }
| ClientCommand::Unload { .. },
) => {
anyhow::bail!("expected workflow operation command")
}
}
}
Ok(())
}
#[test]
fn signal_cancel_and_query_accept_optional_run_id() -> anyhow::Result<()> {
let commands = [
vec![
"aion",
"signal",
WORKFLOW_ID,
"poke",
"--run-id",
RUN_ID,
"--payload",
"{}",
],
vec!["aion", "cancel", WORKFLOW_ID, "--run-id", RUN_ID],
vec!["aion", "reopen", WORKFLOW_ID, "--run-id", RUN_ID],
vec!["aion", "query", WORKFLOW_ID, "state", "--run-id", RUN_ID],
];
for args in commands {
let cli = Cli::try_parse_from(args)?;
let Some(command) = cli.command else {
anyhow::bail!("expected a subcommand");
};
match command {
Command::Client(ClientCommand::Remote(
RemoteCommand::Signal { run_id, .. }
| RemoteCommand::Cancel { run_id, .. }
| RemoteCommand::Reopen { run_id, .. }
| RemoteCommand::Pause { run_id, .. }
| RemoteCommand::Resume { run_id, .. }
| RemoteCommand::Query { run_id, .. },
)) => assert_eq!(run_id.as_deref(), Some(RUN_ID)),
Command::Server(_)
| Command::Stop(_)
| Command::Update(_)
| Command::Store(_)
| Command::Dev(_)
| Command::Awl { .. }
| Command::Worker { .. }
| Command::Client(
ClientCommand::Remote(
RemoteCommand::Start { .. }
| RemoteCommand::List { .. }
| RemoteCommand::Describe { .. }
| RemoteCommand::Tail(_)
| RemoteCommand::Inspect { .. }
| RemoteCommand::Retire { .. },
)
| ClientCommand::New(_)
| ClientCommand::Generate { .. }
| ClientCommand::Check { .. }
| ClientCommand::Input { .. }
| ClientCommand::Package { .. }
| ClientCommand::Deploy { .. }
| ClientCommand::Run { .. }
| ClientCommand::Versions { .. }
| ClientCommand::Route { .. }
| ClientCommand::Unload { .. },
) => {
anyhow::bail!("expected run-targeted workflow operation command")
}
}
}
Ok(())
}
#[test]
fn deploy_subcommands_parse_with_global_token() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"--token",
"operator-token",
"deploy",
"dist/order.aion",
])?;
assert_eq!(cli.token.as_deref(), Some("operator-token"));
let Some(Command::Client(ClientCommand::Deploy { archive })) = cli.command else {
anyhow::bail!("expected deploy command");
};
assert_eq!(archive, Path::new("dist/order.aion"));
let cli = Cli::try_parse_from(["aion", "versions", "--workflow-type", "order"])?;
assert!(cli.token.is_none());
let Some(Command::Client(ClientCommand::Versions { workflow_type })) = cli.command else {
anyhow::bail!("expected versions command");
};
assert_eq!(workflow_type.as_deref(), Some("order"));
let hash = "a".repeat(64);
let cli = Cli::try_parse_from(["aion", "route", "order", &hash])?;
let Some(Command::Client(ClientCommand::Route {
workflow_type,
content_hash,
})) = cli.command
else {
anyhow::bail!("expected route command");
};
assert_eq!(workflow_type, "order");
assert_eq!(content_hash, hash);
let cli = Cli::try_parse_from(["aion", "unload", "order", &hash])?;
let Some(Command::Client(ClientCommand::Unload {
workflow_type,
content_hash,
})) = cli.command
else {
anyhow::bail!("expected unload command");
};
assert_eq!(workflow_type, "order");
assert_eq!(content_hash, hash);
Ok(())
}
#[test]
fn deploy_help_documents_the_operator_surface() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(deploy) = command.find_subcommand_mut("deploy") else {
anyhow::bail!("deploy subcommand should be registered");
};
let help = deploy.render_long_help().to_string();
assert!(help.contains("operator API"));
assert!(help.contains("deploy"));
Ok(())
}
#[test]
fn server_subcommand_converts_all_overrides() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"server",
"--config",
"dev-config.toml",
"--listen-address",
"127.0.0.1:18080",
"--grpc-address",
"127.0.0.1:18051",
"--store-url",
"aion.db",
"--scheduler-threads",
"2",
"--drain-timeout",
"45",
])?;
let Some(Command::Server(args)) = cli.command else {
anyhow::bail!("expected server command");
};
let overrides = CliOverrides::from(args.run);
assert_eq!(
overrides.config_path,
Some(PathBuf::from("dev-config.toml"))
);
assert_eq!(
overrides.listen_address.map(|address| address.port()),
Some(18080)
);
assert_eq!(
overrides.grpc_address.map(|address| address.port()),
Some(18051),
"the gRPC bind override never reached the server config"
);
assert_eq!(overrides.store_url.as_deref(), Some("aion.db"));
assert_eq!(overrides.scheduler_threads, Some(2));
assert_eq!(overrides.drain_timeout_seconds, Some(45));
Ok(())
}
#[test]
fn the_server_refuses_a_client_endpoint_instead_of_binding_the_default() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "--endpoint", "127.0.0.1:51099", "server"])?;
assert!(matches!(cli.command, Some(Command::Server(_))));
let refusal = server_endpoint_refusal(cli.endpoint.as_deref())
.ok_or_else(|| anyhow::anyhow!("an explicit --endpoint was accepted by the server"))?;
assert!(refusal.contains("127.0.0.1:51099"));
assert!(refusal.contains("--grpc-address"));
assert!(refusal.contains("AION_SERVER_GRPC_ADDRESS"));
Ok(())
}
#[test]
fn the_control_verbs_refuse_a_client_endpoint_instead_of_acting_locally() -> anyhow::Result<()>
{
for (argv, verb, action) in [
(
vec!["aion", "--endpoint", "10.0.0.9:50051", "server", "stop"],
"stop",
"stop",
),
(
vec!["aion", "server", "stop", "--endpoint", "10.0.0.9:50051"],
"stop",
"stop",
),
(
vec!["aion", "--endpoint", "10.0.0.9:50051", "server", "status"],
"status",
"report status",
),
(
vec!["aion", "server", "status", "--endpoint", "10.0.0.9:50051"],
"status",
"report status",
),
] {
let cli = Cli::try_parse_from(&argv)?;
let refusal = control_verb_endpoint_refusal(verb, action, cli.endpoint.as_deref())
.ok_or_else(|| {
anyhow::anyhow!(
"an explicit --endpoint was accepted by a control verb: {argv:?}"
)
})?;
assert!(refusal.contains("10.0.0.9:50051"));
assert!(
refusal.contains(&format!("refusing to {action}")),
"the refusal must open with its rendered verb phrase: {refusal}"
);
assert!(
refusal.contains(&format!("aion server {verb}")),
"the refusal must name ITS OWN verb, not a sibling's: {refusal}"
);
assert!(
refusal.contains("local"),
"the refusal must say the verb is local: {refusal}"
);
assert!(
refusal.contains("run the verb on that machine"),
"the refusal must name the remedy: {refusal}"
);
}
let bare = Cli::try_parse_from(["aion", "server", "stop"])?;
assert!(control_verb_endpoint_refusal("stop", "stop", bare.endpoint.as_deref()).is_none());
Ok(())
}
#[test]
fn a_server_run_without_endpoint_is_not_refused_and_clients_keep_the_default()
-> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "server"])?;
assert!(server_endpoint_refusal(cli.endpoint.as_deref()).is_none());
assert_eq!(cli.endpoint(), format!("http://{DEFAULT_ENDPOINT}"));
let dialled = Cli::try_parse_from(["aion", "--endpoint", "example:1", "list"])?;
assert_eq!(dialled.endpoint(), "http://example:1");
Ok(())
}
#[test]
fn server_subcommand_maps_authoring_gleam_path() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"server",
"--gleam-path",
"/usr/local/bin/gleam",
"--authoring-project-root",
"/srv/aion/authoring",
])?;
let Some(Command::Server(args)) = cli.command else {
anyhow::bail!("expected server command");
};
let overrides = CliOverrides::from(args.run);
assert_eq!(
overrides.gleam_path,
Some(PathBuf::from("/usr/local/bin/gleam"))
);
assert_eq!(
overrides.authoring_project_root,
Some(PathBuf::from("/srv/aion/authoring"))
);
Ok(())
}
#[test]
fn server_workflow_package_flag_is_repeatable() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"server",
"--workflow-package",
"examples/hello-world/hello-world.aion",
"--workflow-package",
"local.aion",
])?;
let Some(Command::Server(args)) = cli.command else {
anyhow::bail!("expected server command");
};
let overrides = CliOverrides::from(args.run);
assert_eq!(
overrides.workflow_packages,
vec![
PathBuf::from("examples/hello-world/hello-world.aion"),
PathBuf::from("local.aion"),
]
);
Ok(())
}
#[test]
fn server_help_documents_the_server_surface() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(server) = command.find_subcommand_mut("server") else {
anyhow::bail!("server subcommand should be registered");
};
let help = server.render_long_help().to_string();
assert!(help.contains("Run the Aion workflow server"));
assert!(help.contains("--config"));
assert!(help.contains("--workflow-package"));
Ok(())
}
#[test]
fn top_level_help_lists_the_server_subcommand() -> anyhow::Result<()> {
let error = Cli::try_parse_from(["aion", "--help"])
.err()
.ok_or_else(|| anyhow::anyhow!("help should exit early"))?;
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
let help = error.to_string();
assert!(help.contains("server"));
assert!(help.contains("Run the Aion workflow server"));
Ok(())
}
#[test]
fn bare_invocation_parses_as_the_launcher() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion"])?;
assert!(cli.command.is_none());
assert!(!cli.foreground);
assert!(!cli.no_open);
assert!(super::launcher_flag_refusal(&cli).is_none());
let cli = Cli::try_parse_from(["aion", "--foreground", "--no-open"])?;
assert!(cli.command.is_none());
assert!(cli.foreground);
assert!(cli.no_open);
assert!(super::launcher_flag_refusal(&cli).is_none());
Ok(())
}
#[test]
fn launcher_flags_with_a_subcommand_are_refused_by_name() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "--foreground", "server"])?;
let refusal = super::launcher_flag_refusal(&cli)
.ok_or_else(|| anyhow::anyhow!("expected a refusal"))?;
assert!(refusal.contains("`--foreground` configures"));
let cli = Cli::try_parse_from(["aion", "--no-open", "versions"])?;
let refusal = super::launcher_flag_refusal(&cli)
.ok_or_else(|| anyhow::anyhow!("expected a refusal"))?;
assert!(refusal.contains("`--no-open` configures"));
let cli = Cli::try_parse_from(["aion", "--foreground", "--no-open", "server"])?;
let refusal = super::launcher_flag_refusal(&cli)
.ok_or_else(|| anyhow::anyhow!("expected a refusal"))?;
assert!(refusal.contains("`--foreground` and `--no-open` configure"));
Ok(())
}
#[test]
fn dev_parses_path_gleam_path_and_optional_debounce() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"dev",
"examples/hello-world",
"--gleam-path",
"/usr/local/bin/gleam",
"--debounce-ms",
"200",
])?;
let Some(Command::Dev(args)) = cli.command else {
anyhow::bail!("expected dev command");
};
assert_eq!(args.path, Path::new("examples/hello-world"));
assert_eq!(args.gleam_path, Path::new("/usr/local/bin/gleam"));
assert_eq!(
args.debounce_ms,
Some(std::time::Duration::from_millis(200))
);
let cli = Cli::try_parse_from(["aion", "dev", "--gleam-path", "gleam"])?;
let Some(Command::Dev(args)) = cli.command else {
anyhow::bail!("expected dev command");
};
assert_eq!(args.path, Path::new("."));
assert!(args.debounce_ms.is_none());
Ok(())
}
#[test]
fn dev_requires_gleam_path_and_rejects_zero_debounce() {
assert!(Cli::try_parse_from(["aion", "dev"]).is_err());
assert!(
Cli::try_parse_from(["aion", "dev", "--gleam-path", "gleam", "--debounce-ms", "0"])
.is_err()
);
}
#[test]
fn dev_help_documents_the_instant_loop_and_pinning() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(dev) = command.find_subcommand_mut("dev") else {
anyhow::bail!("dev subcommand should be registered");
};
let help = dev.render_long_help().to_string();
assert!(help.contains("--gleam-path"));
assert!(help.contains("--debounce-ms"));
assert!(help.contains("no engine restart"));
assert!(help.contains("in-flight"));
Ok(())
}
#[test]
fn worker_agent_parses_the_document_and_repeatable_liminal_addresses() -> anyhow::Result<()> {
let cli = Cli::try_parse_from([
"aion",
"worker",
"agent",
"flows/review.awl",
"--liminal-address",
"127.0.0.1:50061",
"--liminal-address",
"127.0.0.1:50062",
"--identity",
"reviewer-worker",
"--task-queue",
"reviewers",
])?;
let Some(Command::Worker {
command: super::WorkerCommand::Agent(args),
}) = cli.command
else {
anyhow::bail!("expected worker agent command");
};
assert_eq!(args.awl.as_deref(), Some(Path::new("flows/review.awl")));
assert_eq!(
args.liminal_addresses,
vec![
String::from("127.0.0.1:50061"),
String::from("127.0.0.1:50062")
]
);
assert_eq!(args.identity.as_deref(), Some("reviewer-worker"));
assert_eq!(args.task_queue.as_deref(), Some("reviewers"));
assert!(!args.check);
let cli = Cli::try_parse_from(["aion", "worker", "agent", "flows/review.awl", "--check"])?;
let Some(Command::Worker {
command: super::WorkerCommand::Agent(args),
}) = cli.command
else {
anyhow::bail!("expected worker agent command");
};
assert!(args.check);
assert!(args.liminal_addresses.is_empty());
assert!(args.identity.is_none());
Ok(())
}
#[test]
fn the_agent_verb_survives_the_flag_deletion_and_takes_a_document() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "worker", "agent", "flows/review.awl"])?;
let Some(Command::Worker {
command: super::WorkerCommand::Agent(args),
}) = cli.command
else {
anyhow::bail!("`aion worker agent <FILE.awl>` must still be the door in");
};
assert_eq!(args.awl.as_deref(), Some(Path::new("flows/review.awl")));
Ok(())
}
#[test]
fn every_migrated_launch_flag_is_refused_by_the_parser() {
for (flag, value) in [
("--harness", Some("acp")),
("--concurrency", Some("4")),
("--reconnect-initial-backoff", Some("5")),
("--reconnect-max-backoff", Some("60")),
("--reconnect-max-attempts", Some("100")),
("--acp-command", Some("/usr/local/bin/claude-code-acp")),
("--acp-arg", Some("--acp")),
("--acp-cwd", Some("/srv/workspace")),
("--acp-permission", Some("deny")),
("--acp-env", Some("KEY=value")),
("--acp-exit-grace", Some("2")),
("--norn-binary", Some("/usr/local/bin/norn")),
("--norn-arg", Some("--account")),
("--norn-unset-env", Some("OPENAI_API_KEY")),
] {
let mut argv = vec!["aion", "worker", "agent", "flows/review.awl", flag];
argv.extend(value);
assert!(
Cli::try_parse_from(&argv).is_err(),
"`{flag}` moved into the document's `harness` section and must no longer \
parse; accepting it would leave an operator believing a setting is in force \
when the document decides it"
);
}
}
#[test]
fn the_argv_a_pre_change_managed_worker_record_replays_earns_a_named_refusal() {
let stored_verb = [
"worker",
"agent",
"workflows/mm_fleet_loop.awl",
"--harness",
"norn",
"--task-queue",
"mm_loop_build",
"--identity",
"mm-loop-builder",
"--liminal-address",
"127.0.0.1:50061",
"--concurrency",
"4",
"--reconnect-initial-backoff",
"1",
"--reconnect-max-backoff",
"30",
"--reconnect-max-attempts",
"100",
"--norn-arg=--account",
"--norn-arg=default",
];
let mut argv = vec!["aion"];
argv.extend(stored_verb);
assert!(
Cli::try_parse_from(&argv).is_err(),
"a stored record does not buy a deleted flag its way back in"
);
let owned: Vec<String> = argv.into_iter().map(str::to_owned).collect();
let Some(advice) = super::migrated_flag_advice(&owned) else {
unreachable!("an argv carrying deleted flags must earn the advice");
};
for expected in [
"--harness",
"--concurrency",
"--reconnect-initial-backoff",
"--norn-arg",
"harness",
"managed-worker",
"re-mint",
"worker-deployments",
] {
assert!(
advice.contains(expected),
"the refusal must name `{expected}`, got:\n{advice}"
);
}
assert!(
!advice.contains("--task-queue") && !advice.contains("--identity"),
"a flag that SURVIVED must not be named as deleted, or the operator strips a \
setting the verb still needs:\n{advice}"
);
}
#[test]
fn a_worker_awl_invocation_earns_no_migration_advice() {
let published: Vec<String> = [
"aion",
"worker",
"awl",
"greeter.awl",
"--concurrency",
"4",
"--reconnect-initial-backoff",
"0.1",
"--reconnect-max-backoff",
"5",
"--reconnect-max-attempts",
"1000",
"--identty",
"greeter-worker",
]
.into_iter()
.map(str::to_owned)
.collect();
assert!(
Cli::try_parse_from(&published).is_err(),
"the control needs a FAILING parse, or the advice path never runs and this \
test proves nothing"
);
assert_eq!(
super::migrated_flag_advice(&published),
None,
"`--concurrency` and the reconnect trio are live REQUIRED flags on `worker \
awl`; advice claiming they are deleted would send an operator to write a \
`harness` section into a worker-family document, which cannot carry one"
);
}
#[test]
fn the_same_flags_on_worker_agent_still_earn_the_advice() {
let agent: Vec<String> = [
"aion",
"worker",
"agent",
"flows/review.awl",
"--concurrency",
"4",
"--reconnect-initial-backoff",
"1",
]
.into_iter()
.map(str::to_owned)
.collect();
let Some(advice) = super::migrated_flag_advice(&agent) else {
unreachable!("the deleted flags on their own verb must earn the advice");
};
assert!(advice.contains("--concurrency") && advice.contains("harness"));
}
#[test]
fn a_deleted_flag_on_an_unrelated_verb_earns_no_advice() {
let unrelated: Vec<String> = ["aion", "worker", "shell", "--concurrency", "4"]
.into_iter()
.map(str::to_owned)
.collect();
assert_eq!(super::migrated_flag_advice(&unrelated), None);
}
#[test]
fn an_argv_with_no_deleted_flag_earns_no_migration_advice() {
let argv: Vec<String> = ["aion", "worker", "agent", "flows/review.awl", "--nonsense"]
.into_iter()
.map(str::to_owned)
.collect();
assert!(
super::migrated_flag_advice(&argv).is_none(),
"the advice is for DELETED flags, not for every unknown argument"
);
}
#[test]
fn a_bare_invocation_refuses_loudly_naming_the_new_shape() -> anyhow::Result<()> {
let cli = Cli::try_parse_from(["aion", "worker", "agent"])?;
let Some(Command::Worker {
command: super::WorkerCommand::Agent(args),
}) = cli.command
else {
anyhow::bail!("a bare invocation must PARSE, so the verb can refuse in its own words");
};
let Err(error) = args.document() else {
anyhow::bail!("an invocation naming no document must be refused");
};
let message = error.to_string();
for expected in [
"aion worker agent <FILE.awl>",
"harness",
"aion awl check",
"no longer a flag",
] {
assert!(
message.contains(expected),
"the refusal must name `{expected}`, got:\n{message}"
);
}
Ok(())
}
#[test]
fn worker_agent_help_documents_the_model_it_serves() -> anyhow::Result<()> {
let mut command = Cli::command();
let Some(worker) = command.find_subcommand_mut("worker") else {
anyhow::bail!("worker subcommand should be registered");
};
let Some(agent) = worker.find_subcommand_mut("agent") else {
anyhow::bail!("worker agent subcommand should be registered");
};
let help = agent.render_long_help().to_string();
assert!(help.contains("--liminal-address"));
assert!(help.contains("--task-queue"));
assert!(help.contains("--check"));
assert!(help.contains("worker` block's name"));
assert!(help.contains("all-or-nothing"));
assert!(help.contains("liminal worker transport"));
assert!(help.contains("nothing is invented"));
for expected in [
"`harness` section",
"concurrency",
"env_pass",
"permission",
"exit_grace",
"no default",
] {
assert!(help.contains(expected), "help must document `{expected}`");
}
assert!(help.contains("Nothing is inherited"));
Ok(())
}
#[test]
fn normalize_endpoint_preserves_explicit_scheme() {
assert_eq!(
normalize_endpoint("https://aion.example:50051"),
"https://aion.example:50051"
);
}
#[test]
fn normalize_endpoint_defaults_to_http_scheme() {
assert_eq!(
normalize_endpoint("127.0.0.1:50051"),
"http://127.0.0.1:50051"
);
}
#[test]
fn every_dial_address_the_cli_hands_out_carries_a_scheme() -> anyhow::Result<()> {
let shell = [
"aion",
"--endpoint",
"example.internal:50051",
"worker",
"shell",
"--manifest",
"worker.toml",
"--awl",
"flow.awl",
];
for argv in [
vec!["aion", "list"],
vec![
"aion",
"worker",
"shell",
"--manifest",
"w.toml",
"--awl",
"f.awl",
],
vec!["aion", "--endpoint", "127.0.0.1:50051", "list"],
shell.to_vec(),
vec!["aion", "--endpoint", "https://aion.example:50051", "list"],
] {
let dialled = Cli::try_parse_from(&argv)?.endpoint();
assert!(
dialled.contains("://"),
"the CLI handed `{argv:?}` the scheme-less address `{dialled}`; a verb that \
dials it as given connects to nothing, which is exactly how `aion worker \
shell` failed"
);
}
let explicit =
Cli::try_parse_from(["aion", "--endpoint", "https://aion.example:50051", "list"])?;
assert_eq!(explicit.endpoint(), "https://aion.example:50051");
Ok(())
}
}