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