use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use mlua_swarm::{
AgentBlockInProcessSpawnerFactory, Compiler, LuaInProcessSpawnerFactory,
OperatorSpawnerFactory, RustFnInProcessSpawnerFactory, SpawnerRegistry,
SubprocessProcessSpawnerFactory,
};
use mlua_swarm_cli::dsl;
#[derive(Debug, Args)]
pub struct BpArgs {
#[command(subcommand)]
cmd: BpCmd,
}
#[derive(Debug, Subcommand)]
enum BpCmd {
Build(BuildArgs),
Lint(LintArgs),
New(NewArgs),
}
#[derive(Debug, Args)]
struct BuildArgs {
script: PathBuf,
#[arg(short = 'o', long = "out")]
out: Option<PathBuf>,
#[arg(long)]
register: bool,
#[arg(long)]
server: Option<String>,
#[arg(long = "include", action = clap::ArgAction::Append, value_name = "DIR")]
include: Vec<PathBuf>,
#[arg(long = "strict-embed")]
strict_embed: bool,
}
#[derive(Debug, Args)]
struct LintArgs {
script: PathBuf,
#[arg(long = "include", action = clap::ArgAction::Append, value_name = "DIR")]
include: Vec<PathBuf>,
#[arg(long)]
strict: bool,
}
#[derive(Debug, Args)]
struct NewArgs {
template: String,
name: String,
#[arg(long)]
stages: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
operator: Option<String>,
#[arg(long)]
binding: Option<String>,
#[arg(short = 'o', long = "out")]
out: Option<PathBuf>,
}
const DEFAULT_SERVER: &str = "127.0.0.1:7777";
pub async fn run(args: BpArgs) -> Result<()> {
match args.cmd {
BpCmd::Build(build_args) => run_build(build_args).await,
BpCmd::Lint(lint_args) => run_lint(lint_args),
BpCmd::New(new_args) => run_new(new_args),
}
}
async fn run_build(args: BuildArgs) -> Result<()> {
let script = std::fs::read_to_string(&args.script)
.with_context(|| format!("reading {}", args.script.display()))?;
let (bp_value, dsl_warnings) = dsl::build_bp_from_script_with_warnings(&script)
.with_context(|| format!("building Blueprint from {}", args.script.display()))?;
for w in &dsl_warnings {
eprintln!("dsl warn: {w}");
}
match compile_lint(&bp_value, &args.script, &args.include) {
Ok(LintReport::Ok { agents, operators }) => {
eprintln!("compile lint: OK ({agents} agent(s), {operators} operator(s) checked)");
}
Ok(LintReport::Warn {
agents,
operators,
reason,
warnings,
}) => {
eprintln!(
"compile lint: WARN ({agents} agent(s), {operators} operator(s) checked) — {reason}"
);
for w in &warnings {
eprintln!(" - {w}");
}
if args.strict_embed {
return Err(anyhow!(
"compile lint: --strict-embed, refusing to emit Blueprint JSON with unresolved refs"
));
}
}
Err(e) => {
if let Some(hint) = fix_hint_for_error(&e) {
eprintln!();
eprintln!("fix hint ({}):", hint.kind);
eprintln!(" {}", hint.reason);
eprintln!();
eprintln!(" suggested patch:");
for line in hint.patch_suggestion.lines() {
eprintln!(" {line}");
}
if let Some(docs) = &hint.docs_ref {
eprintln!();
eprintln!(" see: {docs}");
}
}
return Err(e);
}
}
let out_str = serde_json::to_string_pretty(&bp_value)?;
match &args.out {
Some(path) => {
std::fs::write(path, &out_str)
.with_context(|| format!("writing {}", path.display()))?;
}
None => println!("{out_str}"),
}
if args.register {
let outcome = register(&bp_value, args.server.as_deref()).await?;
eprintln!(
"register: {} -> HTTP {}: {}",
outcome.url, outcome.http_status, outcome.body
);
}
Ok(())
}
fn run_lint(args: LintArgs) -> Result<()> {
let script = std::fs::read_to_string(&args.script)
.with_context(|| format!("reading {}", args.script.display()))?;
let (bp_value, dsl_warnings) = dsl::build_bp_from_script_with_warnings(&script)
.with_context(|| format!("building Blueprint from {}", args.script.display()))?;
for w in &dsl_warnings {
eprintln!("dsl warn: {w}");
}
let strict_dsl_warn = args.strict && !dsl_warnings.is_empty();
match compile_lint(&bp_value, &args.script, &args.include) {
Ok(LintReport::Ok { agents, operators }) => {
eprintln!("bp lint: OK ({agents} agent(s), {operators} operator(s) checked)");
if strict_dsl_warn {
return Err(anyhow!("bp lint: --strict, exiting non-zero on WARN"));
}
Ok(())
}
Ok(LintReport::Warn {
agents,
operators,
reason,
warnings,
}) => {
eprintln!(
"bp lint: WARN ({agents} agent(s), {operators} operator(s) checked) — {reason}"
);
for w in &warnings {
eprintln!(" - {w}");
}
if args.strict {
Err(anyhow!("bp lint: --strict, exiting non-zero on WARN"))
} else {
Ok(())
}
}
Err(e) => {
let msg = format!("{e:#}");
eprintln!("bp lint: ERROR — {msg}");
if let Some(hint) = fix_hint_for_error(&e) {
eprintln!();
eprintln!("fix hint ({}):", hint.kind);
eprintln!(" {}", hint.reason);
eprintln!();
eprintln!(" suggested patch:");
for line in hint.patch_suggestion.lines() {
eprintln!(" {line}");
}
if let Some(docs) = &hint.docs_ref {
eprintln!();
eprintln!(" see: {docs}");
}
}
Err(e)
}
}
}
const DEFAULT_BINDING: &str = "claude";
const DEFAULT_OPERATOR: &str = "main-ai";
const DEFAULT_PIPELINE_STAGES: &[&str] = &["stage1", "stage2"];
const DEFAULT_VERDICT_STAGES: [&str; 3] = ["analyze", "review", "publish"];
const DEFAULT_SINGLE_AGENT: &str = "solo";
const DEFAULT_FANOUT_STAGES: &[&str] = &["checker1", "checker2"];
const FANOUT_AGGREGATE_STAGE: &str = "aggregate";
fn run_new(args: NewArgs) -> Result<()> {
let out = render_template(&args)?;
match &args.out {
Some(path) => {
std::fs::write(path, &out).with_context(|| format!("writing {}", path.display()))?;
eprintln!("mse bp new: wrote {} ({} bytes)", path.display(), out.len());
}
None => print!("{out}"),
}
Ok(())
}
fn render_template(args: &NewArgs) -> Result<String> {
render_template_by_kind(
&args.template,
&args.name,
args.stages.as_deref(),
args.agent.as_deref(),
args.operator.as_deref(),
args.binding.as_deref(),
)
}
pub(crate) fn render_template_by_kind(
template: &str,
name: &str,
stages: Option<&str>,
agent: Option<&str>,
operator: Option<&str>,
binding: Option<&str>,
) -> Result<String> {
let operator = operator.unwrap_or(DEFAULT_OPERATOR);
let binding = binding.unwrap_or(DEFAULT_BINDING);
match template {
"pipeline" => Ok(render_pipeline_template(
name,
&parse_stages(stages, DEFAULT_PIPELINE_STAGES),
operator,
binding,
)),
"single" => Ok(render_single_template(
name,
agent.unwrap_or(DEFAULT_SINGLE_AGENT),
operator,
binding,
)),
"verdict" => Ok(render_verdict_template(
name,
&parse_verdict_stages(stages),
operator,
binding,
)),
"fanout" => Ok(render_fanout_template(
name,
&parse_stages(stages, DEFAULT_FANOUT_STAGES),
operator,
binding,
)),
other => Err(anyhow!(
"unknown template '{other}': accepted = pipeline / single / verdict / fanout"
)),
}
}
fn parse_stages(raw: Option<&str>, default: &[&str]) -> Vec<String> {
match raw {
Some(s) => s
.split(',')
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.map(String::from)
.collect(),
None => default.iter().map(|s| (*s).to_string()).collect(),
}
}
fn parse_verdict_stages(raw: Option<&str>) -> [String; 3] {
let supplied = parse_stages(raw, &[]);
let mut out = DEFAULT_VERDICT_STAGES.map(String::from);
for (slot, val) in out.iter_mut().zip(supplied) {
*slot = val;
}
out
}
fn render_pipeline_template(
name: &str,
stages: &[String],
operator: &str,
binding: &str,
) -> String {
let stages: &[String] = if stages.is_empty() {
return render_pipeline_template(
name,
&DEFAULT_PIPELINE_STAGES
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
operator,
binding,
);
} else {
stages
};
let init_ctx_sample = stages
.iter()
.map(|stage| format!("{stage} = \"...\""))
.collect::<Vec<_>>()
.join(", ");
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new pipeline` (GH #62 Axis A).\n");
out.push_str("-- Every mandatory field is pre-filled: `halted_at` (compile-lint\n");
out.push_str("-- default), each operator agent's explicit `ws_operator` Runner,\n");
out.push_str("-- the operator's `kind` (main_ai,\n");
out.push_str("-- so a caller can omit `operator_kind` at swarm_run time and the\n");
out.push_str("-- BP Agent-level tier of the OperatorKind cascade routes spawns to\n");
out.push_str("-- a joined main-ai session instead of silently falling through to\n");
out.push_str("-- the Automate backend, GH #66), `strict_refs` + `strict_kind`.\n");
out.push_str("--\n");
out.push_str("-- Launch prerequisite (GH #64): the pipeline sugar reads each stage's\n");
out.push_str("-- input from `$.d.<stage_name>` and does NOT auto-chain outputs into\n");
out.push_str("-- the next stage. Seed every stage under `d` when starting a run:\n");
out.push_str("--\n");
out.push_str(&format!(
"-- swarm_run(blueprint = ..., init_ctx = {{ d = {{ {init_ctx_sample} }} }})\n"
));
out.push_str("--\n");
out.push_str("-- See `mse://guides/bp-dsl-templates` for the `$.d.<stage>` convention\n");
out.push_str(
"-- and recipes for hand-chaining outputs (e.g. `F.step { input = F.p \"$.<prev>\" }`).\n",
);
out.push_str("--\n");
out.push_str(
"-- Naming glue (see `mse://guides/operator-execution-model` §Operator naming):\n",
);
out.push_str(
"-- `operators[].name` == mint-time `roles[]` alias == every agent's `operator_ref`.\n",
);
out.push_str(
"-- The literal is arbitrary (`main-ai` is a convention, not a system name); to run\n",
);
out.push_str("-- two MainAIs in parallel, split into per-lane aliases (e.g. `phase_a_op`\n");
out.push_str("-- / `phase_b_op`) and rebind the agents' `operator_ref` accordingly.\n\n");
out.push_str("local B = require(\"bp_dsl\")\n\n");
out.push_str("local flow = B.pipeline({\n");
for stage in stages {
out.push_str(&format!(
" B.stage \"{stage}\" {{ agent = \"{stage}\" }},\n"
));
}
out.push_str(" halted_at = \"$.halted_at\",\n");
out.push_str(" done = \"$.pipeline_complete\",\n");
out.push_str("})\n\n");
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
for stage in stages {
out.push_str(&format!(
" {{ name = \"{stage}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {stage}\", tools = {{}} }},\n \
runner = {{ backend = \"ws_operator\", variant = \"{binding}\", tools = {{}} }} }},\n"
));
}
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
fn render_single_template(name: &str, agent: &str, operator: &str, binding: &str) -> String {
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new single` (GH #62 Axis A).\n");
out.push_str("-- Minimal 1-step 1-agent shape — `flow_dsl` directly, no pipeline\n");
out.push_str("-- sugar. All mandatory fields (`runner`, `strict_refs`,\n");
out.push_str("-- `strict_kind`) are pre-filled. The operator's `kind` is also\n");
out.push_str("-- pre-filled as `main_ai` (GH #66) so `swarm_run` can omit\n");
out.push_str("-- `operator_kind` at launch and spawns route to a joined\n");
out.push_str("-- main-ai session.\n");
out.push_str("--\n");
out.push_str(
"-- Naming glue (see `mse://guides/operator-execution-model` §Operator naming):\n",
);
out.push_str(
"-- `operators[].name` == mint-time `roles[]` alias == agent's `operator_ref`.\n",
);
out.push_str("-- The literal is arbitrary (`main-ai` is a convention, not a system name);\n");
out.push_str("-- rename it (e.g. `phase_a_op`) and update `operator_ref` in lockstep to\n");
out.push_str("-- run this BP on a dedicated MainAI alongside other BPs.\n\n");
out.push_str("local F = require(\"flow_dsl\")\n\n");
out.push_str(&format!(
"local flow = F.step({{ id = \"{agent}\", agent = \"{agent}\", \
input = F.lit(\"\"), out = F.p(\"$.{agent}\") }})\n\n"
));
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
out.push_str(&format!(
" {{ name = \"{agent}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {agent}\", tools = {{}} }},\n \
runner = {{ backend = \"ws_operator\", variant = \"{binding}\", tools = {{}} }} }},\n"
));
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
fn render_verdict_template(
name: &str,
stages: &[String; 3],
operator: &str,
binding: &str,
) -> String {
let [analyze, review, publish] = stages;
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new verdict` (GH #62 Axis A).\n");
out.push_str(&format!(
"-- Mirrors `mse://blueprints/samples/07-dsl-pipeline`: {analyze} -> \
{review} (verdict-gated, bounded retry through fixer on BLOCKED) -> \
{publish}. All mandatory fields pre-filled.\n"
));
out.push_str("-- The operator's `kind` is also pre-filled as `main_ai` (GH #66)\n");
out.push_str("-- so `swarm_run` can omit `operator_kind` at launch and spawns\n");
out.push_str("-- route to a joined main-ai session.\n");
out.push_str("--\n");
out.push_str("-- Launch prerequisite (GH #64): the pipeline sugar reads each stage's\n");
out.push_str("-- input from `$.d.<stage_name>` and does NOT auto-chain outputs into\n");
out.push_str("-- the next stage. Seed every stage under `d` when starting a run:\n");
out.push_str("--\n");
out.push_str(&format!(
"-- swarm_run(blueprint = ..., init_ctx = {{ d = {{ {analyze} = \"...\", {review} = \"...\", {publish} = \"...\" }} }})\n"
));
out.push_str("--\n");
out.push_str("-- See `mse://guides/bp-dsl-templates` for the `$.d.<stage>` convention\n");
out.push_str(
"-- and recipes for hand-chaining outputs (e.g. `F.step { input = F.p \"$.<prev>\" }`).\n",
);
out.push_str("--\n");
out.push_str(
"-- Naming glue (see `mse://guides/operator-execution-model` §Operator naming):\n",
);
out.push_str(
"-- `operators[].name` == mint-time `roles[]` alias == every agent's `operator_ref`.\n",
);
out.push_str(
"-- The literal is arbitrary (`main-ai` is a convention, not a system name); to run\n",
);
out.push_str("-- two MainAIs in parallel, split into per-lane aliases (e.g. `phase_a_op`\n");
out.push_str("-- / `phase_b_op`) and rebind the agents' `operator_ref` accordingly.\n\n");
out.push_str("local B = require(\"bp_dsl\")\n\n");
out.push_str("local flow = B.pipeline({\n");
out.push_str(&format!(
" B.stage \"{analyze}\" {{ agent = \"{analyze}\" }},\n"
));
out.push_str(&format!(
" B.stage \"{review}\" {{\n \
agent = \"{review}\",\n \
retry = {{\n \
max = 2,\n \
fix = B.stage \"fix\" {{ agent = \"fixer\", input = B.from \"{review}\" }},\n \
}},\n }},\n"
));
out.push_str(&format!(
" B.stage \"{publish}\" {{ agent = \"{publish}\" }},\n"
));
out.push_str(" halt_on = { \"BLOCKED\" },\n");
out.push_str(" halted_at = \"$.halted_at\",\n");
out.push_str(" done = \"$.pipeline_complete\",\n");
out.push_str("})\n\n");
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
for stage in [analyze.as_str(), publish.as_str()] {
out.push_str(&format!(
" {{ name = \"{stage}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {stage}\", tools = {{}} }},\n \
runner = {{ backend = \"ws_operator\", variant = \"{binding}\", tools = {{}} }} }},\n"
));
}
out.push_str(&format!(
" {{ name = \"{review}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: stage a `verdict` part = \
`PASS` or `BLOCKED`, then finish with report body\", \
tools = {{}} }},\n \
runner = {{ backend = \"ws_operator\", variant = \"{binding}\", tools = {{}} }},\n \
verdict = {{ channel = \"part\", values = {{ \"PASS\", \"BLOCKED\" }} }} }},\n"
));
out.push_str(&format!(
" {{ name = \"fixer\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: given the reviewer's report, \
emit a fix and reply so the review can retry\", \
tools = {{}} }},\n \
runner = {{ backend = \"ws_operator\", variant = \"{binding}\", tools = {{}} }} }},\n"
));
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
fn render_fanout_lane_body(head: &str, tail: &[String], indent: &str) -> String {
let step = |checker: &str| {
format!(
"F.step({{ agent = \"{checker}\", input = F.p(\"$.d.{checker}\"), out = F.p(\"$.lane.{checker}\") }})"
)
};
let Some((next, rest)) = tail.split_first() else {
return step(head);
};
let inner = format!("{indent} ");
let fallthrough_note = if rest.is_empty() {
format!("{inner}-- Closed item set: the last checker is the fallthrough.\n")
} else {
String::new()
};
format!(
"F.branch({{\n\
{inner}cond = F.p(\"$.item\"):eq(\"{head}\"),\n\
{inner}on_true = {on_true},\n\
{fallthrough_note}{inner}on_false = {on_false},\n\
{indent}}})",
on_true = step(head),
on_false = render_fanout_lane_body(next, rest, &inner),
)
}
fn render_fanout_template(
name: &str,
checkers: &[String],
operator: &str,
binding: &str,
) -> String {
let Some((first_checker, other_checkers)) = checkers.split_first() else {
return render_fanout_template(
name,
&DEFAULT_FANOUT_STAGES
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
operator,
binding,
);
};
let aggregate = FANOUT_AGGREGATE_STAGE;
let checker_list_lua = checkers
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
let init_ctx_sample = checkers
.iter()
.map(|c| format!("{c} = \"...\""))
.collect::<Vec<_>>()
.join(", ");
let mut out = String::new();
out.push_str("-- Scaffolded by `mse bp new fanout` (GH #82).\n");
out.push_str("-- Parallel branch dispatch + aggregate — the shape `bp_dsl` used to\n");
out.push_str("-- require `F.raw()` for. Every mandatory field is pre-filled\n");
out.push_str("-- (`halted_at` is unused here since no halt-on rule is declared;\n");
out.push_str("-- each operator agent's explicit `ws_operator` Runner; the\n");
out.push_str("-- operator's `kind = main_ai`; `strict_refs` + `strict_kind`).\n");
out.push_str("--\n");
out.push_str("-- Launch prerequisite (mirrors `pipeline` / `verdict` — GH #64): each\n");
out.push_str("-- checker reads its input from `$.d.<checker>`. Seed every\n");
out.push_str("-- checker under `d` when starting a run:\n");
out.push_str("--\n");
out.push_str(&format!(
"-- swarm_run(blueprint = ..., init_ctx = {{ d = {{ {init_ctx_sample} }} }})\n"
));
out.push_str("--\n");
out.push_str("-- The fanout `body` runs ONCE PER ITEM, so it holds exactly one\n");
out.push_str("-- dispatch per lane: a branch cascade on the bound `$.item` picks\n");
out.push_str("-- that lane's checker. (A `seq` of every checker in the body would\n");
out.push_str("-- run all of them for every item — N checkers x N items.)\n");
out.push_str("--\n");
out.push_str("-- Each lane writes its own `$.lane.<checker>` slot rather than a\n");
out.push_str("-- shared depth-1 path: lanes are disjoint ctx copies, so N lanes on\n");
out.push_str("-- one address are indistinguishable in the joined result — and a\n");
out.push_str("-- depth-1 `out` is a strong step-naming claim, so N lanes sharing\n");
out.push_str("-- it would contest that name and log a collision warning at\n");
out.push_str("-- register time. Nesting under `$.lane` keeps each claim weak, so\n");
out.push_str("-- the addresses stay distinct and the register stays quiet. A\n");
out.push_str("-- lane's own result therefore sits at `lane.<checker>` inside that\n");
out.push_str("-- lane's entry of the join array (read by the aggregate stage —\n");
out.push_str("-- see below).\n");
out.push_str("--\n");
out.push_str("-- The fanout `join = \"all\"` gathers every lane's final ctx into an\n");
out.push_str("-- ordered array at `$.results`; the aggregate stage reads that\n");
out.push_str("-- array and produces the final decision. `$.results` cannot be\n");
out.push_str("-- indexed from a path expr, so the aggregate step is also how a\n");
out.push_str("-- downstream gate reads this result — see\n");
out.push_str("-- `mse://guides/blueprint-authoring` § \"Fanout lanes, `$.results`,\n");
out.push_str("-- and the aggregate gate\".\n");
out.push_str("--\n");
out.push_str("-- See `mse://guides/blueprint-authoring` § \"Flow node kinds\" for\n");
out.push_str("-- the four `join` modes (`all` / `any` / `race` / `all_settled`)\n");
out.push_str("-- and `mse://guides/bp-dsl-templates` for this template's shape.\n\n");
out.push_str("local F = require(\"flow_dsl\")\n\n");
out.push_str("local flow = F.seq({\n");
out.push_str(&format!(
" F.assign({{ at = F.p(\"$.checkers\"), value = F.lit({{ {checker_list_lua} }}) }}),\n"
));
out.push_str(" F.fanout({\n");
out.push_str(" items = F.p(\"$.checkers\"),\n");
out.push_str(" bind = F.p(\"$.item\"),\n");
out.push_str(" join = \"all\",\n");
out.push_str(" out = F.p(\"$.results\"),\n");
out.push_str(&format!(
" body = {},\n",
render_fanout_lane_body(first_checker, other_checkers, " ")
));
out.push_str(" }),\n");
out.push_str(&format!(
" F.step({{ agent = \"{aggregate}\", input = F.p(\"$.results\"), out = F.p(\"$.{aggregate}\") }}),\n"
));
out.push_str("})\n\n");
out.push_str(&format!("return {{\n id = \"{name}\",\n flow = flow,\n"));
out.push_str(" agents = {\n");
for stage in checkers.iter().map(String::as_str).chain([aggregate]) {
out.push_str(&format!(
" {{ name = \"{stage}\", kind = \"operator\",\n \
spec = {{ operator_ref = \"{operator}\" }},\n \
profile = {{ system_prompt = \"TODO: describe {stage}\", tools = {{}} }},\n \
runner = {{ backend = \"ws_operator\", variant = \"{binding}\", tools = {{}} }} }},\n"
));
}
out.push_str(" },\n");
out.push_str(&format!(
" operators = {{ {{ name = \"{operator}\", kind = \"main_ai\" }} }},\n"
));
out.push_str(" strategy = { strict_refs = true, strict_kind = true },\n");
out.push_str(&format!(
" metadata = {{ description = \"TODO: describe {name}\" }},\n"
));
out.push_str("}\n");
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FixHint {
pub kind: &'static str,
pub reason: String,
pub patch_suggestion: String,
pub docs_ref: Option<String>,
}
pub(crate) fn fix_hint_from_compile_error(err_msg: &str) -> Option<FixHint> {
if err_msg.contains("profile.worker_binding is required") {
let agent = extract_between(err_msg, "agent '", "'");
let reason = match agent {
Some(name) => format!(
"operator agent '{name}' has no explicit Runner or legacy `profile.worker_binding`"
),
None => {
"an operator agent has no explicit Runner or legacy `profile.worker_binding`".into()
}
};
return Some(FixHint {
kind: "worker-binding-missing",
reason,
patch_suggestion:
"runner = { backend = \"ws_operator\", variant = \"claude\", tools = {} }".into(),
docs_ref: Some("mse://guides/bp-dsl-templates".into()),
});
}
if err_msg.contains("is not a member of the declared values") {
return Some(FixHint {
kind: "verdict-value-not-in-contract",
reason: "a Branch / Loop cond literal is outside its agent's declared verdict contract (`agents[N].verdict.values`)".into(),
patch_suggestion: "either add the cond's literal to `agents[N].verdict.values`, or change the cond to a value that is already declared".into(),
docs_ref: Some("mse://guides/blueprint-authoring".into()),
});
}
if err_msg.contains("missing field `at`") || err_msg.contains("halted_at") {
return Some(FixHint {
kind: "halted-at-missing",
reason: "the flow declares a halt-on rule but has no `halted_at` sink — where should the halted-stage id land in ctx?".into(),
patch_suggestion:
"halted_at = \"$.halted_at\", -- add inside the B.pipeline { ... } block, before `done = ...`"
.into(),
docs_ref: Some("mse://guides/bp-dsl-templates".into()),
});
}
None
}
pub(crate) fn diagnostic_for_error(err: &anyhow::Error) -> Option<mlua_swarm_diag::Diagnostic> {
if let Some(ce) = err
.chain()
.find_map(|c| c.downcast_ref::<mlua_swarm::CompileError>())
{
return Some(mlua_swarm_diag::Diagnostic::from(ce));
}
let msg = format!("{err:#}");
if msg.contains("missing field `at`") || msg.contains("halted_at") {
return Some(halted_at_missing_diagnostic());
}
None
}
fn halted_at_missing_diagnostic() -> mlua_swarm_diag::Diagnostic {
use mlua_swarm_diag::{Applicability, DiagLevel, DiagStage, Diagnostic, DocsRef, Suggestion};
Diagnostic::new(
"halted-at-missing",
DiagStage::CompileLint,
DiagLevel::Error,
"the flow declares a halt-on rule but has no `halted_at` sink — where should the \
halted-stage id land in ctx?",
)
.with_suggestion(Suggestion {
msg: "add a `halted_at` sink to the pipeline block".into(),
patch: "halted_at = \"$.halted_at\", -- add inside the B.pipeline { ... } block, \
before `done = ...`"
.into(),
applicability: Applicability::MaybeIncorrect,
})
.with_docs_ref(DocsRef {
uri: "mse://guides/bp-dsl-templates",
anchor: None,
})
}
pub(crate) fn fix_hint_from_diagnostic(d: &mlua_swarm_diag::Diagnostic) -> Option<FixHint> {
const FIXABLE_KINDS: [&str; 3] = [
"worker-binding-missing",
"verdict-value-not-in-contract",
"halted-at-missing",
];
if !FIXABLE_KINDS.contains(&d.kind) {
return None;
}
let suggestion = d.suggestion.as_ref()?;
Some(FixHint {
kind: d.kind,
reason: d.message.clone(),
patch_suggestion: suggestion.patch.clone(),
docs_ref: d.docs_ref.map(|r| r.uri.to_string()),
})
}
pub(crate) fn fix_hint_for_error(err: &anyhow::Error) -> Option<FixHint> {
if let Some(hint) = diagnostic_for_error(err)
.as_ref()
.and_then(fix_hint_from_diagnostic)
{
return Some(hint);
}
fix_hint_from_compile_error(&format!("{err:#}"))
}
fn extract_between<'a>(s: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
let start = s.find(prefix)? + prefix.len();
let rest = s.get(start..)?;
let end = rest.find(suffix)?;
Some(&rest[..end])
}
pub(crate) enum LintReport {
Ok { agents: usize, operators: usize },
Warn {
agents: usize,
operators: usize,
reason: String,
warnings: Vec<String>,
},
}
fn bundled_agents_dir() -> Option<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/mcp/resources/samples/agents");
dir.is_dir().then_some(dir)
}
pub(crate) fn compile_lint(
bp_value: &serde_json::Value,
script_path: &Path,
cli_includes: &[PathBuf],
) -> Result<LintReport> {
use mlua_swarm_compile::{
env_blueprint_includes, expand_file_refs_with_config, pre_read_in_bp_includes,
ResolveConfig,
};
let base = script_path.parent().unwrap_or_else(|| Path::new("."));
let default_kind = mlua_swarm::blueprint::loader::pre_read_default_agent_kind(bp_value);
let cfg = ResolveConfig::new(base.to_path_buf())
.with_in_bp_includes(pre_read_in_bp_includes(bp_value))
.with_env_includes(env_blueprint_includes())
.with_cli_includes(cli_includes.to_vec())
.with_bundled_default(bundled_agents_dir());
let expanded = match expand_file_refs_with_config(bp_value.clone(), &cfg, default_kind) {
Ok(v) => v,
Err(e) => {
let reason = format!(
"could not resolve $file/$agent_md refs relative to {} ({e}). Only the \
static DSL shape was validated; the server resolves these refs against \
its own include cascade at register time.",
base.display()
);
return Ok(LintReport::Warn {
agents: 0,
operators: 0,
reason,
warnings: vec![format!("unresolved refs: {e}")],
});
}
};
let bp: mlua_swarm::Blueprint = serde_json::from_value(expanded).map_err(|e| {
anyhow!("compile lint: blueprint shape invalid after $agent_md expansion: {e}")
})?;
let registry = lint_registry(&bp);
Compiler::new(registry)
.compile(&bp)
.map_err(|e| anyhow::Error::new(e).context("compile lint FAILED"))?;
Ok(LintReport::Ok {
agents: bp.agents.len(),
operators: bp.operators.len(),
})
}
struct LintStubOperator;
#[async_trait::async_trait]
impl mlua_swarm::Operator for LintStubOperator {
async fn execute(
&self,
_ctx: &mlua_swarm::Ctx,
_system: Option<String>,
_prompt: serde_json::Value,
_worker: Option<mlua_swarm::WorkerBinding>,
_worker_token: mlua_swarm::CapToken,
) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
Ok(mlua_swarm::WorkerResult {
value: serde_json::Value::Null,
ok: true,
stats: None,
})
}
fn requires_worker_binding(&self) -> bool {
true
}
}
pub(crate) fn lint_registry(bp: &mlua_swarm::Blueprint) -> SpawnerRegistry {
let mut reg = SpawnerRegistry::new();
reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(
mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new()),
));
reg.register::<LuaInProcessSpawnerFactory>(Arc::new(LuaInProcessSpawnerFactory::new()));
reg.register::<AgentBlockInProcessSpawnerFactory>(Arc::new(
AgentBlockInProcessSpawnerFactory::new(),
));
let op_factory = OperatorSpawnerFactory::new();
for op in &bp.operators {
op_factory.register_operator(op.name.clone(), Arc::new(LintStubOperator));
}
reg.register::<OperatorSpawnerFactory>(Arc::new(op_factory));
reg
}
pub(crate) struct RegisterOutcome {
pub url: String,
pub http_status: u16,
pub body: String,
}
pub(crate) async fn register(
bp_value: &serde_json::Value,
server: Option<&str>,
) -> Result<RegisterOutcome> {
let server = server.unwrap_or(DEFAULT_SERVER);
let id = bp_value
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("register: Blueprint JSON has no top-level 'id' string field"))?;
let url = format!("http://{server}/v1/blueprints/{id}");
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(bp_value)
.send()
.await
.map_err(|e| anyhow!("register: request to {url} failed: {e}"))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("register: {url} returned HTTP {status}: {body}"));
}
Ok(RegisterOutcome {
url,
http_status: status.as_u16(),
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_and_compile_lint(rendered: &str) -> Result<LintReport> {
let bp_value = dsl::build_bp_from_script(rendered)?;
compile_lint(&bp_value, Path::new("/tmp/nonexistent.bp.lua"), &[])
}
#[test]
fn pipeline_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("pipeline", "roundtrip-pipe", None, None, None, None)
.expect("render must succeed with defaults");
assert!(rendered.contains("backend = \"ws_operator\", variant = \"claude\""));
assert!(rendered.contains("halted_at = \"$.halted_at\""));
assert!(rendered.contains("kind = \"main_ai\""));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
match report {
LintReport::Ok { agents, operators } => {
assert_eq!(agents, DEFAULT_PIPELINE_STAGES.len());
assert_eq!(operators, 1);
}
LintReport::Warn { reason, .. } => panic!("expected Ok, got Warn: {reason}"),
}
}
#[test]
fn pipeline_template_documents_init_ctx_seeding() {
let rendered = render_template_by_kind(
"pipeline",
"seed-doc",
Some("ingest,transform,emit"),
None,
None,
None,
)
.expect("render must succeed");
assert!(
rendered.contains("$.d.<stage_name>"),
"header must name the input-path convention verbatim"
);
assert!(
rendered.contains(
"init_ctx = { d = { ingest = \"...\", transform = \"...\", emit = \"...\" } }"
),
"header must ship a concrete init_ctx sample tied to the actual stage names"
);
assert!(
rendered.contains("mse://guides/bp-dsl-templates"),
"header must link to the guide covering the convention"
);
}
#[test]
fn all_templates_pre_declare_operator_kind_main_ai() {
for template in ["pipeline", "single", "verdict"] {
let rendered =
render_template_by_kind(template, "op-kind-check", None, None, None, None)
.expect("render must succeed with defaults");
assert!(
rendered.contains("operators = { { name = \"main-ai\", kind = \"main_ai\" } }"),
"{template} template must emit an operator entry with `kind = \"main_ai\"` pre-declared, got: {rendered}"
);
}
}
#[test]
fn pipeline_template_honours_stages_operator_binding_flags() {
let rendered = render_template_by_kind(
"pipeline",
"roundtrip-pipe-3",
Some("greet,echo,farewell"),
None,
Some("primary"),
Some("claude-lite"),
)
.expect("render must succeed with custom flags");
assert!(rendered.contains("backend = \"ws_operator\", variant = \"claude-lite\""));
assert!(rendered.contains("operator_ref = \"primary\""));
assert_eq!(rendered.matches("kind = \"operator\"").count(), 3);
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 3,
operators: 1
}
));
}
#[test]
fn single_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("single", "roundtrip-single", None, None, None, None)
.expect("render must succeed with defaults");
assert!(rendered.contains(&format!("variant = \"{DEFAULT_BINDING}\"")));
assert!(rendered.contains(&format!("operator_ref = \"{DEFAULT_OPERATOR}\"")));
assert!(rendered.contains(&format!("agent = \"{DEFAULT_SINGLE_AGENT}\"")));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 1,
operators: 1
}
));
}
#[test]
fn verdict_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("verdict", "roundtrip-verdict", None, None, None, None)
.expect("render must succeed with defaults");
assert_eq!(rendered.matches("kind = \"operator\"").count(), 4);
assert!(rendered.contains("verdict = { channel = \"part\""));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 4,
operators: 1
}
));
}
#[test]
fn verdict_template_documents_init_ctx_seeding() {
let rendered =
render_template_by_kind("verdict", "seed-doc-verdict", None, None, None, None)
.expect("render must succeed with defaults");
assert!(
rendered.contains("$.d.<stage_name>"),
"verdict header must name the input-path convention verbatim"
);
let [analyze, review, publish] = &DEFAULT_VERDICT_STAGES;
assert!(
rendered.contains(&format!(
"init_ctx = {{ d = {{ {analyze} = \"...\", {review} = \"...\", {publish} = \"...\" }} }}"
)),
"verdict header must ship a concrete init_ctx sample tied to the 3 canonical stage names"
);
assert!(
rendered.contains("mse://guides/bp-dsl-templates"),
"verdict header must link to the guide covering the convention"
);
}
#[test]
fn verdict_template_stage_override_stays_3_slot() {
let rendered =
render_template_by_kind("verdict", "rv-partial", Some("scan"), None, None, None)
.expect("render must succeed with partial stages");
assert!(rendered.contains("B.stage \"scan\""));
assert!(rendered.contains(&format!("B.stage \"{}\"", DEFAULT_VERDICT_STAGES[1])));
assert!(rendered.contains(&format!("B.stage \"{}\"", DEFAULT_VERDICT_STAGES[2])));
let over =
render_template_by_kind("verdict", "rv-over", Some("a,b,c,d,e"), None, None, None)
.expect("render must succeed with over-supplied stages");
assert!(!over.contains("B.stage \"d\""));
assert!(!over.contains("B.stage \"e\""));
}
#[test]
fn unknown_template_returns_error_naming_accepted_list() {
let err = render_template_by_kind("bogus", "x", None, None, None, None)
.expect_err("unknown template must error");
let msg = format!("{err:#}");
assert!(msg.contains("unknown template 'bogus'"));
assert!(msg.contains("pipeline"));
assert!(msg.contains("single"));
assert!(msg.contains("verdict"));
assert!(msg.contains("fanout"));
}
#[test]
fn fanout_template_round_trips_with_defaults() {
let rendered =
render_template_by_kind("fanout", "roundtrip-fanout", None, None, None, None)
.expect("render must succeed with defaults");
assert_eq!(rendered.matches("kind = \"operator\"").count(), 3);
assert!(rendered.contains("F.fanout({"));
assert!(rendered.contains("join = \"all\","));
for checker in DEFAULT_FANOUT_STAGES {
assert!(
rendered.contains(&format!("agent = \"{checker}\"")),
"default checker '{checker}' missing"
);
}
assert!(rendered.contains(&format!("agent = \"{FANOUT_AGGREGATE_STAGE}\"")));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 3,
operators: 1
}
));
}
#[test]
fn fanout_template_honours_stages_flag() {
let rendered = render_template_by_kind(
"fanout",
"custom-fanout",
Some("lint,test,build"),
None,
None,
None,
)
.expect("render must succeed with 3 stages");
assert_eq!(rendered.matches("kind = \"operator\"").count(), 4);
for checker in ["lint", "test", "build"] {
assert!(
rendered.contains(&format!("agent = \"{checker}\"")),
"custom checker '{checker}' missing"
);
}
assert!(rendered.contains(&format!("agent = \"{FANOUT_AGGREGATE_STAGE}\"")));
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 4,
operators: 1
}
));
}
#[test]
fn fanout_template_documents_init_ctx_seeding() {
let rendered = render_template_by_kind(
"fanout",
"seed-doc-fanout",
Some("lint,test"),
None,
None,
None,
)
.expect("render must succeed");
assert!(
rendered.contains("$.d.lint"),
"each checker must read its input from $.d.<checker>"
);
assert!(
rendered.contains("init_ctx = { d = { lint = \"...\", test = \"...\" } }"),
"fanout header must ship a concrete init_ctx sample tied to the checker names"
);
assert!(
rendered.contains("mse://guides/bp-dsl-templates"),
"fanout header must link to the guide covering the convention"
);
}
#[test]
fn fanout_template_dispatches_one_step_per_lane() {
let rendered = render_template_by_kind(
"fanout",
"lane-arithmetic",
Some("lint,test,build"),
None,
None,
None,
)
.expect("render must succeed with 3 stages");
let bp = dsl::build_bp_from_script(&rendered).expect("rendered scaffold must build");
let body = &bp["flow"]["children"][1]["body"];
let outer_cond = &body["cond"];
assert_eq!(body["kind"], "branch", "lane body must be a branch: {body}");
assert_eq!(
outer_cond,
&serde_json::json!({
"op": "eq",
"lhs": {"op": "path", "at": "$.item"},
"rhs": {"op": "lit", "value": "lint"},
}),
"each lane is selected by comparing the bound $.item"
);
assert_eq!(body["then"]["kind"], "step");
assert_eq!(body["then"]["ref"], "lint");
assert_eq!(
body["then"]["out"],
serde_json::json!({"op": "path", "at": "$.lane.lint"})
);
let inner = &body["else"];
assert_eq!(inner["kind"], "branch", "cascade depth must be 2: {inner}");
assert_eq!(
inner["cond"]["rhs"],
serde_json::json!({"op": "lit", "value": "test"})
);
assert_eq!(inner["then"]["ref"], "test");
assert_eq!(
inner["then"]["out"],
serde_json::json!({"op": "path", "at": "$.lane.test"})
);
assert_eq!(
inner["else"]["kind"], "step",
"the last checker is the terminal else, not another branch"
);
assert_eq!(inner["else"]["ref"], "build");
assert_eq!(
inner["else"]["out"],
serde_json::json!({"op": "path", "at": "$.lane.build"})
);
let rendered_body = serde_json::to_string(body).expect("body serializes");
assert_eq!(
rendered_body.matches("\"kind\":\"step\"").count(),
3,
"one step per lane: {rendered_body}"
);
assert_eq!(
rendered_body.matches("\"kind\":\"branch\"").count(),
2,
"N checkers => N-1 branches: {rendered_body}"
);
assert!(
!rendered_body.contains("\"kind\":\"seq\""),
"a seq body would run every checker for every item: {rendered_body}"
);
}
#[test]
fn fanout_template_single_stage_emits_a_bare_step_body() {
let rendered =
render_template_by_kind("fanout", "solo-fanout", Some("solo"), None, None, None)
.expect("render must succeed with a single stage");
let bp = dsl::build_bp_from_script(&rendered).expect("rendered scaffold must build");
let body = &bp["flow"]["children"][1]["body"];
assert_eq!(body["kind"], "step", "single lane needs no branch: {body}");
assert_eq!(body["ref"], "solo");
assert_eq!(
body["in"],
serde_json::json!({"op": "path", "at": "$.d.solo"})
);
assert_eq!(
body["out"],
serde_json::json!({"op": "path", "at": "$.lane.solo"})
);
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: 2,
operators: 1
}
));
}
#[test]
fn fanout_template_lanes_claim_distinct_step_names() {
let rendered = render_template_by_kind(
"fanout",
"naming-fanout",
Some("lint,test,build"),
None,
None,
None,
)
.expect("render must succeed with 3 stages");
let warnings = step_naming_warnings(&rendered);
assert!(
warnings.is_empty(),
"scaffolded lanes must not contest a step name: {warnings:?}"
);
let collided = rendered
.replace("$.lane.lint", "$.branch_out")
.replace("$.lane.test", "$.branch_out")
.replace("$.lane.build", "$.branch_out");
let collided_warnings = step_naming_warnings(&collided);
assert!(
collided_warnings.iter().any(|w| w.name == "branch_out"),
"control: a shared depth-1 lane `out` must contest `branch_out`, \
otherwise this test cannot fail: {collided_warnings:?}"
);
}
fn step_naming_warnings(rendered: &str) -> Vec<mlua_swarm::StepNamingWarning> {
let bp_value = dsl::build_bp_from_script(rendered).expect("rendered scaffold must build");
let bp: mlua_swarm::Blueprint =
serde_json::from_value(bp_value).expect("scaffold must be a valid Blueprint");
let (_, warnings) =
mlua_swarm::StepNaming::from_blueprint(&bp).expect("scaffold must not hard-collide");
warnings
}
#[test]
fn fanout_template_with_empty_stages_flag_falls_back_to_defaults() {
let rendered = render_template_by_kind("fanout", "recov", Some(""), None, None, None)
.expect("render must succeed with empty stages");
for checker in DEFAULT_FANOUT_STAGES {
assert!(
rendered.contains(&format!("agent = \"{checker}\"")),
"empty-stages fallback must include default checker '{checker}'"
);
}
build_and_compile_lint(&rendered).expect("empty-stages fallback must compile-lint");
}
#[test]
fn fix_hint_worker_binding_extracts_agent_name_and_names_kind() {
let msg = "compile lint FAILED: agent 'greeter' spec invalid: \
profile.worker_binding is required for this operator backend. Fix by either: \
(a) if authoring the Blueprint JSON directly, ...";
let hint = fix_hint_from_compile_error(msg).expect("worker_binding hint must fire");
assert_eq!(hint.kind, "worker-binding-missing");
assert!(hint.reason.contains("greeter"));
assert!(hint.patch_suggestion.contains("backend = \"ws_operator\""));
assert_eq!(
hint.docs_ref.as_deref(),
Some("mse://guides/bp-dsl-templates")
);
}
#[test]
fn fix_hint_worker_binding_reason_falls_back_when_no_agent_quoted() {
let msg =
"compile lint FAILED: profile.worker_binding is required for this operator backend.";
let hint = fix_hint_from_compile_error(msg).expect("worker_binding hint must fire");
assert!(hint.reason.contains("operator agent"));
assert!(hint.reason.contains("explicit Runner"));
}
#[test]
fn fix_hint_verdict_contract_mismatch_names_the_contract_field() {
let msg = "compile lint FAILED: value 'NOT_DECLARED' is not a member of the declared values [\"PASS\", \"BLOCKED\"]";
let hint = fix_hint_from_compile_error(msg).expect("verdict hint must fire");
assert_eq!(hint.kind, "verdict-value-not-in-contract");
assert!(hint.reason.contains("`agents[N].verdict.values`"));
assert!(hint.patch_suggestion.contains("add the cond's literal"));
}
#[test]
fn fix_hint_halted_at_fires_on_missing_field_at() {
let msg =
"compile lint FAILED: missing field `at` (hint: fetch the Blueprint JSON Schema...)";
let hint = fix_hint_from_compile_error(msg).expect("halted_at hint must fire");
assert_eq!(hint.kind, "halted-at-missing");
assert!(hint
.patch_suggestion
.contains("halted_at = \"$.halted_at\""));
}
#[test]
fn fix_hint_returns_none_for_unknown_lint_shape() {
assert!(
fix_hint_from_compile_error("some new lint the mapping doesn't know about").is_none()
);
assert!(fix_hint_from_compile_error("").is_none());
}
#[test]
fn typed_worker_binding_error_routes_through_diagnostic_to_fix_hint() {
let ce = mlua_swarm::CompileError::InvalidSpec {
name: "greeter".into(),
msg: format!(
"{}. Fix by either: (a) ...",
mlua_swarm::WORKER_BINDING_REQUIRED_MSG_PREFIX
),
};
let err = anyhow::Error::new(ce).context("compile lint FAILED");
let d = diagnostic_for_error(&err).expect("typed path must recognize CompileError");
assert_eq!(d.kind, "worker-binding-missing");
let hint = fix_hint_for_error(&err).expect("hint must derive from the diagnostic");
assert_eq!(hint.kind, "worker-binding-missing");
assert!(hint.reason.contains("greeter"));
assert!(hint.patch_suggestion.contains("backend = \"ws_operator\""));
assert_eq!(
hint.docs_ref.as_deref(),
Some("mse://guides/bp-dsl-templates")
);
}
#[test]
fn typed_non_fixable_error_yields_diagnostic_but_no_hint() {
let ce = mlua_swarm::CompileError::DuplicateAgent("scout".into());
let err = anyhow::Error::new(ce).context("compile lint FAILED");
let d = diagnostic_for_error(&err).expect("typed path must recognize CompileError");
assert_eq!(d.kind, "duplicate-agent-name");
assert!(fix_hint_from_diagnostic(&d).is_none());
assert!(fix_hint_for_error(&err).is_none());
}
#[test]
fn halted_at_serde_error_synthesizes_the_diagnostic_on_the_string_path() {
let err = anyhow!(
"compile lint: blueprint shape invalid after $agent_md expansion: missing field `at`"
);
let d = diagnostic_for_error(&err).expect("string path must recognize the serde error");
assert_eq!(d.kind, "halted-at-missing");
let hint = fix_hint_for_error(&err).expect("halted-at hint must fire");
assert_eq!(hint.kind, "halted-at-missing");
assert!(hint
.patch_suggestion
.contains("halted_at = \"$.halted_at\""));
}
#[test]
fn compile_lint_preserves_the_typed_compile_error_in_the_chain() {
let rendered = render_single_template("solo-bp", "solo", "main-ai", "claude");
let runner_clause =
"runner = { backend = \"ws_operator\", variant = \"claude\", tools = {} } },";
assert!(
rendered.contains(runner_clause),
"template shape drifted; update the runner_clause literal"
);
let stripped = rendered.replace(runner_clause, "},");
let bp_value = dsl::build_bp_from_script(&stripped).expect("script must build");
let err = compile_lint(&bp_value, Path::new("/tmp/nonexistent.bp.lua"), &[])
.err()
.expect("compile lint must fail without a runner");
assert!(
err.chain()
.any(|c| c.downcast_ref::<mlua_swarm::CompileError>().is_some()),
"typed CompileError must survive the compile_lint anyhow chain: {err:#}"
);
let d =
diagnostic_for_error(&err).expect("typed CompileError must project into a Diagnostic");
assert!(
mlua_swarm_diag::lint_decl(d.kind).is_some(),
"projected kind '{}' must be a declared lint",
d.kind
);
assert!(format!("{err:#}").starts_with("compile lint FAILED: "));
}
#[test]
fn extract_between_returns_first_match_only() {
assert_eq!(extract_between("agent 'a' 'b'", "agent '", "'"), Some("a"));
assert_eq!(extract_between("no prefix here", "agent '", "'"), None);
assert_eq!(extract_between("agent 'unclosed", "agent '", "'"), None);
}
#[test]
fn pipeline_template_with_empty_stages_flag_falls_back_to_defaults() {
let rendered = render_template_by_kind("pipeline", "rp-empty", Some(""), None, None, None)
.expect("render must succeed and fall back");
let report = build_and_compile_lint(&rendered).expect("compile lint must succeed");
assert!(matches!(
report,
LintReport::Ok {
agents: n,
operators: 1
} if n == DEFAULT_PIPELINE_STAGES.len()
));
}
}