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