Skip to main content

mlua_swarm_server/
blueprints.rs

1//! HTTP surface for inspecting Blueprint state (= for debug / animation verification).
2//! `/v1/blueprints/:id/head` returns the head Blueprint JSON;
3//! `/v1/blueprints/:id/history` returns the commit-version list;
4//! `/v1/blueprints/:id/binding-requirements` returns the declaration-side
5//! `BindRequest` list an operator's capability manifest must cover.
6//! Callers pass a shared `Store` via `Arc` and mount the router.
7
8use axum::{
9    extract::{Path, Query, State},
10    http::StatusCode,
11    routing::{get, post},
12    Json, Router,
13};
14use mlua_swarm::blueprint::loader::pre_read_default_agent_kind;
15use mlua_swarm::blueprint::store::{
16    blueprint_version, BlueprintId, BlueprintStore, CommitMetadata,
17};
18use mlua_swarm::blueprint::{default_global_agent_kind, AgentKind, Blueprint};
19use mlua_swarm::core::explain::{explain_agent_ctx, CtxTier};
20use mlua_swarm::core::step_naming::StepNaming;
21use mlua_swarm::operator::render::template_variables;
22use mlua_swarm::{binding_requests, LegacyWorkerBindingPolicy};
23use mlua_swarm_compile::{
24    env_blueprint_includes, expand_file_refs_with_config, pre_read_in_bp_includes, ResolveConfig,
25};
26use mlua_swarm_schema::{
27    resolve_bound_agents, resolve_bound_agents_strict, resolve_runner, BindRequest, BindingDigest,
28    Runner, RunnerResolutionSource,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::BTreeMap;
32use std::path::PathBuf;
33use std::sync::Arc;
34
35/// Router state: BP store + the base dir used to resolve `$file` / `$agent_md`
36/// refs + `default_agent_kind` from the CLI (= layer (2) of the 4-tier cascade —
37/// the CLI override layer).
38/// When `ref_base = None`, ref expansion is skipped (= seed bodies are parsed
39/// as raw JSON).
40#[derive(Clone)]
41pub struct BlueprintsState {
42    /// Backing Blueprint store (git2 or in-memory backend).
43    pub store: Arc<dyn BlueprintStore>,
44    /// Base dir for `$file` / `$agent_md` ref expansion; `None` skips expansion.
45    pub ref_base: Option<PathBuf>,
46    /// Additional directories (tier 5 of the include cascade — see
47    /// `mlua-swarm-compile::ResolveConfig`) searched after `ref_base`
48    /// (tier 1). Empty vec = no server-config includes; the register
49    /// path still walks the in-bp and env tiers.
50    pub ref_includes: Vec<PathBuf>,
51    /// CLI-level `default_agent_kind` override (layer (2) of the 4-tier cascade).
52    pub cli_default_agent_kind: Option<AgentKind>,
53    /// Server-side strict-embed switch (design table row 3, Phase 6 —
54    /// issue 4c4e3eb8). When `true`, `POST /v1/blueprints/:id`
55    /// refuses raw bodies that still carry `$file` / `$agent_md` refs
56    /// (returns 400 with a hint pointing at `mse bp build
57    /// --strict-embed`), so ref resolution is pushed onto the client
58    /// and the server only ever sees pre-embedded Blueprint JSON.
59    /// Default `false` = the server runs the linker itself
60    /// (backward-compat). Wired from
61    /// [`crate::config::ResolvedConfig::blueprint_strict_embed`] via
62    /// the CLI `--blueprint-strict-embed` flag or the config-file
63    /// `blueprint_strict_embed` key. NOTE: independent from the
64    /// client-side `mse bp build --strict-embed` flag despite the
65    /// shared token — that one pre-embeds refs at build time, this one
66    /// rejects raw refs at register time; the two layers are compared
67    /// side by side in the bundled `mse://guides/strict-embed-modes`
68    /// resource (GH #78 P1b).
69    pub strict_embed: bool,
70    /// Migration gate for the deprecated `AgentProfile.worker_binding`
71    /// Runner fallback, wired from the same server config the launch path
72    /// uses ([`crate::config::ResolvedConfig::legacy_worker_binding_policy`]).
73    /// `GET /v1/blueprints/:id/binding-requirements` applies this policy so
74    /// its declared requirements match what launch will actually resolve.
75    /// Defaults to `Allow` (compat) in [`build_blueprints_router`].
76    pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
77}
78
79/// Minimal entry: no `ref_base` (ref expansion skipped), no CLI default
80/// kind override, and `strict_embed = false` (backward-compat = the
81/// server accepts raw refs and runs the linker itself when `ref_base` is
82/// set).
83pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
84    build_blueprints_router_with_refs(
85        store,
86        None,
87        Vec::new(),
88        None,
89        false,
90        LegacyWorkerBindingPolicy::Allow,
91    )
92}
93
94/// When `ref_base` is set, `seed_blueprint` resolves `{"$file": ...}` /
95/// `{"$agent_md": ...}` refs in the body under that base dir and expands them.
96/// Path hygiene (absolute paths and `..` are rejected) is enforced inside
97/// `expand_file_refs`, sandboxed to the subtree under the base dir.
98///
99/// `cli_default_agent_kind` = the override from CLI `--default-agent-kind`
100/// (= layer (2) of the 4-tier cascade). Falls back when the BP JSON top-level
101/// `default_agent_kind` (= (3)) is absent; if that too is absent, uses the
102/// Schema `impl Default` = `Operator` (= (1)).
103///
104/// `legacy_worker_binding_policy` is the migration gate the launch path
105/// applies; `GET /v1/blueprints/:id/binding-requirements` reuses it so its
106/// declared requirements match what launch resolves.
107pub fn build_blueprints_router_with_refs(
108    store: Arc<dyn BlueprintStore>,
109    ref_base: Option<PathBuf>,
110    ref_includes: Vec<PathBuf>,
111    cli_default_agent_kind: Option<AgentKind>,
112    strict_embed: bool,
113    legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
114) -> Router {
115    let state = BlueprintsState {
116        store,
117        ref_base,
118        ref_includes,
119        cli_default_agent_kind,
120        strict_embed,
121        legacy_worker_binding_policy,
122    };
123    Router::new()
124        .route("/v1/blueprints/:id/head", get(get_head))
125        .route("/v1/blueprints/:id/history", get(get_history))
126        .route(
127            "/v1/blueprints/:id/binding-requirements",
128            get(binding_requirements),
129        )
130        .route(
131            "/v1/blueprints/:id/agents/:agent/explain",
132            get(explain_agent),
133        )
134        .route(
135            "/v1/blueprints/:id/agents/explain",
136            get(explain_agents_batch),
137        )
138        .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
139        .route(
140            "/v1/blueprints/:id",
141            post(seed_blueprint).delete(archive_blueprint),
142        )
143        .with_state(state)
144}
145
146/// `DELETE /v1/blueprints/:id` — archive (logical soft-delete) the id.
147/// Appends an archive marker commit; the underlying Blueprint YAML is
148/// preserved as history. After archive, `read_head` /
149/// `TaskApplication::resolve` reject with `Archived`, and `list_ids`
150/// filters the id out by default.
151///
152/// Semantic rename: the HTTP path stays `DELETE` for client
153/// compatibility, but the behavior is archive, not physical delete.
154/// Restore via `POST /v1/blueprints/:id/unarchive`.
155///
156/// Returns: 204 No Content.
157async fn archive_blueprint(
158    State(state): State<BlueprintsState>,
159    Path(id): Path<String>,
160) -> Result<StatusCode, (StatusCode, String)> {
161    let bp_id = BlueprintId::new(id.clone());
162    state.store.archive_id(&bp_id).await.map_err(|e| match e {
163        mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
164        | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
165            (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
166        }
167        other => (
168            StatusCode::INTERNAL_SERVER_ERROR,
169            format!("archive_id: {other}"),
170        ),
171    })?;
172    Ok(StatusCode::NO_CONTENT)
173}
174
175/// `POST /v1/blueprints/:id/unarchive` — reverse of archive. Appends
176/// an unarchive marker commit so the audit trail records the event.
177async fn unarchive_blueprint(
178    State(state): State<BlueprintsState>,
179    Path(id): Path<String>,
180) -> Result<StatusCode, (StatusCode, String)> {
181    let bp_id = BlueprintId::new(id.clone());
182    state
183        .store
184        .unarchive_id(&bp_id)
185        .await
186        .map_err(|e| match e {
187            mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
188            | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
189                (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
190            }
191            other => (
192                StatusCode::INTERNAL_SERVER_ERROR,
193                format!("unarchive_id: {other}"),
194            ),
195        })?;
196    Ok(StatusCode::NO_CONTENT)
197}
198
199/// Format a Blueprint deserialization failure with a schema pointer, so a
200/// register error is self-serviceable (the schema export is the MCP adapter
201/// `bp_schema` tool = schemars JSON Schema of `Blueprint`).
202fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
203    format!(
204        "blueprint parse: {e} \
205         (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
206    )
207}
208
209/// Walk the raw seed body and collect the relative paths of every
210/// `{"$file": "..."}` / `{"$agent_md": "..."}` ref still present.
211/// Returns `None` when the body is already fully embedded (= no refs
212/// left), `Some(paths)` otherwise. Used by [`seed_blueprint`] to gate
213/// the `strict_embed` opt-in (design table row 3 — server-side strict
214/// mode refuses raw refs so clients must `mse bp build --strict-embed`
215/// upstream).
216fn collect_unembedded_refs(val: &serde_json::Value) -> Option<Vec<String>> {
217    let mut acc: Vec<String> = Vec::new();
218    walk_refs(val, &mut acc);
219    if acc.is_empty() {
220        None
221    } else {
222        Some(acc)
223    }
224}
225
226fn walk_refs(val: &serde_json::Value, acc: &mut Vec<String>) {
227    match val {
228        serde_json::Value::Object(map) => {
229            for key in ["$file", "$agent_md"] {
230                if let Some(serde_json::Value::String(rel)) = map.get(key) {
231                    acc.push(format!("{key}={rel}"));
232                }
233            }
234            for v in map.values() {
235                walk_refs(v, acc);
236            }
237        }
238        serde_json::Value::Array(arr) => {
239            for v in arr {
240                walk_refs(v, acc);
241            }
242        }
243        _ => {}
244    }
245}
246
247/// Format the ref-expand failure with an include-cascade fix hint. The
248/// underlying [`mlua_swarm_compile::LoadError::FileRef`] message already
249/// names every searched dir (see `linker.rs::resolve_ref_path`); this
250/// wrapper appends the actionable knobs so authors know which tier to
251/// extend.
252fn ref_expand_error_with_fix_hint(e: &mlua_swarm_compile::LoadError) -> String {
253    format!(
254        "ref expand: {e} \
255         (fix: extend the include cascade — add the containing directory via CLI \
256         `--include <DIR>` on `mse serve`, env `MSE_BLUEPRINT_INCLUDES`, config-file \
257         `blueprint_ref_includes`, or in-bp top-level `blueprint_ref_includes = {{...}}`; \
258         or pre-embed refs client-side via `mse bp build --strict-embed`)"
259    )
260}
261
262/// `POST /v1/blueprints/:id` — register / re-register a Blueprint.
263///
264/// Semantics:
265/// - No prior head → seed as first commit (`write_new`, empty
266///   parents). Returns 201.
267/// - Prior head with **same** `ContentHash` → idempotent no-op.
268///   Returns 200 with `seeded: false`.
269/// - Prior head with **different** `ContentHash` → append a new
270///   commit on top of the current head (Git-native commit graph
271///   advance). Returns 201.
272/// - Prior head archived → returns 409 `Archived` (call
273///   `POST /:id/unarchive` first).
274/// - Concurrent POST on the same id → per-id lock contention returns
275///   429 Too Many Requests (client retry).
276///
277/// Path id vs body.id mismatch returns 400.
278///
279/// When `BlueprintsState.ref_base = Some(dir)`, `{"$file": ...}` /
280/// `{"$agent_md": ...}` refs in the body are expanded under the base
281/// dir via `expand_file_refs` before being parsed into a typed
282/// `Blueprint` (= path hygiene is applied by the loader, rejecting
283/// absolute paths and `..`).
284async fn seed_blueprint(
285    State(state): State<BlueprintsState>,
286    Path(id): Path<String>,
287    Json(raw_body): Json<serde_json::Value>,
288) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
289    // Design table row 3, Phase 6 (issue 4c4e3eb8): strict-embed
290    // pre-check. When enabled, refuse any raw body that still carries
291    // `$file` / `$agent_md` refs — ref resolution is pushed onto the
292    // client. Runs before the ref-base branch so it catches raw refs
293    // even when the server has no `ref_base` configured.
294    if state.strict_embed {
295        if let Some(refs) = collect_unembedded_refs(&raw_body) {
296            return Err((
297                StatusCode::BAD_REQUEST,
298                format!(
299                    "strict_embed: raw body carries unembedded refs ({}); \
300                     pre-embed client-side via `mse bp build --strict-embed` \
301                     and POST the fully-resolved Blueprint JSON",
302                    refs.join(", ")
303                ),
304            ));
305        }
306    }
307    let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
308        // Four-tier cascade for the kind resolution: (3) BP JSON top-level
309        // `default_agent_kind` → (2) CLI value → (1) Schema impl Default =
310        // Operator. Handed to expand_file_refs so the loader can resolve the
311        // kind when the $agent_md sibling is missing. The sibling `"kind"`
312        // literal (tier 4) wins first inside expand_file_refs.
313        let default_kind = match pre_read_default_agent_kind(&raw_body) {
314            // BP top-level carries a literal → use it verbatim.
315            kind if raw_body.get("default_agent_kind").is_some() => kind,
316            // BP top-level absent → CLI value fallback → Schema default.
317            _ => state
318                .cli_default_agent_kind
319                .clone()
320                .unwrap_or_else(default_global_agent_kind),
321        };
322        // Six-tier include cascade: (1) ref_base = bp.lua parent, (2)
323        // in-bp `blueprint_ref_includes`, (3) env
324        // `MSE_BLUEPRINT_INCLUDES`, (5) server config
325        // `blueprint_ref_includes`. Tiers 4 (CLI `--include` on the
326        // client) and 6 (bundled default) are client-side only —
327        // server-side never sees them.
328        let cfg = ResolveConfig::new(base.clone())
329            .with_in_bp_includes(pre_read_in_bp_includes(&raw_body))
330            .with_env_includes(env_blueprint_includes())
331            .with_config_includes(state.ref_includes.clone());
332        let expanded = expand_file_refs_with_config(raw_body, &cfg, default_kind)
333            .map_err(|e| (StatusCode::BAD_REQUEST, ref_expand_error_with_fix_hint(&e)))?;
334        serde_json::from_value(expanded)
335            .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
336    } else {
337        serde_json::from_value(raw_body)
338            .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
339    };
340    let store = state.store;
341    if id != body.id.as_str() {
342        return Err((
343            StatusCode::BAD_REQUEST,
344            format!("path id={id} != body.id={}", body.id),
345        ));
346    }
347    let bp_id = BlueprintId::new(id.clone());
348    let v = blueprint_version(&body).map_err(|e| {
349        (
350            StatusCode::INTERNAL_SERVER_ERROR,
351            format!("bp version: {e}"),
352        )
353    })?;
354    let prev_head = match store.read_head(&bp_id).await {
355        Ok(traced) => Some(traced),
356        Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
357        Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
358            return Err((
359                StatusCode::CONFLICT,
360                format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
361            ));
362        }
363        Err(e) => {
364            return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
365        }
366    };
367    if let Some(traced) = &prev_head {
368        if traced.trace.version == v {
369            return Ok((
370                StatusCode::OK,
371                Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
372            ));
373        }
374    }
375    let parents: Vec<_> = prev_head
376        .as_ref()
377        .map(|t| vec![t.trace.version])
378        .unwrap_or_default();
379    let now_ms = std::time::SystemTime::now()
380        .duration_since(std::time::UNIX_EPOCH)
381        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
382        .as_millis() as i64;
383    let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
384    store
385        .write_new(&bp_id, &body, &parents, meta)
386        .await
387        .map_err(|e| match &e {
388            mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
389                StatusCode::TOO_MANY_REQUESTS,
390                format!("blueprint {id} lock busy; retry"),
391            ),
392            mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
393                StatusCode::CONFLICT,
394                format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
395            ),
396            _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
397        })?;
398    Ok((
399        StatusCode::CREATED,
400        Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
401    ))
402}
403
404#[derive(Debug, Serialize)]
405struct HeadResponse {
406    id: String,
407    version: String,
408    blueprint: Blueprint,
409}
410
411async fn get_head(
412    State(state): State<BlueprintsState>,
413    Path(id): Path<String>,
414) -> Result<Json<HeadResponse>, (StatusCode, String)> {
415    let store = state.store;
416    let bp_id = BlueprintId::new(id.clone());
417    let traced = store
418        .read_head(&bp_id)
419        .await
420        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
421    Ok(Json(HeadResponse {
422        id,
423        version: format!("{:?}", traced.trace.version),
424        blueprint: traced.value,
425    }))
426}
427
428#[derive(Debug, Deserialize)]
429struct HistoryQuery {
430    #[serde(default = "default_limit")]
431    limit: usize,
432}
433
434fn default_limit() -> usize {
435    20
436}
437
438#[derive(Debug, Serialize)]
439struct HistoryEntry {
440    /// Content hash (= debug representation of `BlueprintVersion`).
441    hash: String,
442    /// SemVer label (`Blueprint.metadata.version_label`); `null` when unset.
443    version_label: Option<String>,
444    /// One-line changelog (= `CommitMetadata.rationale`).
445    rationale: String,
446}
447
448#[derive(Debug, Serialize)]
449struct HistoryResponse {
450    count: usize,
451    entries: Vec<HistoryEntry>,
452}
453
454async fn get_history(
455    State(state): State<BlueprintsState>,
456    Path(id): Path<String>,
457    Query(q): Query<HistoryQuery>,
458) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
459    let store = state.store;
460    let bp_id = BlueprintId::new(id);
461    let versions = store
462        .history(&bp_id, q.limit)
463        .await
464        .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
465    let mut entries = Vec::with_capacity(versions.len());
466    for v in versions {
467        let traced = store.read_version(&bp_id, v).await.map_err(|e| {
468            (
469                StatusCode::INTERNAL_SERVER_ERROR,
470                format!("read_version: {e}"),
471            )
472        })?;
473        let rationale = store
474            .read_commit_rationale(&bp_id, v)
475            .await
476            .unwrap_or(None)
477            .unwrap_or_default();
478        entries.push(HistoryEntry {
479            hash: format!("{:?}", v),
480            version_label: traced.value.metadata.version_label.clone(),
481            rationale,
482        });
483    }
484    let count = entries.len();
485    Ok(Json(HistoryResponse { count, entries }))
486}
487
488// ──────────────────────────────────────────────────────────────────────────
489// GET /v1/blueprints/:id/binding-requirements
490// ──────────────────────────────────────────────────────────────────────────
491
492/// Response body for `GET /v1/blueprints/:id/binding-requirements`.
493///
494/// The declaration-side reverse lookup an operator uses to machine-generate
495/// its capability manifest: one [`BindRequest`] per Runner-backed agent,
496/// byte-identical to what `binding_requests` reconstructs on the launch
497/// path. Read-only and provider-free — nothing here mutates the registry or
498/// calls a binding provider (requirements are pure declarations; attestation
499/// happens only when a Run is dispatched).
500#[derive(Debug, Serialize, schemars::JsonSchema)]
501pub struct BindingRequirementsResponse {
502    /// Blueprint id (echoed back from the path param).
503    pub blueprint_id: String,
504    /// `CompilerStrategy.strict_binding` for this Blueprint — whether launch
505    /// fails closed when a requirement is left unattested.
506    pub strict_binding: bool,
507    /// One request per Runner-backed agent, in Blueprint declaration order;
508    /// `[]` when no agent resolves to a Runner.
509    pub requirements: Vec<BindRequest>,
510}
511
512/// `GET /v1/blueprints/:id/binding-requirements` — the reverse lookup an
513/// operator uses to machine-generate its capability manifest: what bindings
514/// does this registered Blueprint require? Resolves the head Blueprint's
515/// Runner-backed agents under the SAME [`LegacyWorkerBindingPolicy`] the
516/// launch path applies (so the returned requirements match what launch will
517/// actually request), then reconstructs the platform-neutral `BindRequest`
518/// list via `binding_requests`.
519///
520/// Read-only, provider-free, and the same unauthenticated diagnostic trust
521/// tier as [`get_head`]: it never mutates the registry and never calls a
522/// binding provider.
523///
524/// - Unknown Blueprint id → 404 (same error mapping as [`get_head`]).
525/// - Resolution failure (a legacy `profile.worker_binding` rejected under
526///   `LegacyWorkerBindingPolicy::Reject`, or an unresolvable `runner_ref` /
527///   `default_runner`) → 422 carrying the resolve error message.
528async fn binding_requirements(
529    State(state): State<BlueprintsState>,
530    Path(id): Path<String>,
531) -> Result<Json<BindingRequirementsResponse>, (StatusCode, String)> {
532    let store = state.store;
533    let bp_id = BlueprintId::new(id.clone());
534    let traced = store
535        .read_head(&bp_id)
536        .await
537        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
538    let bp = traced.value;
539
540    // Apply the SAME legacy-worker-binding policy the launch path uses
541    // (`TaskLaunchService::load_or_resolve_bound_agents`) so the declared
542    // requirements match what launch will actually resolve.
543    let bound = match state.legacy_worker_binding_policy {
544        LegacyWorkerBindingPolicy::Allow => resolve_bound_agents(&bp),
545        LegacyWorkerBindingPolicy::Reject => resolve_bound_agents_strict(&bp),
546    }
547    .map_err(|e| {
548        (
549            StatusCode::UNPROCESSABLE_ENTITY,
550            format!("resolve bound agents: {e}"),
551        )
552    })?;
553
554    Ok(Json(BindingRequirementsResponse {
555        blueprint_id: id,
556        strict_binding: bp.strategy.strict_binding,
557        requirements: binding_requests(&bound),
558    }))
559}
560
561// ──────────────────────────────────────────────────────────────────────────
562// GET /v1/blueprints/:id/agents/:agent/explain
563// ──────────────────────────────────────────────────────────────────────────
564
565/// `blueprint` field of [`ExplainAgentResponse`]: which Blueprint this
566/// explain view was resolved against.
567#[derive(Debug, Serialize)]
568struct ExplainBlueprintRef {
569    /// Blueprint id (echoed back from the path param).
570    id: String,
571    /// Head commit version (`Trace.version`, debug-formatted — same
572    /// convention as [`HeadResponse::version`]).
573    version: String,
574}
575
576/// `agent` field of [`ExplainAgentResponse`]: the resolved agent's
577/// identity, verbatim from the Blueprint's `AgentDef`.
578#[derive(Debug, Serialize)]
579struct ExplainAgentRef {
580    /// Agent name (= `AgentDef.name`, echoed back from the path param).
581    name: String,
582    /// Worker IMPL kind (= `AgentDef.kind`).
583    kind: AgentKind,
584}
585
586/// `worker_binding` field of [`ExplainAgentResponse`] when the agent
587/// declares one. Mirrors `mlua_swarm::operator::WorkerBinding::variant`;
588/// its `tools` half is reported separately under `declared_tools`, so it
589/// is not duplicated here.
590#[derive(Debug, Serialize)]
591struct ExplainWorkerBinding {
592    /// Worker variant name (`AgentDef.profile.worker_binding`).
593    variant: String,
594}
595
596/// `declared_tools` field of [`ExplainAgentResponse`].
597#[derive(Debug, Serialize)]
598struct ExplainDeclaredTools {
599    /// `AgentDef.profile.tools`, verbatim (`[]` when `profile` is absent).
600    tools: Vec<String>,
601    /// Always `true` — see [`Self::note`].
602    informational: bool,
603    /// Explains why `tools` does not grant anything by itself.
604    note: String,
605}
606
607/// `system_prompt` field of [`ExplainAgentResponse`], present when
608/// `AgentDef.profile.system_prompt` is non-empty.
609#[derive(Debug, Serialize)]
610struct ExplainSystemPrompt {
611    /// UTF-8 byte length of the raw (unrendered) template.
612    bytes: usize,
613    /// Line count of the raw template (`str::lines` count).
614    lines: usize,
615    /// Variables `mlua_swarm::operator::render::template_variables`
616    /// reports the template requires. Empty when
617    /// [`Self::template_syntax_error`] is `Some`.
618    template_variables: Vec<String>,
619    /// `Some(message)` when the template failed to parse; `None`
620    /// otherwise.
621    template_syntax_error: Option<String>,
622    /// Explains the non-`Object` `initial_directive` binding rule.
623    note: String,
624}
625
626/// One key's entry in [`ExplainEffectiveCtx::keys`].
627#[derive(Debug, Serialize)]
628struct ExplainCtxKeyEntry {
629    /// The value this key resolves to (the winning tier's value).
630    value: serde_json::Value,
631    /// Which static tier supplied [`Self::value`] — one of
632    /// `"agent_inline"` / `"meta_ref"` / `"bp_global"`.
633    winning_tier: String,
634}
635
636/// `effective_ctx` field of [`ExplainAgentResponse`]: the static 3-tier
637/// cascade resolution `mlua_swarm::core::explain::explain_agent_ctx`
638/// computes (byte-identical to the runtime merge — see that function's
639/// doc for why this reuses rather than reimplements the merge).
640#[derive(Debug, Serialize)]
641struct ExplainEffectiveCtx {
642    /// Per-key winner table.
643    keys: BTreeMap<String, ExplainCtxKeyEntry>,
644    /// Explains that Run/Task/Step runtime tiers are out of scope here.
645    note: String,
646}
647
648/// `output` field of [`ExplainAgentResponse`].
649#[derive(Debug, Serialize)]
650struct ExplainOutput {
651    /// The canonical step-projection name
652    /// (`StepNaming::canonical_of_producer`), or the agent name itself as
653    /// a fallback — see [`Self::naming_warnings`].
654    projection_name: String,
655    /// Non-empty when [`Self::projection_name`] fell back to the agent
656    /// name, or `StepNaming::from_blueprint` itself failed (explain is a
657    /// diagnostic view, so neither case 500s — see [`explain_agent`]'s
658    /// doc).
659    naming_warnings: Vec<String>,
660    /// Explains the `{"out","parts"}` OUTPUT shape change for parts
661    /// staging.
662    parts_note: String,
663}
664
665/// `runner` field of [`ExplainAgentResponse`] (GH #46 Milestone 2) — the
666/// Runner-tier doctor diagnostics for this agent. Read-only and purely
667/// observational: nothing here gates compilation or dispatch (Milestone 3
668/// wires the resolved Runner into the launch path; this endpoint stays a
669/// diagnostic view), the same "surface it, never block"
670/// BLOCK-disabled-by-default convention `bp_doctor`'s agent-md size check
671/// already follows.
672#[derive(Debug, Serialize)]
673struct ExplainRunner {
674    /// The Runner this agent resolves to via `resolve_runner`'s 5-tier
675    /// cascade, when resolution succeeds. `None` when no tier declares a
676    /// Runner (byte-compat: an agent with no `runner` / `runner_ref` /
677    /// `profile.worker_binding` / `Blueprint.default_runner` resolves to
678    /// `None` here, mirroring [`ExplainAgentResponse::worker_binding`]).
679    resolved: Option<Runner>,
680    /// Error-level finding: `Some(msg)` when `resolve_runner` returned an
681    /// unresolved `runner_ref` / `default_runner` reference
682    /// (`RunnerResolveError`, rendered via its `Display`).
683    error: Option<String>,
684    /// Warn-level finding: `Some(msg)` when the resolved Runner's backend
685    /// disagrees with `AgentDef.kind` (`agent_block_in_process` paired
686    /// with a non-`agent_block` kind, or a WebSocket Operator backend paired
687    /// with `agent_block`). `None` when the pairing is consistent, or when
688    /// [`Self::resolved`] is `None`.
689    warning: Option<String>,
690    /// Declaration tier selected by the immutable binding resolver.
691    source: Option<RunnerResolutionSource>,
692    /// Run/replay correlation digest over Agent, Runner, and Context policy.
693    binding_digest: Option<BindingDigest>,
694}
695
696/// GH #46 M2 doctor check: does the resolved Runner's backend agree with
697/// `AgentDef.kind` about which backend actually executes this agent? Pure
698/// and read-only, never gates compile / dispatch (see [`ExplainRunner`]'s
699/// doc).
700fn runner_kind_mismatch_warning(
701    runner: &Runner,
702    kind: &AgentKind,
703    agent_name: &str,
704) -> Option<String> {
705    match (runner, kind) {
706        (Runner::AgentBlockInProcess { .. }, AgentKind::AgentBlock) => None,
707        (Runner::AgentBlockInProcess { .. }, other) => Some(format!(
708            "agent '{agent_name}' resolves to Runner::AgentBlockInProcess but AgentDef.kind = \
709             {other:?} (expected AgentBlock)"
710        )),
711        (Runner::WsOperator { .. }, AgentKind::AgentBlock) => Some(format!(
712            "agent '{agent_name}' resolves to Runner::WsOperator but AgentDef.kind = AgentBlock"
713        )),
714        (Runner::WsOperator { .. }, _) => None,
715        (Runner::WsClaudeCode { .. }, AgentKind::AgentBlock) => Some(format!(
716            "agent '{agent_name}' resolves to Runner::WsClaudeCode but AgentDef.kind = AgentBlock"
717        )),
718        (Runner::WsClaudeCode { .. }, _) => None,
719        // GH #83: Runner::Subprocess pairs 1:1 with AgentKind::Subprocess.
720        (Runner::Subprocess { .. }, AgentKind::Subprocess) => None,
721        (Runner::Subprocess { .. }, other) => Some(format!(
722            "agent '{agent_name}' resolves to Runner::Subprocess but AgentDef.kind = {other:?} \
723             (expected Subprocess)"
724        )),
725    }
726}
727
728/// Response body for `GET /v1/blueprints/:id/agents/:agent/explain`.
729#[derive(Debug, Serialize)]
730struct ExplainAgentResponse {
731    /// Which Blueprint this view was resolved against.
732    blueprint: ExplainBlueprintRef,
733    /// The resolved agent's identity.
734    agent: ExplainAgentRef,
735    /// The Blueprint-baked worker binding, if declared.
736    worker_binding: Option<ExplainWorkerBinding>,
737    /// `Some(reason)` when [`Self::worker_binding`] is `None`.
738    binding_note: Option<String>,
739    /// GH #46 M2 — Runner-tier doctor diagnostics (see [`ExplainRunner`]).
740    runner: ExplainRunner,
741    /// The agent's declared (informational-only) tool list.
742    declared_tools: ExplainDeclaredTools,
743    /// The rendered-template diagnostics, when `profile.system_prompt` is
744    /// non-empty.
745    system_prompt: Option<ExplainSystemPrompt>,
746    /// The static ctx cascade resolution.
747    effective_ctx: ExplainEffectiveCtx,
748    /// The step-projection naming resolution.
749    output: ExplainOutput,
750}
751
752/// Maps a static [`CtxTier`] to the wire label
753/// [`ExplainCtxKeyEntry::winning_tier`] reports.
754fn ctx_tier_label(tier: CtxTier) -> &'static str {
755    match tier {
756        CtxTier::AgentInline => "agent_inline",
757        CtxTier::MetaRef => "meta_ref",
758        CtxTier::BpGlobal => "bp_global",
759    }
760}
761
762/// Builds [`ExplainSystemPrompt`] from a non-empty `profile.system_prompt`
763/// template.
764fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
765    let (variables, template_syntax_error): (Vec<String>, Option<String>) =
766        match template_variables(template) {
767            Ok(vars) => (vars.into_iter().collect(), None),
768            Err(e) => (Vec::new(), Some(e.to_string())),
769        };
770    ExplainSystemPrompt {
771        bytes: template.len(),
772        lines: template.lines().count(),
773        template_variables: variables,
774        template_syntax_error,
775        note: "when the step directive is not a JSON object, only `value` is bound at render \
776               time"
777            .to_string(),
778    }
779}
780
781/// `GET /v1/blueprints/:id/agents/:agent/explain` — read-only, dry-run
782/// visualization of how `agent`'s Blueprint definition materializes into
783/// its runtime worker contract (see `workspace/tasks/explain-agent/issue.md`
784/// for the full design rationale). Same unauthenticated trust tier as
785/// [`get_head`] (an operator-diagnostic route; no engine state is touched
786/// — every value here is resolved statically from the head Blueprint
787/// alone).
788///
789/// 404s when the Blueprint id itself is not found (same error mapping as
790/// [`get_head`]) or when `agent` is not a name in `bp.agents` (JSON body:
791/// `{"error", "agent", "available"}`). A `StepNaming::from_blueprint`
792/// failure does not 500 — `output.projection_name` falls back to the
793/// agent name and the failure is reported via `output.naming_warnings`
794/// (this endpoint is a diagnostic view, not a compile gate).
795async fn explain_agent(
796    State(state): State<BlueprintsState>,
797    Path((id, agent)): Path<(String, String)>,
798) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
799    let store = state.store;
800    let bp_id = BlueprintId::new(id.clone());
801    let traced = store
802        .read_head(&bp_id)
803        .await
804        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
805    let bp = traced.value;
806    let version = format!("{:?}", traced.trace.version);
807
808    let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
809        let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
810        return Err((
811            StatusCode::NOT_FOUND,
812            serde_json::json!({
813                "error": "agent not found in blueprint",
814                "agent": agent,
815                "available": available,
816            })
817            .to_string(),
818        ));
819    };
820
821    let profile = agent_def.profile.as_ref();
822
823    let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
824        Some(variant) => (
825            Some(ExplainWorkerBinding {
826                variant: variant.clone(),
827            }),
828            None,
829        ),
830        None => (
831            None,
832            Some(
833                "no worker_binding declared; WS operator dispatch will fail at compile \
834                 (InvalidSpec)"
835                    .to_string(),
836            ),
837        ),
838    };
839
840    let declared_tools = ExplainDeclaredTools {
841        tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
842        informational: true,
843        note: "declared tools do not grant anything; the effective tool surface is the worker \
844               wrapper's frontmatter (see operator.rs WorkerBinding doc)"
845            .to_string(),
846    };
847
848    // GH #46 M2 doctor checks: unresolved runner_ref / default_runner is
849    // an error-level finding; a resolved-but-mismatched backend/kind pair
850    // is a warn-level finding. Both are purely observational (see
851    // `ExplainRunner`'s doc) — this never gates compile / dispatch.
852    let bound = resolve_bound_agents(&bp)
853        .ok()
854        .and_then(|all| all.into_iter().find(|b| b.agent.name == agent_def.name));
855    let runner = match resolve_runner(&bp, agent_def) {
856        Ok(resolved) => {
857            let warning = resolved
858                .as_ref()
859                .and_then(|r| runner_kind_mismatch_warning(r, &agent_def.kind, &agent_def.name));
860            ExplainRunner {
861                resolved,
862                error: None,
863                warning,
864                source: bound.as_ref().map(|b| b.runner_source),
865                binding_digest: bound.as_ref().map(|b| b.binding_digest.clone()),
866            }
867        }
868        Err(e) => ExplainRunner {
869            resolved: None,
870            error: Some(e.to_string()),
871            warning: None,
872            source: None,
873            binding_digest: None,
874        },
875    };
876
877    let system_prompt = profile
878        .filter(|p| !p.system_prompt.is_empty())
879        .map(|p| explain_system_prompt(&p.system_prompt));
880
881    let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
882    let effective_ctx = ExplainEffectiveCtx {
883        keys: ctx_keys
884            .into_iter()
885            .map(|(k, resolution)| {
886                (
887                    k,
888                    ExplainCtxKeyEntry {
889                        value: resolution.value,
890                        winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
891                    },
892                )
893            })
894            .collect(),
895        note: "static tiers only; Run/Task/Step runtime tiers always win over these \
896               (only-if-absent insertion order)"
897            .to_string(),
898    };
899
900    let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
901        Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
902            Some(canonical) => (canonical.to_string(), Vec::new()),
903            None => (
904                agent.clone(),
905                vec![format!(
906                    "agent '{agent}' does not appear in the blueprint's flow; using the agent \
907                     name as a fallback projection name"
908                )],
909            ),
910        },
911        Err(e) => (
912            agent.clone(),
913            vec![format!("StepNaming::from_blueprint failed: {e}")],
914        ),
915    };
916
917    let output = ExplainOutput {
918        projection_name,
919        naming_warnings,
920        parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
921                     to {\"out\", \"parts\"}; reference via $.<step>.out"
922            .to_string(),
923    };
924
925    Ok(Json(ExplainAgentResponse {
926        blueprint: ExplainBlueprintRef { id, version },
927        agent: ExplainAgentRef {
928            name: agent_def.name.clone(),
929            kind: agent_def.kind.clone(),
930        },
931        worker_binding,
932        binding_note,
933        runner,
934        declared_tools,
935        system_prompt,
936        effective_ctx,
937        output,
938    }))
939}
940
941// ──────────────────────────────────────────────────────────────────────────
942// GET /v1/blueprints/:id/agents/explain (batch summary)
943// ──────────────────────────────────────────────────────────────────────────
944
945/// `worker_binding` field of [`AgentSummary`] — same shape as
946/// [`ExplainWorkerBinding`] (kept as a distinct type so the batch response
947/// schema doesn't couple to the single-agent view's naming).
948#[derive(Debug, Serialize)]
949struct WorkerBindingSummary {
950    /// Worker variant name (`AgentDef.profile.worker_binding`).
951    variant: String,
952}
953
954/// One row of [`BatchExplainAgentsResponse::agents`] — a summary, not the
955/// full [`ExplainAgentResponse`] detail: a whole-Blueprint sweep response
956/// must stay small, so this reports counts/presence rather than the raw
957/// `declared_tools` list or the rendered `system_prompt` template. Drill
958/// down via `GET /v1/blueprints/:id/agents/:agent/explain` for the full
959/// per-agent view.
960#[derive(Debug, Serialize)]
961struct AgentSummary {
962    /// Agent name (`AgentDef.name`).
963    name: String,
964    /// Worker IMPL kind (`AgentDef.kind`, debug-formatted — same
965    /// convention as the `bp_doctor` MCP tool's per-agent `kind` field).
966    kind: String,
967    /// The Blueprint-baked worker binding, if declared. `null` (not
968    /// omitted) when absent — the caller needs to see every agent,
969    /// bound or not.
970    worker_binding: Option<WorkerBindingSummary>,
971    /// `AgentDef.profile.tools.len()`; `0` when `profile` is absent.
972    declared_tools_count: usize,
973    /// UTF-8 byte length of `profile.system_prompt`; `0` when `profile`
974    /// is absent or the template is empty.
975    system_prompt_bytes: usize,
976    /// Number of keys `explain_agent_ctx` resolves for this agent (the
977    /// static 3-tier cascade); `0` when the agent has no static ctx.
978    effective_ctx_key_count: usize,
979    /// The canonical step-projection name
980    /// (`StepNaming::canonical_of_producer`), falling back to the agent
981    /// name on a naming miss — same fail-soft convention as
982    /// [`ExplainOutput::projection_name`], but without a
983    /// `naming_warnings` companion (this is a summary row).
984    projection_name: String,
985}
986
987/// Response body for `GET /v1/blueprints/:id/agents/explain`.
988#[derive(Debug, Serialize)]
989struct BatchExplainAgentsResponse {
990    /// Which Blueprint this sweep was resolved against.
991    blueprint: ExplainBlueprintRef,
992    /// One row per `bp.agents` entry, in Blueprint order.
993    agents: Vec<AgentSummary>,
994}
995
996/// `GET /v1/blueprints/:id/agents/explain` — batch summary sweep across
997/// every agent in the Blueprint. Same read-only, dry-run, unauthenticated
998/// trust tier as [`explain_agent`] / [`get_head`] — nothing here is
999/// resolved beyond the head Blueprint.
1000///
1001/// 404s only when the Blueprint id itself is not found (same error
1002/// mapping as [`get_head`]); a Blueprint with zero agents returns
1003/// `agents: []`, not 404 (there is no per-agent path segment to fail to
1004/// resolve here). `StepNaming::from_blueprint` failing does not 500 —
1005/// every row's `projection_name` falls back to the agent name, mirroring
1006/// [`explain_agent`]'s per-agent fail-soft convention (this batch view
1007/// just has no `naming_warnings` companion field to report it through).
1008async fn explain_agents_batch(
1009    State(state): State<BlueprintsState>,
1010    Path(id): Path<String>,
1011) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
1012    let store = state.store;
1013    let bp_id = BlueprintId::new(id.clone());
1014    let traced = store
1015        .read_head(&bp_id)
1016        .await
1017        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
1018    let bp = traced.value;
1019    let version = format!("{:?}", traced.trace.version);
1020
1021    // Resolved once for the whole Blueprint (StepNaming::from_blueprint is
1022    // a whole-BP operation, not per-agent); a failure fails soft the same
1023    // way explain_agent's per-agent lookup does — every row below falls
1024    // back to the agent name via `.unwrap_or_else`.
1025    let naming = StepNaming::from_blueprint(&bp)
1026        .ok()
1027        .map(|(naming, _)| naming);
1028
1029    let agents = bp
1030        .agents
1031        .iter()
1032        .map(|agent_def| {
1033            let profile = agent_def.profile.as_ref();
1034            let worker_binding = profile
1035                .and_then(|p| p.worker_binding.as_ref())
1036                .map(|variant| WorkerBindingSummary {
1037                    variant: variant.clone(),
1038                });
1039            let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
1040            let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
1041            let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
1042                .map(|keys| keys.len())
1043                .unwrap_or(0);
1044            let projection_name = naming
1045                .as_ref()
1046                .and_then(|naming| naming.canonical_of_producer(&agent_def.name))
1047                .map(|canonical| canonical.to_string())
1048                .unwrap_or_else(|| agent_def.name.clone());
1049            AgentSummary {
1050                name: agent_def.name.clone(),
1051                kind: format!("{:?}", agent_def.kind),
1052                worker_binding,
1053                declared_tools_count,
1054                system_prompt_bytes,
1055                effective_ctx_key_count,
1056                projection_name,
1057            }
1058        })
1059        .collect();
1060
1061    Ok(Json(BatchExplainAgentsResponse {
1062        blueprint: ExplainBlueprintRef { id, version },
1063        agents,
1064    }))
1065}
1066
1067#[cfg(test)]
1068mod explain_agent_tests {
1069    use super::*;
1070    use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
1071    use mlua_swarm::blueprint::{
1072        current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
1073        CompilerHints, CompilerStrategy,
1074    };
1075    use serde_json::json;
1076
1077    fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
1078        AgentDef {
1079            name: name.to_string(),
1080            kind: AgentKind::RustFn,
1081            spec: json!({ "fn_id": name }),
1082            profile,
1083            meta,
1084            runner: None,
1085            runner_ref: None,
1086            verdict: None,
1087        }
1088    }
1089
1090    /// A single-step Blueprint whose sole Step dispatches `agent_name` —
1091    /// enough for `StepNaming::from_blueprint` to resolve a real (non-
1092    /// fallback) `canonical_of_producer` entry.
1093    fn single_step_bp(
1094        bp_id: &str,
1095        agent_name: &str,
1096        profile: Option<AgentProfile>,
1097        meta: Option<AgentMeta>,
1098        default_agent_ctx: Option<serde_json::Value>,
1099    ) -> Blueprint {
1100        Blueprint {
1101            schema_version: current_schema_version(),
1102            id: bp_id.into(),
1103            flow: serde_json::from_value(json!({
1104                "kind": "step",
1105                "ref": agent_name,
1106                "in": {"op": "path", "at": "$.input"},
1107                "out": {"op": "path", "at": "$.out"},
1108            }))
1109            .expect("flow parse"),
1110            agents: vec![agent_def(agent_name, profile, meta)],
1111            operators: vec![],
1112            metas: vec![],
1113            hints: CompilerHints::default(),
1114            strategy: CompilerStrategy::default(),
1115            metadata: BlueprintMetadata::default(),
1116            spawner_hints: Default::default(),
1117            default_agent_kind: AgentKind::Operator,
1118            default_operator_kind: None,
1119            default_init_ctx: None,
1120            default_agent_ctx,
1121            default_context_policy: None,
1122            projection_placement: None,
1123            audits: vec![],
1124            degradation_policy: None,
1125            runners: vec![],
1126            default_runner: None,
1127            subprocesses: vec![],
1128            check_policy: None,
1129            blueprint_ref_includes: Vec::new(),
1130        }
1131    }
1132
1133    async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
1134        let bp_id = BlueprintId::new(bp.id.as_str());
1135        let v = blueprint_version(bp).expect("version");
1136        store
1137            .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
1138            .await
1139            .expect("write_new");
1140    }
1141
1142    fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
1143        BlueprintsState {
1144            store: Arc::new(store),
1145            ref_base: None,
1146            ref_includes: Vec::new(),
1147            cli_default_agent_kind: None,
1148            strict_embed: false,
1149            legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
1150        }
1151    }
1152
1153    #[tokio::test]
1154    async fn full_case_reports_binding_ctx_override_and_system_prompt() {
1155        let profile = AgentProfile {
1156            system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
1157            tools: vec!["Read".to_string(), "Grep".to_string()],
1158            worker_binding: Some("mse-worker-knowledge".to_string()),
1159            ..Default::default()
1160        };
1161        let meta = AgentMeta {
1162            ctx: Some(json!({ "work_dir": "/inline" })),
1163            ..Default::default()
1164        };
1165        let bp = single_step_bp(
1166            "explain-full-bp",
1167            "researcher",
1168            Some(profile),
1169            Some(meta),
1170            Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1171        );
1172        let store = InMemoryBlueprintStore::new();
1173        seed(&store, &bp).await;
1174
1175        let resp = explain_agent(
1176            State(state_with(store)),
1177            Path(("explain-full-bp".to_string(), "researcher".to_string())),
1178        )
1179        .await
1180        .expect("explain_agent")
1181        .0;
1182
1183        assert_eq!(resp.blueprint.id, "explain-full-bp");
1184        assert!(!resp.blueprint.version.is_empty());
1185        assert_eq!(resp.agent.name, "researcher");
1186        assert_eq!(resp.agent.kind, AgentKind::RustFn);
1187
1188        let binding = resp.worker_binding.expect("worker_binding present");
1189        assert_eq!(binding.variant, "mse-worker-knowledge");
1190        assert!(resp.binding_note.is_none());
1191
1192        assert_eq!(
1193            resp.declared_tools.tools,
1194            vec!["Read".to_string(), "Grep".to_string()]
1195        );
1196        assert!(resp.declared_tools.informational);
1197
1198        let sp = resp.system_prompt.expect("system_prompt present");
1199        assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
1200        assert_eq!(sp.lines, 1);
1201        assert_eq!(
1202            sp.template_variables,
1203            vec!["mode".to_string(), "name".to_string()]
1204        );
1205        assert!(sp.template_syntax_error.is_none());
1206
1207        assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
1208        assert_eq!(
1209            resp.effective_ctx.keys["work_dir"].winning_tier,
1210            "agent_inline"
1211        );
1212        assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
1213        assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
1214
1215        assert_eq!(resp.output.projection_name, "researcher");
1216        assert!(resp.output.naming_warnings.is_empty());
1217    }
1218
1219    #[tokio::test]
1220    async fn agent_without_worker_binding_reports_binding_note() {
1221        let profile = AgentProfile {
1222            tools: vec!["Read".to_string()],
1223            ..Default::default()
1224        };
1225        let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
1226        let store = InMemoryBlueprintStore::new();
1227        seed(&store, &bp).await;
1228
1229        let resp = explain_agent(
1230            State(state_with(store)),
1231            Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
1232        )
1233        .await
1234        .expect("explain_agent")
1235        .0;
1236
1237        assert!(resp.worker_binding.is_none());
1238        let note = resp.binding_note.expect("binding_note present");
1239        assert!(note.contains("no worker_binding declared"));
1240        assert!(resp.system_prompt.is_none());
1241    }
1242
1243    #[tokio::test]
1244    async fn unknown_agent_name_returns_404_with_available_list() {
1245        let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1246        let store = InMemoryBlueprintStore::new();
1247        seed(&store, &bp).await;
1248
1249        let err = explain_agent(
1250            State(state_with(store)),
1251            Path((
1252                "explain-404-agent-bp".to_string(),
1253                "no-such-agent".to_string(),
1254            )),
1255        )
1256        .await
1257        .expect_err("expected 404");
1258
1259        assert_eq!(err.0, StatusCode::NOT_FOUND);
1260        let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1261        assert_eq!(body["error"], "agent not found in blueprint");
1262        assert_eq!(body["agent"], "no-such-agent");
1263        assert_eq!(body["available"], json!(["foo"]));
1264    }
1265
1266    #[tokio::test]
1267    async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1268        let store = InMemoryBlueprintStore::new();
1269
1270        let err = explain_agent(
1271            State(state_with(store)),
1272            Path(("no-such-bp".to_string(), "any-agent".to_string())),
1273        )
1274        .await
1275        .expect_err("expected 404");
1276
1277        assert_eq!(err.0, StatusCode::NOT_FOUND);
1278    }
1279
1280    #[tokio::test]
1281    async fn template_syntax_error_is_reported_without_500() {
1282        let profile = AgentProfile {
1283            system_prompt: "hello {{ unclosed".to_string(),
1284            ..Default::default()
1285        };
1286        let bp = single_step_bp(
1287            "explain-syntax-error-bp",
1288            "scout",
1289            Some(profile),
1290            None,
1291            None,
1292        );
1293        let store = InMemoryBlueprintStore::new();
1294        seed(&store, &bp).await;
1295
1296        let resp = explain_agent(
1297            State(state_with(store)),
1298            Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1299        )
1300        .await
1301        .expect("explain_agent")
1302        .0;
1303
1304        let sp = resp.system_prompt.expect("system_prompt present");
1305        assert!(sp.template_variables.is_empty());
1306        assert!(sp.template_syntax_error.is_some());
1307    }
1308
1309    // ─── GH #46 M2: `runner` doctor checks (unknown ref error / backend↔kind mismatch warn) ───
1310
1311    #[tokio::test]
1312    async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1313        let profile = AgentProfile {
1314            worker_binding: Some("mse-worker-knowledge".to_string()),
1315            tools: vec!["Read".to_string()],
1316            ..Default::default()
1317        };
1318        let bp = single_step_bp(
1319            "explain-runner-legacy-bp",
1320            "scout",
1321            Some(profile),
1322            None,
1323            None,
1324        );
1325        let store = InMemoryBlueprintStore::new();
1326        seed(&store, &bp).await;
1327
1328        let resp = explain_agent(
1329            State(state_with(store)),
1330            Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1331        )
1332        .await
1333        .expect("explain_agent")
1334        .0;
1335
1336        assert_eq!(
1337            resp.runner.resolved,
1338            Some(mlua_swarm_schema::Runner::WsClaudeCode {
1339                variant: "mse-worker-knowledge".to_string(),
1340                tools: vec!["Read".to_string()],
1341            })
1342        );
1343        assert!(resp.runner.error.is_none());
1344        assert!(resp.runner.warning.is_none());
1345    }
1346
1347    #[tokio::test]
1348    async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1349        let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1350        bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1351        let store = InMemoryBlueprintStore::new();
1352        seed(&store, &bp).await;
1353
1354        let resp = explain_agent(
1355            State(state_with(store)),
1356            Path((
1357                "explain-runner-unresolved-bp".to_string(),
1358                "scout".to_string(),
1359            )),
1360        )
1361        .await
1362        .expect("explain_agent")
1363        .0;
1364
1365        assert!(resp.runner.resolved.is_none());
1366        let error = resp.runner.error.expect("error-level finding present");
1367        assert!(
1368            error.contains("no-such-entry"),
1369            "error must name the unresolved runner_ref: {error}"
1370        );
1371        assert!(resp.runner.warning.is_none());
1372    }
1373
1374    #[tokio::test]
1375    async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1376        // `AgentDef.kind = RustFn` (via `single_step_bp`'s `agent_def` helper)
1377        // paired with an `agent_block_in_process` Runner is the documented
1378        // mismatch (Design §6: "backend ↔ kind mismatch").
1379        let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1380        bp.runners = vec![mlua_swarm_schema::RunnerDef {
1381            name: "in-process".to_string(),
1382            runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1383                tools: vec!["Bash".to_string()],
1384            },
1385        }];
1386        bp.agents[0].runner_ref = Some("in-process".to_string());
1387        let store = InMemoryBlueprintStore::new();
1388        seed(&store, &bp).await;
1389
1390        let resp = explain_agent(
1391            State(state_with(store)),
1392            Path((
1393                "explain-runner-mismatch-bp".to_string(),
1394                "scout".to_string(),
1395            )),
1396        )
1397        .await
1398        .expect("explain_agent")
1399        .0;
1400
1401        assert!(resp.runner.resolved.is_some());
1402        assert!(resp.runner.error.is_none());
1403        let warning = resp.runner.warning.expect("warn-level finding present");
1404        assert!(
1405            warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1406            "warning must name both the resolved backend and the mismatched kind: {warning}"
1407        );
1408    }
1409
1410    // ─── GH #47: batch summary sweep (explain_agents_batch) ────────────
1411
1412    /// A 3-agent Blueprint whose flow only dispatches `bound_agent` — the
1413    /// other two are unreferenced by the flow, so `StepNaming` misses them
1414    /// (fail-soft fallback to the agent name is exercised for both).
1415    fn batch_bp() -> Blueprint {
1416        let bound_profile = AgentProfile {
1417            system_prompt: "hello world".to_string(),
1418            tools: vec!["Read".to_string(), "Grep".to_string()],
1419            worker_binding: Some("mse-worker-knowledge".to_string()),
1420            ..Default::default()
1421        };
1422        let bound_meta = AgentMeta {
1423            ctx: Some(json!({ "work_dir": "/inline" })),
1424            ..Default::default()
1425        };
1426        Blueprint {
1427            schema_version: current_schema_version(),
1428            id: "explain-batch-bp".into(),
1429            flow: serde_json::from_value(json!({
1430                "kind": "step",
1431                "ref": "bound_agent",
1432                "in": {"op": "path", "at": "$.input"},
1433                "out": {"op": "path", "at": "$.out"},
1434            }))
1435            .expect("flow parse"),
1436            agents: vec![
1437                agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1438                agent_def("unbound_agent", None, None),
1439                agent_def("orphan_agent", None, None),
1440            ],
1441            operators: vec![],
1442            metas: vec![],
1443            hints: CompilerHints::default(),
1444            strategy: CompilerStrategy::default(),
1445            metadata: BlueprintMetadata::default(),
1446            spawner_hints: Default::default(),
1447            default_agent_kind: AgentKind::Operator,
1448            default_operator_kind: None,
1449            default_init_ctx: None,
1450            default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1451            default_context_policy: None,
1452            projection_placement: None,
1453            audits: vec![],
1454            degradation_policy: None,
1455            runners: vec![],
1456            default_runner: None,
1457            subprocesses: vec![],
1458            check_policy: None,
1459            blueprint_ref_includes: Vec::new(),
1460        }
1461    }
1462
1463    #[tokio::test]
1464    async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1465        let bp = batch_bp();
1466        let store = InMemoryBlueprintStore::new();
1467        seed(&store, &bp).await;
1468
1469        let resp = explain_agents_batch(
1470            State(state_with(store)),
1471            Path("explain-batch-bp".to_string()),
1472        )
1473        .await
1474        .expect("explain_agents_batch")
1475        .0;
1476
1477        assert_eq!(resp.blueprint.id, "explain-batch-bp");
1478        assert!(!resp.blueprint.version.is_empty());
1479        assert_eq!(resp.agents.len(), 3);
1480
1481        let bound = resp
1482            .agents
1483            .iter()
1484            .find(|a| a.name == "bound_agent")
1485            .expect("bound_agent row");
1486        assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1487        let binding = bound
1488            .worker_binding
1489            .as_ref()
1490            .expect("worker_binding present");
1491        assert_eq!(binding.variant, "mse-worker-knowledge");
1492        assert_eq!(bound.declared_tools_count, 2);
1493        assert_eq!(bound.system_prompt_bytes, "hello world".len());
1494        // work_dir (agent_inline override) + extra (bp-global carry) = 2 keys.
1495        assert_eq!(bound.effective_ctx_key_count, 2);
1496        // Referenced by the flow -> a real (non-fallback) canonical name.
1497        assert_eq!(bound.projection_name, "bound_agent");
1498
1499        let unbound = resp
1500            .agents
1501            .iter()
1502            .find(|a| a.name == "unbound_agent")
1503            .expect("unbound_agent row");
1504        assert!(unbound.worker_binding.is_none());
1505        assert_eq!(unbound.declared_tools_count, 0);
1506        assert_eq!(unbound.system_prompt_bytes, 0);
1507        // Only the bp-global tier applies (no agent-level meta) = 2 keys.
1508        assert_eq!(unbound.effective_ctx_key_count, 2);
1509        // Not referenced by the flow -> StepNaming miss -> fallback to name.
1510        assert_eq!(unbound.projection_name, "unbound_agent");
1511
1512        let orphan = resp
1513            .agents
1514            .iter()
1515            .find(|a| a.name == "orphan_agent")
1516            .expect("orphan_agent row");
1517        assert_eq!(orphan.projection_name, "orphan_agent");
1518    }
1519
1520    #[tokio::test]
1521    async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1522        let bp = Blueprint {
1523            schema_version: current_schema_version(),
1524            id: "explain-batch-empty-bp".into(),
1525            flow: serde_json::from_value(json!({
1526                "kind": "step",
1527                "ref": "unused",
1528                "in": {"op": "path", "at": "$.input"},
1529                "out": {"op": "path", "at": "$.out"},
1530            }))
1531            .expect("flow parse"),
1532            agents: vec![],
1533            operators: vec![],
1534            metas: vec![],
1535            hints: CompilerHints::default(),
1536            strategy: CompilerStrategy::default(),
1537            metadata: BlueprintMetadata::default(),
1538            spawner_hints: Default::default(),
1539            default_agent_kind: AgentKind::Operator,
1540            default_operator_kind: None,
1541            default_init_ctx: None,
1542            default_agent_ctx: None,
1543            default_context_policy: None,
1544            projection_placement: None,
1545            audits: vec![],
1546            degradation_policy: None,
1547            runners: vec![],
1548            default_runner: None,
1549            subprocesses: vec![],
1550            check_policy: None,
1551            blueprint_ref_includes: Vec::new(),
1552        };
1553        let store = InMemoryBlueprintStore::new();
1554        seed(&store, &bp).await;
1555
1556        let resp = explain_agents_batch(
1557            State(state_with(store)),
1558            Path("explain-batch-empty-bp".to_string()),
1559        )
1560        .await
1561        .expect("explain_agents_batch")
1562        .0;
1563
1564        assert!(resp.agents.is_empty());
1565    }
1566
1567    #[tokio::test]
1568    async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1569        let store = InMemoryBlueprintStore::new();
1570
1571        let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1572            .await
1573            .expect_err("expected 404");
1574
1575        assert_eq!(err.0, StatusCode::NOT_FOUND);
1576    }
1577
1578    // ─── C3: GET /v1/blueprints/:id/binding-requirements ───────────────
1579
1580    fn state_with_policy(
1581        store: InMemoryBlueprintStore,
1582        legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
1583    ) -> BlueprintsState {
1584        BlueprintsState {
1585            store: Arc::new(store),
1586            ref_base: None,
1587            ref_includes: Vec::new(),
1588            cli_default_agent_kind: None,
1589            strict_embed: false,
1590            legacy_worker_binding_policy,
1591        }
1592    }
1593
1594    /// A Runner-backed agent: a legacy `profile.worker_binding` resolves to a
1595    /// `WsClaudeCode` Runner (see `runner_resolves_from_legacy_worker_binding`),
1596    /// so `binding_requests` reconstructs a request carrying its
1597    /// variant / tools / model.
1598    fn runner_agent(name: &str, variant: &str, tools: &[&str], model: &str) -> AgentDef {
1599        let profile = AgentProfile {
1600            worker_binding: Some(variant.to_string()),
1601            tools: tools.iter().map(|t| t.to_string()).collect(),
1602            model: Some(model.to_string()),
1603            ..Default::default()
1604        };
1605        agent_def(name, Some(profile), None)
1606    }
1607
1608    fn bp_with_agents(bp_id: &str, agents: Vec<AgentDef>, strict_binding: bool) -> Blueprint {
1609        let first = agents
1610            .first()
1611            .map(|a| a.name.clone())
1612            .unwrap_or_else(|| "unused".to_string());
1613        Blueprint {
1614            schema_version: current_schema_version(),
1615            id: bp_id.into(),
1616            flow: serde_json::from_value(json!({
1617                "kind": "step",
1618                "ref": first,
1619                "in": {"op": "path", "at": "$.input"},
1620                "out": {"op": "path", "at": "$.out"},
1621            }))
1622            .expect("flow parse"),
1623            agents,
1624            operators: vec![],
1625            metas: vec![],
1626            hints: CompilerHints::default(),
1627            strategy: CompilerStrategy {
1628                strict_binding,
1629                ..Default::default()
1630            },
1631            metadata: BlueprintMetadata::default(),
1632            spawner_hints: Default::default(),
1633            default_agent_kind: AgentKind::Operator,
1634            default_operator_kind: None,
1635            default_init_ctx: None,
1636            default_agent_ctx: None,
1637            default_context_policy: None,
1638            projection_placement: None,
1639            audits: vec![],
1640            degradation_policy: None,
1641            runners: vec![],
1642            default_runner: None,
1643            subprocesses: vec![],
1644            check_policy: None,
1645            blueprint_ref_includes: Vec::new(),
1646        }
1647    }
1648
1649    #[tokio::test]
1650    async fn binding_requirements_lists_one_request_per_runner_backed_agent() {
1651        let bp = bp_with_agents(
1652            "binding-reqs-two-runners-bp",
1653            vec![
1654                runner_agent(
1655                    "worker",
1656                    "mse-worker-knowledge",
1657                    &["Read", "Grep"],
1658                    "sonnet",
1659                ),
1660                runner_agent("reader", "mse-worker-reader", &["Read"], "haiku"),
1661            ],
1662            true,
1663        );
1664        let store = InMemoryBlueprintStore::new();
1665        seed(&store, &bp).await;
1666
1667        let resp = binding_requirements(
1668            State(state_with(store)),
1669            Path("binding-reqs-two-runners-bp".to_string()),
1670        )
1671        .await
1672        .expect("binding_requirements")
1673        .0;
1674
1675        assert_eq!(resp.blueprint_id, "binding-reqs-two-runners-bp");
1676        // `strict_binding` is echoed verbatim from the Blueprint strategy.
1677        assert!(resp.strict_binding);
1678        assert_eq!(resp.requirements.len(), 2);
1679
1680        let worker = resp
1681            .requirements
1682            .iter()
1683            .find(|r| r.agent == "worker")
1684            .expect("worker requirement");
1685        assert_eq!(
1686            worker.backend,
1687            mlua_swarm_schema::BindingBackend::WsClaudeCode
1688        );
1689        assert_eq!(
1690            worker.launch_variant.as_deref(),
1691            Some("mse-worker-knowledge")
1692        );
1693        assert_eq!(worker.requested_tools, vec!["Grep", "Read"]);
1694        assert_eq!(worker.requested_model.as_deref(), Some("sonnet"));
1695
1696        let reader = resp
1697            .requirements
1698            .iter()
1699            .find(|r| r.agent == "reader")
1700            .expect("reader requirement");
1701        assert_eq!(
1702            reader.backend,
1703            mlua_swarm_schema::BindingBackend::WsClaudeCode
1704        );
1705        assert_eq!(reader.launch_variant.as_deref(), Some("mse-worker-reader"));
1706        assert_eq!(reader.requested_tools, vec!["Read"]);
1707        assert_eq!(reader.requested_model.as_deref(), Some("haiku"));
1708    }
1709
1710    #[tokio::test]
1711    async fn binding_requirements_empty_when_no_runner_backed_agents() {
1712        // A profile without `worker_binding` (and no runner / runner_ref)
1713        // resolves to no Runner, so `binding_requests` yields nothing.
1714        let profile = AgentProfile {
1715            tools: vec!["Read".to_string()],
1716            ..Default::default()
1717        };
1718        let bp = single_step_bp(
1719            "binding-reqs-no-runners-bp",
1720            "scout",
1721            Some(profile),
1722            None,
1723            None,
1724        );
1725        let store = InMemoryBlueprintStore::new();
1726        seed(&store, &bp).await;
1727
1728        let resp = binding_requirements(
1729            State(state_with(store)),
1730            Path("binding-reqs-no-runners-bp".to_string()),
1731        )
1732        .await
1733        .expect("binding_requirements")
1734        .0;
1735
1736        assert!(!resp.strict_binding);
1737        assert!(resp.requirements.is_empty());
1738    }
1739
1740    #[tokio::test]
1741    async fn binding_requirements_unknown_blueprint_id_returns_404() {
1742        let store = InMemoryBlueprintStore::new();
1743
1744        let err = binding_requirements(State(state_with(store)), Path("no-such-bp".to_string()))
1745            .await
1746            .expect_err("expected 404");
1747
1748        assert_eq!(err.0, StatusCode::NOT_FOUND);
1749    }
1750
1751    #[tokio::test]
1752    async fn binding_requirements_422_when_legacy_binding_rejected_by_policy() {
1753        // A legacy `profile.worker_binding` is the only Runner source; under
1754        // `Reject` policy the strict resolver refuses it, so the handler maps
1755        // the resolve error to 422 (not 500).
1756        let bp = bp_with_agents(
1757            "binding-reqs-legacy-reject-bp",
1758            vec![runner_agent(
1759                "worker",
1760                "mse-worker-knowledge",
1761                &["Read"],
1762                "sonnet",
1763            )],
1764            false,
1765        );
1766        let store = InMemoryBlueprintStore::new();
1767        seed(&store, &bp).await;
1768
1769        let err = binding_requirements(
1770            State(state_with_policy(store, LegacyWorkerBindingPolicy::Reject)),
1771            Path("binding-reqs-legacy-reject-bp".to_string()),
1772        )
1773        .await
1774        .expect_err("expected 422");
1775
1776        assert_eq!(err.0, StatusCode::UNPROCESSABLE_ENTITY);
1777        assert!(
1778            err.1.contains("resolve bound agents"),
1779            "422 body must carry the resolve error: {}",
1780            err.1
1781        );
1782    }
1783}
1784
1785// ──────────────────────────────────────────────────────────────────────
1786// Phase 6 (issue 4c4e3eb8) — `seed_blueprint`: strict_embed pre-check
1787// + include-cascade fix hint on ref-expand failure. Design table row 3.
1788// ──────────────────────────────────────────────────────────────────────
1789
1790#[cfg(test)]
1791mod seed_strict_embed_tests {
1792    use super::*;
1793    use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
1794    use serde_json::json;
1795    use std::fs;
1796    use tempfile::TempDir;
1797
1798    /// The minimal `agent.md` the `$agent_md` refs in these tests
1799    /// resolve to. Same shape the `linker.rs` unit tests use.
1800    const AGENT_MD: &str = "---\n\
1801name: writer\n\
1802description: writes\n\
1803model: sonnet\n\
1804---\n\
1805You write.\n";
1806
1807    fn write_md(dir: &std::path::Path, rel: &str, content: &str) -> PathBuf {
1808        let p = dir.join(rel);
1809        if let Some(parent) = p.parent() {
1810            fs::create_dir_all(parent).unwrap();
1811        }
1812        fs::write(&p, content).unwrap();
1813        p
1814    }
1815
1816    /// A minimal valid Blueprint JSON body suitable for
1817    /// `seed_blueprint` — `agents` list carries a single already-
1818    /// resolved `AgentDef` object. Tests that need to exercise refs
1819    /// substitute an entry manually.
1820    fn minimal_bp_body(id: &str) -> serde_json::Value {
1821        json!({
1822            "schema_version": mlua_swarm::blueprint::current_schema_version(),
1823            "id": id,
1824            "flow": { "kind": "step", "ref": "writer",
1825                      "in": {"op": "path", "at": "$.input"},
1826                      "out": {"op": "path", "at": "$.out"} },
1827            "agents": [
1828                { "name": "writer", "kind": "rust_fn", "spec": { "fn_id": "writer" } }
1829            ],
1830            "operators": [],
1831            "metas": [],
1832            "hints": {},
1833            "strategy": {},
1834            "metadata": {},
1835            "spawner_hints": {},
1836            "default_agent_kind": "operator",
1837            "default_agent_ctx": null,
1838            "audits": [],
1839            "runners": [],
1840            "blueprint_ref_includes": []
1841        })
1842    }
1843
1844    fn state_for_test(
1845        store: InMemoryBlueprintStore,
1846        ref_base: Option<PathBuf>,
1847        strict_embed: bool,
1848    ) -> BlueprintsState {
1849        BlueprintsState {
1850            store: Arc::new(store),
1851            ref_base,
1852            ref_includes: Vec::new(),
1853            cli_default_agent_kind: None,
1854            strict_embed,
1855            legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
1856        }
1857    }
1858
1859    // (a) Default (strict_embed=false) + resolvable ref → 201 pass.
1860    #[tokio::test]
1861    async fn strict_embed_off_resolves_agent_md_ref_and_seeds() {
1862        let dir = TempDir::new().unwrap();
1863        write_md(dir.path(), "agents/writer.md", AGENT_MD);
1864        let mut body = minimal_bp_body("strict-off-resolvable-bp");
1865        body["agents"] = json!([ { "$agent_md": "agents/writer.md", "kind": "rust_fn" } ]);
1866
1867        let store = InMemoryBlueprintStore::new();
1868        let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1869
1870        let (status, resp) = seed_blueprint(
1871            State(state),
1872            Path("strict-off-resolvable-bp".to_string()),
1873            Json(body),
1874        )
1875        .await
1876        .expect("seed ok");
1877        assert_eq!(status, StatusCode::CREATED);
1878        assert_eq!(resp.0["seeded"], json!(true));
1879    }
1880
1881    // (b) Default (strict_embed=false) + unresolvable ref → 400 with
1882    //     fix hint (must name include-cascade knobs).
1883    #[tokio::test]
1884    async fn strict_embed_off_unresolvable_ref_returns_400_with_include_cascade_hint() {
1885        let dir = TempDir::new().unwrap();
1886        // Do NOT write the file — force cascade miss.
1887        let mut body = minimal_bp_body("strict-off-unresolvable-bp");
1888        body["agents"] = json!([ { "$agent_md": "agents/missing.md", "kind": "rust_fn" } ]);
1889
1890        let store = InMemoryBlueprintStore::new();
1891        let state = state_for_test(store, Some(dir.path().to_path_buf()), false);
1892
1893        let err = seed_blueprint(
1894            State(state),
1895            Path("strict-off-unresolvable-bp".to_string()),
1896            Json(body),
1897        )
1898        .await
1899        .expect_err("expected 400");
1900        assert_eq!(err.0, StatusCode::BAD_REQUEST);
1901        let msg = err.1;
1902        // Underlying linker error names the searched dirs.
1903        assert!(
1904            msg.contains("cascade") && msg.contains(dir.path().to_str().unwrap()),
1905            "linker cascade error must name searched dirs: {msg}"
1906        );
1907        // Wrapper adds the include-cascade fix hint pointing at the
1908        // configurable knobs (server CLI / env / config / in-bp).
1909        assert!(msg.contains("--include"), "hint names CLI flag: {msg}");
1910        assert!(
1911            msg.contains("MSE_BLUEPRINT_INCLUDES"),
1912            "hint names env var: {msg}"
1913        );
1914        assert!(
1915            msg.contains("blueprint_ref_includes"),
1916            "hint names config-file / in-bp key: {msg}"
1917        );
1918        assert!(
1919            msg.contains("mse bp build --strict-embed"),
1920            "hint suggests client-side pre-embed as escape hatch: {msg}"
1921        );
1922    }
1923
1924    // (c) strict_embed=true + raw ref present → 400 with pre-embed hint.
1925    //     Runs even with no ref_base configured (pre-check is
1926    //     unconditional on strict_embed).
1927    #[tokio::test]
1928    async fn strict_embed_on_refuses_body_with_agent_md_ref() {
1929        let mut body = minimal_bp_body("strict-on-refs-present-bp");
1930        body["agents"] = json!([ { "$agent_md": "agents/anything.md", "kind": "rust_fn" } ]);
1931
1932        let store = InMemoryBlueprintStore::new();
1933        // ref_base=None on purpose: strict-embed rejects raw refs
1934        // whether or not the server could resolve them.
1935        let state = state_for_test(store, None, true);
1936
1937        let err = seed_blueprint(
1938            State(state),
1939            Path("strict-on-refs-present-bp".to_string()),
1940            Json(body),
1941        )
1942        .await
1943        .expect_err("expected 400");
1944        assert_eq!(err.0, StatusCode::BAD_REQUEST);
1945        let msg = err.1;
1946        assert!(
1947            msg.starts_with("strict_embed:"),
1948            "verdict tag must namespace the error: {msg}"
1949        );
1950        assert!(
1951            msg.contains("$agent_md=agents/anything.md"),
1952            "message must name every unembedded ref: {msg}"
1953        );
1954        assert!(
1955            msg.contains("mse bp build --strict-embed"),
1956            "message must point at client-side pre-embed: {msg}"
1957        );
1958    }
1959
1960    // (c-2) strict_embed=true + `$file` ref present → same reject
1961    //       (walker covers both ref kinds).
1962    #[tokio::test]
1963    async fn strict_embed_on_refuses_body_with_file_ref_deep_in_object() {
1964        let mut body = minimal_bp_body("strict-on-file-ref-bp");
1965        // Nest the `$file` ref inside a Step directive so we exercise
1966        // the recursive walker (not just the top-level path).
1967        body["flow"] = json!({
1968            "kind": "step",
1969            "ref": "writer",
1970            "in": {"op": "lit", "value": { "$file": "prompts/deep.md" } },
1971            "out": {"op": "path", "at": "$.out"}
1972        });
1973
1974        let store = InMemoryBlueprintStore::new();
1975        let state = state_for_test(store, None, true);
1976
1977        let err = seed_blueprint(
1978            State(state),
1979            Path("strict-on-file-ref-bp".to_string()),
1980            Json(body),
1981        )
1982        .await
1983        .expect_err("expected 400");
1984        assert_eq!(err.0, StatusCode::BAD_REQUEST);
1985        assert!(
1986            err.1.contains("$file=prompts/deep.md"),
1987            "walker must find nested `$file` refs: {}",
1988            err.1
1989        );
1990    }
1991
1992    // (d) strict_embed=true + fully-embedded body (no refs) → 201 pass.
1993    #[tokio::test]
1994    async fn strict_embed_on_accepts_fully_embedded_body() {
1995        let body = minimal_bp_body("strict-on-embedded-bp");
1996
1997        let store = InMemoryBlueprintStore::new();
1998        let state = state_for_test(store, None, true);
1999
2000        let (status, resp) = seed_blueprint(
2001            State(state),
2002            Path("strict-on-embedded-bp".to_string()),
2003            Json(body),
2004        )
2005        .await
2006        .expect("embedded body seeds ok");
2007        assert_eq!(status, StatusCode::CREATED);
2008        assert_eq!(resp.0["seeded"], json!(true));
2009    }
2010
2011    // Direct helper unit test — walker must return `None` on an
2012    // already-embedded value and `Some(refs)` on a body with refs.
2013    #[test]
2014    fn walker_finds_refs_in_arrays_and_nested_objects() {
2015        let embedded = json!({ "id": "x", "agents": [ { "name": "a", "kind": "rust_fn" } ] });
2016        assert!(collect_unembedded_refs(&embedded).is_none());
2017
2018        let with_refs = json!({
2019            "id": "x",
2020            "agents": [ { "$agent_md": "a.md" } ],
2021            "flow": { "in": { "value": { "$file": "p.md" } } }
2022        });
2023        let refs = collect_unembedded_refs(&with_refs).expect("some refs");
2024        assert!(refs.iter().any(|s| s == "$agent_md=a.md"));
2025        assert!(refs.iter().any(|s| s == "$file=p.md"));
2026    }
2027}