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::{expand_file_refs, 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_schema::{resolve_runner, Runner};
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::path::PathBuf;
24use std::sync::Arc;
25
26/// Router state: BP store + the base dir used to resolve `$file` / `$agent_md`
27/// refs + `default_agent_kind` from the CLI (= layer (2) of the 4-tier cascade —
28/// the CLI override layer).
29/// When `ref_base = None`, ref expansion is skipped (= seed bodies are parsed
30/// as raw JSON).
31#[derive(Clone)]
32pub struct BlueprintsState {
33    /// Backing Blueprint store (git2 or in-memory backend).
34    pub store: Arc<dyn BlueprintStore>,
35    /// Base dir for `$file` / `$agent_md` ref expansion; `None` skips expansion.
36    pub ref_base: Option<PathBuf>,
37    /// CLI-level `default_agent_kind` override (layer (2) of the 4-tier cascade).
38    pub cli_default_agent_kind: Option<AgentKind>,
39}
40
41/// Minimal entry: no `ref_base` (ref expansion skipped) and no CLI default kind override.
42pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
43    build_blueprints_router_with_refs(store, None, None)
44}
45
46/// When `ref_base` is set, `seed_blueprint` resolves `{"$file": ...}` /
47/// `{"$agent_md": ...}` refs in the body under that base dir and expands them.
48/// Path hygiene (absolute paths and `..` are rejected) is enforced inside
49/// `expand_file_refs`, sandboxed to the subtree under the base dir.
50///
51/// `cli_default_agent_kind` = the override from CLI `--default-agent-kind`
52/// (= layer (2) of the 4-tier cascade). Falls back when the BP JSON top-level
53/// `default_agent_kind` (= (3)) is absent; if that too is absent, uses the
54/// Schema `impl Default` = `Operator` (= (1)).
55pub fn build_blueprints_router_with_refs(
56    store: Arc<dyn BlueprintStore>,
57    ref_base: Option<PathBuf>,
58    cli_default_agent_kind: Option<AgentKind>,
59) -> Router {
60    let state = BlueprintsState {
61        store,
62        ref_base,
63        cli_default_agent_kind,
64    };
65    Router::new()
66        .route("/v1/blueprints/:id/head", get(get_head))
67        .route("/v1/blueprints/:id/history", get(get_history))
68        .route(
69            "/v1/blueprints/:id/agents/:agent/explain",
70            get(explain_agent),
71        )
72        .route(
73            "/v1/blueprints/:id/agents/explain",
74            get(explain_agents_batch),
75        )
76        .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
77        .route(
78            "/v1/blueprints/:id",
79            post(seed_blueprint).delete(archive_blueprint),
80        )
81        .with_state(state)
82}
83
84/// `DELETE /v1/blueprints/:id` — archive (logical soft-delete) the id.
85/// Appends an archive marker commit; the underlying Blueprint YAML is
86/// preserved as history. After archive, `read_head` /
87/// `TaskApplication::resolve` reject with `Archived`, and `list_ids`
88/// filters the id out by default.
89///
90/// Semantic rename: the HTTP path stays `DELETE` for client
91/// compatibility, but the behavior is archive, not physical delete.
92/// Restore via `POST /v1/blueprints/:id/unarchive`.
93///
94/// Returns: 204 No Content.
95async fn archive_blueprint(
96    State(state): State<BlueprintsState>,
97    Path(id): Path<String>,
98) -> Result<StatusCode, (StatusCode, String)> {
99    let bp_id = BlueprintId::new(id.clone());
100    state.store.archive_id(&bp_id).await.map_err(|e| match e {
101        mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
102        | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
103            (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
104        }
105        other => (
106            StatusCode::INTERNAL_SERVER_ERROR,
107            format!("archive_id: {other}"),
108        ),
109    })?;
110    Ok(StatusCode::NO_CONTENT)
111}
112
113/// `POST /v1/blueprints/:id/unarchive` — reverse of archive. Appends
114/// an unarchive marker commit so the audit trail records the event.
115async fn unarchive_blueprint(
116    State(state): State<BlueprintsState>,
117    Path(id): Path<String>,
118) -> Result<StatusCode, (StatusCode, String)> {
119    let bp_id = BlueprintId::new(id.clone());
120    state
121        .store
122        .unarchive_id(&bp_id)
123        .await
124        .map_err(|e| match e {
125            mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
126            | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
127                (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
128            }
129            other => (
130                StatusCode::INTERNAL_SERVER_ERROR,
131                format!("unarchive_id: {other}"),
132            ),
133        })?;
134    Ok(StatusCode::NO_CONTENT)
135}
136
137/// Format a Blueprint deserialization failure with a schema pointer, so a
138/// register error is self-serviceable (the schema export is the MCP adapter
139/// `bp_schema` tool = schemars JSON Schema of `Blueprint`).
140fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
141    format!(
142        "blueprint parse: {e} \
143         (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
144    )
145}
146
147/// `POST /v1/blueprints/:id` — register / re-register a Blueprint.
148///
149/// Semantics:
150/// - No prior head → seed as first commit (`write_new`, empty
151///   parents). Returns 201.
152/// - Prior head with **same** `ContentHash` → idempotent no-op.
153///   Returns 200 with `seeded: false`.
154/// - Prior head with **different** `ContentHash` → append a new
155///   commit on top of the current head (Git-native commit graph
156///   advance). Returns 201.
157/// - Prior head archived → returns 409 `Archived` (call
158///   `POST /:id/unarchive` first).
159/// - Concurrent POST on the same id → per-id lock contention returns
160///   429 Too Many Requests (client retry).
161///
162/// Path id vs body.id mismatch returns 400.
163///
164/// When `BlueprintsState.ref_base = Some(dir)`, `{"$file": ...}` /
165/// `{"$agent_md": ...}` refs in the body are expanded under the base
166/// dir via `expand_file_refs` before being parsed into a typed
167/// `Blueprint` (= path hygiene is applied by the loader, rejecting
168/// absolute paths and `..`).
169async fn seed_blueprint(
170    State(state): State<BlueprintsState>,
171    Path(id): Path<String>,
172    Json(raw_body): Json<serde_json::Value>,
173) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
174    let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
175        // Four-tier cascade for the kind resolution: (3) BP JSON top-level
176        // `default_agent_kind` → (2) CLI value → (1) Schema impl Default =
177        // Operator. Handed to expand_file_refs so the loader can resolve the
178        // kind when the $agent_md sibling is missing. The sibling `"kind"`
179        // literal (tier 4) wins first inside expand_file_refs.
180        let default_kind = match pre_read_default_agent_kind(&raw_body) {
181            // BP top-level carries a literal → use it verbatim.
182            kind if raw_body.get("default_agent_kind").is_some() => kind,
183            // BP top-level absent → CLI value fallback → Schema default.
184            _ => state
185                .cli_default_agent_kind
186                .clone()
187                .unwrap_or_else(default_global_agent_kind),
188        };
189        let expanded = expand_file_refs(raw_body, base, default_kind)
190            .map_err(|e| (StatusCode::BAD_REQUEST, format!("ref expand: {e}")))?;
191        serde_json::from_value(expanded)
192            .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
193    } else {
194        serde_json::from_value(raw_body)
195            .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
196    };
197    let store = state.store;
198    if id != body.id.as_str() {
199        return Err((
200            StatusCode::BAD_REQUEST,
201            format!("path id={id} != body.id={}", body.id),
202        ));
203    }
204    let bp_id = BlueprintId::new(id.clone());
205    let v = blueprint_version(&body).map_err(|e| {
206        (
207            StatusCode::INTERNAL_SERVER_ERROR,
208            format!("bp version: {e}"),
209        )
210    })?;
211    let prev_head = match store.read_head(&bp_id).await {
212        Ok(traced) => Some(traced),
213        Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
214        Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
215            return Err((
216                StatusCode::CONFLICT,
217                format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
218            ));
219        }
220        Err(e) => {
221            return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
222        }
223    };
224    if let Some(traced) = &prev_head {
225        if traced.trace.version == v {
226            return Ok((
227                StatusCode::OK,
228                Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
229            ));
230        }
231    }
232    let parents: Vec<_> = prev_head
233        .as_ref()
234        .map(|t| vec![t.trace.version])
235        .unwrap_or_default();
236    let now_ms = std::time::SystemTime::now()
237        .duration_since(std::time::UNIX_EPOCH)
238        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
239        .as_millis() as i64;
240    let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
241    store
242        .write_new(&bp_id, &body, &parents, meta)
243        .await
244        .map_err(|e| match &e {
245            mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
246                StatusCode::TOO_MANY_REQUESTS,
247                format!("blueprint {id} lock busy; retry"),
248            ),
249            mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
250                StatusCode::CONFLICT,
251                format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
252            ),
253            _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
254        })?;
255    Ok((
256        StatusCode::CREATED,
257        Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
258    ))
259}
260
261#[derive(Debug, Serialize)]
262struct HeadResponse {
263    id: String,
264    version: String,
265    blueprint: Blueprint,
266}
267
268async fn get_head(
269    State(state): State<BlueprintsState>,
270    Path(id): Path<String>,
271) -> Result<Json<HeadResponse>, (StatusCode, String)> {
272    let store = state.store;
273    let bp_id = BlueprintId::new(id.clone());
274    let traced = store
275        .read_head(&bp_id)
276        .await
277        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
278    Ok(Json(HeadResponse {
279        id,
280        version: format!("{:?}", traced.trace.version),
281        blueprint: traced.value,
282    }))
283}
284
285#[derive(Debug, Deserialize)]
286struct HistoryQuery {
287    #[serde(default = "default_limit")]
288    limit: usize,
289}
290
291fn default_limit() -> usize {
292    20
293}
294
295#[derive(Debug, Serialize)]
296struct HistoryEntry {
297    /// Content hash (= debug representation of `BlueprintVersion`).
298    hash: String,
299    /// SemVer label (`Blueprint.metadata.version_label`); `null` when unset.
300    version_label: Option<String>,
301    /// One-line changelog (= `CommitMetadata.rationale`).
302    rationale: String,
303}
304
305#[derive(Debug, Serialize)]
306struct HistoryResponse {
307    count: usize,
308    entries: Vec<HistoryEntry>,
309}
310
311async fn get_history(
312    State(state): State<BlueprintsState>,
313    Path(id): Path<String>,
314    Query(q): Query<HistoryQuery>,
315) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
316    let store = state.store;
317    let bp_id = BlueprintId::new(id);
318    let versions = store
319        .history(&bp_id, q.limit)
320        .await
321        .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
322    let mut entries = Vec::with_capacity(versions.len());
323    for v in versions {
324        let traced = store.read_version(&bp_id, v).await.map_err(|e| {
325            (
326                StatusCode::INTERNAL_SERVER_ERROR,
327                format!("read_version: {e}"),
328            )
329        })?;
330        let rationale = store
331            .read_commit_rationale(&bp_id, v)
332            .await
333            .unwrap_or(None)
334            .unwrap_or_default();
335        entries.push(HistoryEntry {
336            hash: format!("{:?}", v),
337            version_label: traced.value.metadata.version_label.clone(),
338            rationale,
339        });
340    }
341    let count = entries.len();
342    Ok(Json(HistoryResponse { count, entries }))
343}
344
345// ──────────────────────────────────────────────────────────────────────────
346// GET /v1/blueprints/:id/agents/:agent/explain
347// ──────────────────────────────────────────────────────────────────────────
348
349/// `blueprint` field of [`ExplainAgentResponse`]: which Blueprint this
350/// explain view was resolved against.
351#[derive(Debug, Serialize)]
352struct ExplainBlueprintRef {
353    /// Blueprint id (echoed back from the path param).
354    id: String,
355    /// Head commit version (`Trace.version`, debug-formatted — same
356    /// convention as [`HeadResponse::version`]).
357    version: String,
358}
359
360/// `agent` field of [`ExplainAgentResponse`]: the resolved agent's
361/// identity, verbatim from the Blueprint's `AgentDef`.
362#[derive(Debug, Serialize)]
363struct ExplainAgentRef {
364    /// Agent name (= `AgentDef.name`, echoed back from the path param).
365    name: String,
366    /// Worker IMPL kind (= `AgentDef.kind`).
367    kind: AgentKind,
368}
369
370/// `worker_binding` field of [`ExplainAgentResponse`] when the agent
371/// declares one. Mirrors `mlua_swarm::operator::WorkerBinding::variant`;
372/// its `tools` half is reported separately under `declared_tools`, so it
373/// is not duplicated here.
374#[derive(Debug, Serialize)]
375struct ExplainWorkerBinding {
376    /// Worker variant name (`AgentDef.profile.worker_binding`).
377    variant: String,
378}
379
380/// `declared_tools` field of [`ExplainAgentResponse`].
381#[derive(Debug, Serialize)]
382struct ExplainDeclaredTools {
383    /// `AgentDef.profile.tools`, verbatim (`[]` when `profile` is absent).
384    tools: Vec<String>,
385    /// Always `true` — see [`Self::note`].
386    informational: bool,
387    /// Explains why `tools` does not grant anything by itself.
388    note: String,
389}
390
391/// `system_prompt` field of [`ExplainAgentResponse`], present when
392/// `AgentDef.profile.system_prompt` is non-empty.
393#[derive(Debug, Serialize)]
394struct ExplainSystemPrompt {
395    /// UTF-8 byte length of the raw (unrendered) template.
396    bytes: usize,
397    /// Line count of the raw template (`str::lines` count).
398    lines: usize,
399    /// Variables `mlua_swarm::operator::render::template_variables`
400    /// reports the template requires. Empty when
401    /// [`Self::template_syntax_error`] is `Some`.
402    template_variables: Vec<String>,
403    /// `Some(message)` when the template failed to parse; `None`
404    /// otherwise.
405    template_syntax_error: Option<String>,
406    /// Explains the non-`Object` `initial_directive` binding rule.
407    note: String,
408}
409
410/// One key's entry in [`ExplainEffectiveCtx::keys`].
411#[derive(Debug, Serialize)]
412struct ExplainCtxKeyEntry {
413    /// The value this key resolves to (the winning tier's value).
414    value: serde_json::Value,
415    /// Which static tier supplied [`Self::value`] — one of
416    /// `"agent_inline"` / `"meta_ref"` / `"bp_global"`.
417    winning_tier: String,
418}
419
420/// `effective_ctx` field of [`ExplainAgentResponse`]: the static 3-tier
421/// cascade resolution `mlua_swarm::core::explain::explain_agent_ctx`
422/// computes (byte-identical to the runtime merge — see that function's
423/// doc for why this reuses rather than reimplements the merge).
424#[derive(Debug, Serialize)]
425struct ExplainEffectiveCtx {
426    /// Per-key winner table.
427    keys: BTreeMap<String, ExplainCtxKeyEntry>,
428    /// Explains that Run/Task/Step runtime tiers are out of scope here.
429    note: String,
430}
431
432/// `output` field of [`ExplainAgentResponse`].
433#[derive(Debug, Serialize)]
434struct ExplainOutput {
435    /// The canonical step-projection name
436    /// (`StepNaming::canonical_of_producer`), or the agent name itself as
437    /// a fallback — see [`Self::naming_warnings`].
438    projection_name: String,
439    /// Non-empty when [`Self::projection_name`] fell back to the agent
440    /// name, or `StepNaming::from_blueprint` itself failed (explain is a
441    /// diagnostic view, so neither case 500s — see [`explain_agent`]'s
442    /// doc).
443    naming_warnings: Vec<String>,
444    /// Explains the `{"out","parts"}` OUTPUT shape change for parts
445    /// staging.
446    parts_note: String,
447}
448
449/// `runner` field of [`ExplainAgentResponse`] (GH #46 Milestone 2) — the
450/// Runner-tier doctor diagnostics for this agent. Read-only and purely
451/// observational: nothing here gates compilation or dispatch (Milestone 3
452/// wires the resolved Runner into the launch path; this endpoint stays a
453/// diagnostic view), the same "surface it, never block"
454/// BLOCK-disabled-by-default convention `bp_doctor`'s agent-md size check
455/// already follows.
456#[derive(Debug, Serialize)]
457struct ExplainRunner {
458    /// The Runner this agent resolves to via `resolve_runner`'s 5-tier
459    /// cascade, when resolution succeeds. `None` when no tier declares a
460    /// Runner (byte-compat: an agent with no `runner` / `runner_ref` /
461    /// `profile.worker_binding` / `Blueprint.default_runner` resolves to
462    /// `None` here, mirroring [`ExplainAgentResponse::worker_binding`]).
463    resolved: Option<Runner>,
464    /// Error-level finding: `Some(msg)` when `resolve_runner` returned an
465    /// unresolved `runner_ref` / `default_runner` reference
466    /// (`RunnerResolveError`, rendered via its `Display`).
467    error: Option<String>,
468    /// Warn-level finding: `Some(msg)` when the resolved Runner's backend
469    /// disagrees with `AgentDef.kind` (`agent_block_in_process` paired
470    /// with a non-`agent_block` kind, or `ws_claude_code` paired with
471    /// `agent_block`). `None` when the pairing is consistent, or when
472    /// [`Self::resolved`] is `None`.
473    warning: Option<String>,
474}
475
476/// GH #46 M2 doctor check: does the resolved Runner's backend agree with
477/// `AgentDef.kind` about which backend actually executes this agent? Pure
478/// and read-only, never gates compile / dispatch (see [`ExplainRunner`]'s
479/// doc).
480fn runner_kind_mismatch_warning(
481    runner: &Runner,
482    kind: &AgentKind,
483    agent_name: &str,
484) -> Option<String> {
485    match (runner, kind) {
486        (Runner::AgentBlockInProcess { .. }, AgentKind::AgentBlock) => None,
487        (Runner::AgentBlockInProcess { .. }, other) => Some(format!(
488            "agent '{agent_name}' resolves to Runner::AgentBlockInProcess but AgentDef.kind = \
489             {other:?} (expected AgentBlock)"
490        )),
491        (Runner::WsClaudeCode { .. }, AgentKind::AgentBlock) => Some(format!(
492            "agent '{agent_name}' resolves to Runner::WsClaudeCode but AgentDef.kind = AgentBlock"
493        )),
494        (Runner::WsClaudeCode { .. }, _) => None,
495    }
496}
497
498/// Response body for `GET /v1/blueprints/:id/agents/:agent/explain`.
499#[derive(Debug, Serialize)]
500struct ExplainAgentResponse {
501    /// Which Blueprint this view was resolved against.
502    blueprint: ExplainBlueprintRef,
503    /// The resolved agent's identity.
504    agent: ExplainAgentRef,
505    /// The Blueprint-baked worker binding, if declared.
506    worker_binding: Option<ExplainWorkerBinding>,
507    /// `Some(reason)` when [`Self::worker_binding`] is `None`.
508    binding_note: Option<String>,
509    /// GH #46 M2 — Runner-tier doctor diagnostics (see [`ExplainRunner`]).
510    runner: ExplainRunner,
511    /// The agent's declared (informational-only) tool list.
512    declared_tools: ExplainDeclaredTools,
513    /// The rendered-template diagnostics, when `profile.system_prompt` is
514    /// non-empty.
515    system_prompt: Option<ExplainSystemPrompt>,
516    /// The static ctx cascade resolution.
517    effective_ctx: ExplainEffectiveCtx,
518    /// The step-projection naming resolution.
519    output: ExplainOutput,
520}
521
522/// Maps a static [`CtxTier`] to the wire label
523/// [`ExplainCtxKeyEntry::winning_tier`] reports.
524fn ctx_tier_label(tier: CtxTier) -> &'static str {
525    match tier {
526        CtxTier::AgentInline => "agent_inline",
527        CtxTier::MetaRef => "meta_ref",
528        CtxTier::BpGlobal => "bp_global",
529    }
530}
531
532/// Builds [`ExplainSystemPrompt`] from a non-empty `profile.system_prompt`
533/// template.
534fn explain_system_prompt(template: &str) -> ExplainSystemPrompt {
535    let (variables, template_syntax_error): (Vec<String>, Option<String>) =
536        match template_variables(template) {
537            Ok(vars) => (vars.into_iter().collect(), None),
538            Err(e) => (Vec::new(), Some(e.to_string())),
539        };
540    ExplainSystemPrompt {
541        bytes: template.len(),
542        lines: template.lines().count(),
543        template_variables: variables,
544        template_syntax_error,
545        note: "when the step directive is not a JSON object, only `value` is bound at render \
546               time"
547            .to_string(),
548    }
549}
550
551/// `GET /v1/blueprints/:id/agents/:agent/explain` — read-only, dry-run
552/// visualization of how `agent`'s Blueprint definition materializes into
553/// its runtime worker contract (see `workspace/tasks/explain-agent/issue.md`
554/// for the full design rationale). Same unauthenticated trust tier as
555/// [`get_head`] (an operator-diagnostic route; no engine state is touched
556/// — every value here is resolved statically from the head Blueprint
557/// alone).
558///
559/// 404s when the Blueprint id itself is not found (same error mapping as
560/// [`get_head`]) or when `agent` is not a name in `bp.agents` (JSON body:
561/// `{"error", "agent", "available"}`). A `StepNaming::from_blueprint`
562/// failure does not 500 — `output.projection_name` falls back to the
563/// agent name and the failure is reported via `output.naming_warnings`
564/// (this endpoint is a diagnostic view, not a compile gate).
565async fn explain_agent(
566    State(state): State<BlueprintsState>,
567    Path((id, agent)): Path<(String, String)>,
568) -> Result<Json<ExplainAgentResponse>, (StatusCode, String)> {
569    let store = state.store;
570    let bp_id = BlueprintId::new(id.clone());
571    let traced = store
572        .read_head(&bp_id)
573        .await
574        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
575    let bp = traced.value;
576    let version = format!("{:?}", traced.trace.version);
577
578    let Some(agent_def) = bp.agents.iter().find(|ad| ad.name == agent) else {
579        let available: Vec<&str> = bp.agents.iter().map(|ad| ad.name.as_str()).collect();
580        return Err((
581            StatusCode::NOT_FOUND,
582            serde_json::json!({
583                "error": "agent not found in blueprint",
584                "agent": agent,
585                "available": available,
586            })
587            .to_string(),
588        ));
589    };
590
591    let profile = agent_def.profile.as_ref();
592
593    let (worker_binding, binding_note) = match profile.and_then(|p| p.worker_binding.as_ref()) {
594        Some(variant) => (
595            Some(ExplainWorkerBinding {
596                variant: variant.clone(),
597            }),
598            None,
599        ),
600        None => (
601            None,
602            Some(
603                "no worker_binding declared; WS operator dispatch will fail at compile \
604                 (InvalidSpec)"
605                    .to_string(),
606            ),
607        ),
608    };
609
610    let declared_tools = ExplainDeclaredTools {
611        tools: profile.map(|p| p.tools.clone()).unwrap_or_default(),
612        informational: true,
613        note: "declared tools do not grant anything; the effective tool surface is the worker \
614               wrapper's frontmatter (see operator.rs WorkerBinding doc)"
615            .to_string(),
616    };
617
618    // GH #46 M2 doctor checks: unresolved runner_ref / default_runner is
619    // an error-level finding; a resolved-but-mismatched backend/kind pair
620    // is a warn-level finding. Both are purely observational (see
621    // `ExplainRunner`'s doc) — this never gates compile / dispatch.
622    let runner = match resolve_runner(&bp, agent_def) {
623        Ok(resolved) => {
624            let warning = resolved
625                .as_ref()
626                .and_then(|r| runner_kind_mismatch_warning(r, &agent_def.kind, &agent_def.name));
627            ExplainRunner {
628                resolved,
629                error: None,
630                warning,
631            }
632        }
633        Err(e) => ExplainRunner {
634            resolved: None,
635            error: Some(e.to_string()),
636            warning: None,
637        },
638    };
639
640    let system_prompt = profile
641        .filter(|p| !p.system_prompt.is_empty())
642        .map(|p| explain_system_prompt(&p.system_prompt));
643
644    let ctx_keys = explain_agent_ctx(&bp, &agent).unwrap_or_default();
645    let effective_ctx = ExplainEffectiveCtx {
646        keys: ctx_keys
647            .into_iter()
648            .map(|(k, resolution)| {
649                (
650                    k,
651                    ExplainCtxKeyEntry {
652                        value: resolution.value,
653                        winning_tier: ctx_tier_label(resolution.winning_tier).to_string(),
654                    },
655                )
656            })
657            .collect(),
658        note: "static tiers only; Run/Task/Step runtime tiers always win over these \
659               (only-if-absent insertion order)"
660            .to_string(),
661    };
662
663    let (projection_name, naming_warnings) = match StepNaming::from_blueprint(&bp) {
664        Ok((naming, _soft_warnings)) => match naming.canonical_of_producer(&agent) {
665            Some(canonical) => (canonical.to_string(), Vec::new()),
666            None => (
667                agent.clone(),
668                vec![format!(
669                    "agent '{agent}' does not appear in the blueprint's flow; using the agent \
670                     name as a fallback projection name"
671                )],
672            ),
673        },
674        Err(e) => (
675            agent.clone(),
676            vec![format!("StepNaming::from_blueprint failed: {e}")],
677        ),
678    };
679
680    let output = ExplainOutput {
681        projection_name,
682        naming_warnings,
683        parts_note: "if the worker stages named artifact parts, the step OUTPUT changes shape \
684                     to {\"out\", \"parts\"}; reference via $.<step>.out"
685            .to_string(),
686    };
687
688    Ok(Json(ExplainAgentResponse {
689        blueprint: ExplainBlueprintRef { id, version },
690        agent: ExplainAgentRef {
691            name: agent_def.name.clone(),
692            kind: agent_def.kind.clone(),
693        },
694        worker_binding,
695        binding_note,
696        runner,
697        declared_tools,
698        system_prompt,
699        effective_ctx,
700        output,
701    }))
702}
703
704// ──────────────────────────────────────────────────────────────────────────
705// GET /v1/blueprints/:id/agents/explain (batch summary)
706// ──────────────────────────────────────────────────────────────────────────
707
708/// `worker_binding` field of [`AgentSummary`] — same shape as
709/// [`ExplainWorkerBinding`] (kept as a distinct type so the batch response
710/// schema doesn't couple to the single-agent view's naming).
711#[derive(Debug, Serialize)]
712struct WorkerBindingSummary {
713    /// Worker variant name (`AgentDef.profile.worker_binding`).
714    variant: String,
715}
716
717/// One row of [`BatchExplainAgentsResponse::agents`] — a summary, not the
718/// full [`ExplainAgentResponse`] detail: a whole-Blueprint sweep response
719/// must stay small, so this reports counts/presence rather than the raw
720/// `declared_tools` list or the rendered `system_prompt` template. Drill
721/// down via `GET /v1/blueprints/:id/agents/:agent/explain` for the full
722/// per-agent view.
723#[derive(Debug, Serialize)]
724struct AgentSummary {
725    /// Agent name (`AgentDef.name`).
726    name: String,
727    /// Worker IMPL kind (`AgentDef.kind`, debug-formatted — same
728    /// convention as the `bp_doctor` MCP tool's per-agent `kind` field).
729    kind: String,
730    /// The Blueprint-baked worker binding, if declared. `null` (not
731    /// omitted) when absent — the caller needs to see every agent,
732    /// bound or not.
733    worker_binding: Option<WorkerBindingSummary>,
734    /// `AgentDef.profile.tools.len()`; `0` when `profile` is absent.
735    declared_tools_count: usize,
736    /// UTF-8 byte length of `profile.system_prompt`; `0` when `profile`
737    /// is absent or the template is empty.
738    system_prompt_bytes: usize,
739    /// Number of keys `explain_agent_ctx` resolves for this agent (the
740    /// static 3-tier cascade); `0` when the agent has no static ctx.
741    effective_ctx_key_count: usize,
742    /// The canonical step-projection name
743    /// (`StepNaming::canonical_of_producer`), falling back to the agent
744    /// name on a naming miss — same fail-soft convention as
745    /// [`ExplainOutput::projection_name`], but without a
746    /// `naming_warnings` companion (this is a summary row).
747    projection_name: String,
748}
749
750/// Response body for `GET /v1/blueprints/:id/agents/explain`.
751#[derive(Debug, Serialize)]
752struct BatchExplainAgentsResponse {
753    /// Which Blueprint this sweep was resolved against.
754    blueprint: ExplainBlueprintRef,
755    /// One row per `bp.agents` entry, in Blueprint order.
756    agents: Vec<AgentSummary>,
757}
758
759/// `GET /v1/blueprints/:id/agents/explain` — batch summary sweep across
760/// every agent in the Blueprint. Same read-only, dry-run, unauthenticated
761/// trust tier as [`explain_agent`] / [`get_head`] — nothing here is
762/// resolved beyond the head Blueprint.
763///
764/// 404s only when the Blueprint id itself is not found (same error
765/// mapping as [`get_head`]); a Blueprint with zero agents returns
766/// `agents: []`, not 404 (there is no per-agent path segment to fail to
767/// resolve here). `StepNaming::from_blueprint` failing does not 500 —
768/// every row's `projection_name` falls back to the agent name, mirroring
769/// [`explain_agent`]'s per-agent fail-soft convention (this batch view
770/// just has no `naming_warnings` companion field to report it through).
771async fn explain_agents_batch(
772    State(state): State<BlueprintsState>,
773    Path(id): Path<String>,
774) -> Result<Json<BatchExplainAgentsResponse>, (StatusCode, String)> {
775    let store = state.store;
776    let bp_id = BlueprintId::new(id.clone());
777    let traced = store
778        .read_head(&bp_id)
779        .await
780        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
781    let bp = traced.value;
782    let version = format!("{:?}", traced.trace.version);
783
784    // Resolved once for the whole Blueprint (StepNaming::from_blueprint is
785    // a whole-BP operation, not per-agent); a failure fails soft the same
786    // way explain_agent's per-agent lookup does — every row below falls
787    // back to the agent name via `.unwrap_or_else`.
788    let naming = StepNaming::from_blueprint(&bp)
789        .ok()
790        .map(|(naming, _)| naming);
791
792    let agents = bp
793        .agents
794        .iter()
795        .map(|agent_def| {
796            let profile = agent_def.profile.as_ref();
797            let worker_binding = profile
798                .and_then(|p| p.worker_binding.as_ref())
799                .map(|variant| WorkerBindingSummary {
800                    variant: variant.clone(),
801                });
802            let declared_tools_count = profile.map(|p| p.tools.len()).unwrap_or(0);
803            let system_prompt_bytes = profile.map(|p| p.system_prompt.len()).unwrap_or(0);
804            let effective_ctx_key_count = explain_agent_ctx(&bp, &agent_def.name)
805                .map(|keys| keys.len())
806                .unwrap_or(0);
807            let projection_name = naming
808                .as_ref()
809                .and_then(|naming| naming.canonical_of_producer(&agent_def.name))
810                .map(|canonical| canonical.to_string())
811                .unwrap_or_else(|| agent_def.name.clone());
812            AgentSummary {
813                name: agent_def.name.clone(),
814                kind: format!("{:?}", agent_def.kind),
815                worker_binding,
816                declared_tools_count,
817                system_prompt_bytes,
818                effective_ctx_key_count,
819                projection_name,
820            }
821        })
822        .collect();
823
824    Ok(Json(BatchExplainAgentsResponse {
825        blueprint: ExplainBlueprintRef { id, version },
826        agents,
827    }))
828}
829
830#[cfg(test)]
831mod explain_agent_tests {
832    use super::*;
833    use mlua_swarm::blueprint::store::InMemoryBlueprintStore;
834    use mlua_swarm::blueprint::{
835        current_schema_version, AgentDef, AgentMeta, AgentProfile, BlueprintMetadata,
836        CompilerHints, CompilerStrategy,
837    };
838    use serde_json::json;
839
840    fn agent_def(name: &str, profile: Option<AgentProfile>, meta: Option<AgentMeta>) -> AgentDef {
841        AgentDef {
842            name: name.to_string(),
843            kind: AgentKind::RustFn,
844            spec: json!({ "fn_id": name }),
845            profile,
846            meta,
847            runner: None,
848            runner_ref: None,
849            verdict: None,
850        }
851    }
852
853    /// A single-step Blueprint whose sole Step dispatches `agent_name` —
854    /// enough for `StepNaming::from_blueprint` to resolve a real (non-
855    /// fallback) `canonical_of_producer` entry.
856    fn single_step_bp(
857        bp_id: &str,
858        agent_name: &str,
859        profile: Option<AgentProfile>,
860        meta: Option<AgentMeta>,
861        default_agent_ctx: Option<serde_json::Value>,
862    ) -> Blueprint {
863        Blueprint {
864            schema_version: current_schema_version(),
865            id: bp_id.into(),
866            flow: serde_json::from_value(json!({
867                "kind": "step",
868                "ref": agent_name,
869                "in": {"op": "path", "at": "$.input"},
870                "out": {"op": "path", "at": "$.out"},
871            }))
872            .expect("flow parse"),
873            agents: vec![agent_def(agent_name, profile, meta)],
874            operators: vec![],
875            metas: vec![],
876            hints: CompilerHints::default(),
877            strategy: CompilerStrategy::default(),
878            metadata: BlueprintMetadata::default(),
879            spawner_hints: Default::default(),
880            default_agent_kind: AgentKind::Operator,
881            default_operator_kind: None,
882            default_init_ctx: None,
883            default_agent_ctx,
884            default_context_policy: None,
885            projection_placement: None,
886            audits: vec![],
887            degradation_policy: None,
888            runners: vec![],
889            default_runner: None,
890            check_policy: None,
891        }
892    }
893
894    async fn seed(store: &InMemoryBlueprintStore, bp: &Blueprint) {
895        let bp_id = BlueprintId::new(bp.id.as_str());
896        let v = blueprint_version(bp).expect("version");
897        store
898            .write_new(&bp_id, bp, &[], CommitMetadata::seed(bp_id.clone(), v, 0))
899            .await
900            .expect("write_new");
901    }
902
903    fn state_with(store: InMemoryBlueprintStore) -> BlueprintsState {
904        BlueprintsState {
905            store: Arc::new(store),
906            ref_base: None,
907            cli_default_agent_kind: None,
908        }
909    }
910
911    #[tokio::test]
912    async fn full_case_reports_binding_ctx_override_and_system_prompt() {
913        let profile = AgentProfile {
914            system_prompt: "Hello {{ name }}, mode={{ mode }}".to_string(),
915            tools: vec!["Read".to_string(), "Grep".to_string()],
916            worker_binding: Some("mse-worker-knowledge".to_string()),
917            ..Default::default()
918        };
919        let meta = AgentMeta {
920            ctx: Some(json!({ "work_dir": "/inline" })),
921            ..Default::default()
922        };
923        let bp = single_step_bp(
924            "explain-full-bp",
925            "researcher",
926            Some(profile),
927            Some(meta),
928            Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
929        );
930        let store = InMemoryBlueprintStore::new();
931        seed(&store, &bp).await;
932
933        let resp = explain_agent(
934            State(state_with(store)),
935            Path(("explain-full-bp".to_string(), "researcher".to_string())),
936        )
937        .await
938        .expect("explain_agent")
939        .0;
940
941        assert_eq!(resp.blueprint.id, "explain-full-bp");
942        assert!(!resp.blueprint.version.is_empty());
943        assert_eq!(resp.agent.name, "researcher");
944        assert_eq!(resp.agent.kind, AgentKind::RustFn);
945
946        let binding = resp.worker_binding.expect("worker_binding present");
947        assert_eq!(binding.variant, "mse-worker-knowledge");
948        assert!(resp.binding_note.is_none());
949
950        assert_eq!(
951            resp.declared_tools.tools,
952            vec!["Read".to_string(), "Grep".to_string()]
953        );
954        assert!(resp.declared_tools.informational);
955
956        let sp = resp.system_prompt.expect("system_prompt present");
957        assert_eq!(sp.bytes, "Hello {{ name }}, mode={{ mode }}".len());
958        assert_eq!(sp.lines, 1);
959        assert_eq!(
960            sp.template_variables,
961            vec!["mode".to_string(), "name".to_string()]
962        );
963        assert!(sp.template_syntax_error.is_none());
964
965        assert_eq!(resp.effective_ctx.keys["work_dir"].value, json!("/inline"));
966        assert_eq!(
967            resp.effective_ctx.keys["work_dir"].winning_tier,
968            "agent_inline"
969        );
970        assert_eq!(resp.effective_ctx.keys["extra"].value, json!("kept"));
971        assert_eq!(resp.effective_ctx.keys["extra"].winning_tier, "bp_global");
972
973        assert_eq!(resp.output.projection_name, "researcher");
974        assert!(resp.output.naming_warnings.is_empty());
975    }
976
977    #[tokio::test]
978    async fn agent_without_worker_binding_reports_binding_note() {
979        let profile = AgentProfile {
980            tools: vec!["Read".to_string()],
981            ..Default::default()
982        };
983        let bp = single_step_bp("explain-no-binding-bp", "scout", Some(profile), None, None);
984        let store = InMemoryBlueprintStore::new();
985        seed(&store, &bp).await;
986
987        let resp = explain_agent(
988            State(state_with(store)),
989            Path(("explain-no-binding-bp".to_string(), "scout".to_string())),
990        )
991        .await
992        .expect("explain_agent")
993        .0;
994
995        assert!(resp.worker_binding.is_none());
996        let note = resp.binding_note.expect("binding_note present");
997        assert!(note.contains("no worker_binding declared"));
998        assert!(resp.system_prompt.is_none());
999    }
1000
1001    #[tokio::test]
1002    async fn unknown_agent_name_returns_404_with_available_list() {
1003        let bp = single_step_bp("explain-404-agent-bp", "foo", None, None, None);
1004        let store = InMemoryBlueprintStore::new();
1005        seed(&store, &bp).await;
1006
1007        let err = explain_agent(
1008            State(state_with(store)),
1009            Path((
1010                "explain-404-agent-bp".to_string(),
1011                "no-such-agent".to_string(),
1012            )),
1013        )
1014        .await
1015        .expect_err("expected 404");
1016
1017        assert_eq!(err.0, StatusCode::NOT_FOUND);
1018        let body: serde_json::Value = serde_json::from_str(&err.1).expect("json body");
1019        assert_eq!(body["error"], "agent not found in blueprint");
1020        assert_eq!(body["agent"], "no-such-agent");
1021        assert_eq!(body["available"], json!(["foo"]));
1022    }
1023
1024    #[tokio::test]
1025    async fn unknown_blueprint_id_returns_404_same_as_get_head() {
1026        let store = InMemoryBlueprintStore::new();
1027
1028        let err = explain_agent(
1029            State(state_with(store)),
1030            Path(("no-such-bp".to_string(), "any-agent".to_string())),
1031        )
1032        .await
1033        .expect_err("expected 404");
1034
1035        assert_eq!(err.0, StatusCode::NOT_FOUND);
1036    }
1037
1038    #[tokio::test]
1039    async fn template_syntax_error_is_reported_without_500() {
1040        let profile = AgentProfile {
1041            system_prompt: "hello {{ unclosed".to_string(),
1042            ..Default::default()
1043        };
1044        let bp = single_step_bp(
1045            "explain-syntax-error-bp",
1046            "scout",
1047            Some(profile),
1048            None,
1049            None,
1050        );
1051        let store = InMemoryBlueprintStore::new();
1052        seed(&store, &bp).await;
1053
1054        let resp = explain_agent(
1055            State(state_with(store)),
1056            Path(("explain-syntax-error-bp".to_string(), "scout".to_string())),
1057        )
1058        .await
1059        .expect("explain_agent")
1060        .0;
1061
1062        let sp = resp.system_prompt.expect("system_prompt present");
1063        assert!(sp.template_variables.is_empty());
1064        assert!(sp.template_syntax_error.is_some());
1065    }
1066
1067    // ─── GH #46 M2: `runner` doctor checks (unknown ref error / backend↔kind mismatch warn) ───
1068
1069    #[tokio::test]
1070    async fn runner_resolves_from_legacy_worker_binding_when_nothing_else_declared() {
1071        let profile = AgentProfile {
1072            worker_binding: Some("mse-worker-knowledge".to_string()),
1073            tools: vec!["Read".to_string()],
1074            ..Default::default()
1075        };
1076        let bp = single_step_bp(
1077            "explain-runner-legacy-bp",
1078            "scout",
1079            Some(profile),
1080            None,
1081            None,
1082        );
1083        let store = InMemoryBlueprintStore::new();
1084        seed(&store, &bp).await;
1085
1086        let resp = explain_agent(
1087            State(state_with(store)),
1088            Path(("explain-runner-legacy-bp".to_string(), "scout".to_string())),
1089        )
1090        .await
1091        .expect("explain_agent")
1092        .0;
1093
1094        assert_eq!(
1095            resp.runner.resolved,
1096            Some(mlua_swarm_schema::Runner::WsClaudeCode {
1097                variant: "mse-worker-knowledge".to_string(),
1098                tools: vec!["Read".to_string()],
1099            })
1100        );
1101        assert!(resp.runner.error.is_none());
1102        assert!(resp.runner.warning.is_none());
1103    }
1104
1105    #[tokio::test]
1106    async fn runner_reports_unresolved_runner_ref_as_error_level_finding() {
1107        let mut bp = single_step_bp("explain-runner-unresolved-bp", "scout", None, None, None);
1108        bp.agents[0].runner_ref = Some("no-such-entry".to_string());
1109        let store = InMemoryBlueprintStore::new();
1110        seed(&store, &bp).await;
1111
1112        let resp = explain_agent(
1113            State(state_with(store)),
1114            Path((
1115                "explain-runner-unresolved-bp".to_string(),
1116                "scout".to_string(),
1117            )),
1118        )
1119        .await
1120        .expect("explain_agent")
1121        .0;
1122
1123        assert!(resp.runner.resolved.is_none());
1124        let error = resp.runner.error.expect("error-level finding present");
1125        assert!(
1126            error.contains("no-such-entry"),
1127            "error must name the unresolved runner_ref: {error}"
1128        );
1129        assert!(resp.runner.warning.is_none());
1130    }
1131
1132    #[tokio::test]
1133    async fn runner_reports_backend_kind_mismatch_as_warn_level_finding() {
1134        // `AgentDef.kind = RustFn` (via `single_step_bp`'s `agent_def` helper)
1135        // paired with an `agent_block_in_process` Runner is the documented
1136        // mismatch (Design §6: "backend ↔ kind mismatch").
1137        let mut bp = single_step_bp("explain-runner-mismatch-bp", "scout", None, None, None);
1138        bp.runners = vec![mlua_swarm_schema::RunnerDef {
1139            name: "in-process".to_string(),
1140            runner: mlua_swarm_schema::Runner::AgentBlockInProcess {
1141                tools: vec!["Bash".to_string()],
1142            },
1143        }];
1144        bp.agents[0].runner_ref = Some("in-process".to_string());
1145        let store = InMemoryBlueprintStore::new();
1146        seed(&store, &bp).await;
1147
1148        let resp = explain_agent(
1149            State(state_with(store)),
1150            Path((
1151                "explain-runner-mismatch-bp".to_string(),
1152                "scout".to_string(),
1153            )),
1154        )
1155        .await
1156        .expect("explain_agent")
1157        .0;
1158
1159        assert!(resp.runner.resolved.is_some());
1160        assert!(resp.runner.error.is_none());
1161        let warning = resp.runner.warning.expect("warn-level finding present");
1162        assert!(
1163            warning.contains("AgentBlockInProcess") && warning.contains("RustFn"),
1164            "warning must name both the resolved backend and the mismatched kind: {warning}"
1165        );
1166    }
1167
1168    // ─── GH #47: batch summary sweep (explain_agents_batch) ────────────
1169
1170    /// A 3-agent Blueprint whose flow only dispatches `bound_agent` — the
1171    /// other two are unreferenced by the flow, so `StepNaming` misses them
1172    /// (fail-soft fallback to the agent name is exercised for both).
1173    fn batch_bp() -> Blueprint {
1174        let bound_profile = AgentProfile {
1175            system_prompt: "hello world".to_string(),
1176            tools: vec!["Read".to_string(), "Grep".to_string()],
1177            worker_binding: Some("mse-worker-knowledge".to_string()),
1178            ..Default::default()
1179        };
1180        let bound_meta = AgentMeta {
1181            ctx: Some(json!({ "work_dir": "/inline" })),
1182            ..Default::default()
1183        };
1184        Blueprint {
1185            schema_version: current_schema_version(),
1186            id: "explain-batch-bp".into(),
1187            flow: serde_json::from_value(json!({
1188                "kind": "step",
1189                "ref": "bound_agent",
1190                "in": {"op": "path", "at": "$.input"},
1191                "out": {"op": "path", "at": "$.out"},
1192            }))
1193            .expect("flow parse"),
1194            agents: vec![
1195                agent_def("bound_agent", Some(bound_profile), Some(bound_meta)),
1196                agent_def("unbound_agent", None, None),
1197                agent_def("orphan_agent", None, None),
1198            ],
1199            operators: vec![],
1200            metas: vec![],
1201            hints: CompilerHints::default(),
1202            strategy: CompilerStrategy::default(),
1203            metadata: BlueprintMetadata::default(),
1204            spawner_hints: Default::default(),
1205            default_agent_kind: AgentKind::Operator,
1206            default_operator_kind: None,
1207            default_init_ctx: None,
1208            default_agent_ctx: Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
1209            default_context_policy: None,
1210            projection_placement: None,
1211            audits: vec![],
1212            degradation_policy: None,
1213            runners: vec![],
1214            default_runner: None,
1215            check_policy: None,
1216        }
1217    }
1218
1219    #[tokio::test]
1220    async fn explain_agents_batch_reports_a_summary_row_per_agent() {
1221        let bp = batch_bp();
1222        let store = InMemoryBlueprintStore::new();
1223        seed(&store, &bp).await;
1224
1225        let resp = explain_agents_batch(
1226            State(state_with(store)),
1227            Path("explain-batch-bp".to_string()),
1228        )
1229        .await
1230        .expect("explain_agents_batch")
1231        .0;
1232
1233        assert_eq!(resp.blueprint.id, "explain-batch-bp");
1234        assert!(!resp.blueprint.version.is_empty());
1235        assert_eq!(resp.agents.len(), 3);
1236
1237        let bound = resp
1238            .agents
1239            .iter()
1240            .find(|a| a.name == "bound_agent")
1241            .expect("bound_agent row");
1242        assert_eq!(bound.kind, format!("{:?}", AgentKind::RustFn));
1243        let binding = bound
1244            .worker_binding
1245            .as_ref()
1246            .expect("worker_binding present");
1247        assert_eq!(binding.variant, "mse-worker-knowledge");
1248        assert_eq!(bound.declared_tools_count, 2);
1249        assert_eq!(bound.system_prompt_bytes, "hello world".len());
1250        // work_dir (agent_inline override) + extra (bp-global carry) = 2 keys.
1251        assert_eq!(bound.effective_ctx_key_count, 2);
1252        // Referenced by the flow -> a real (non-fallback) canonical name.
1253        assert_eq!(bound.projection_name, "bound_agent");
1254
1255        let unbound = resp
1256            .agents
1257            .iter()
1258            .find(|a| a.name == "unbound_agent")
1259            .expect("unbound_agent row");
1260        assert!(unbound.worker_binding.is_none());
1261        assert_eq!(unbound.declared_tools_count, 0);
1262        assert_eq!(unbound.system_prompt_bytes, 0);
1263        // Only the bp-global tier applies (no agent-level meta) = 2 keys.
1264        assert_eq!(unbound.effective_ctx_key_count, 2);
1265        // Not referenced by the flow -> StepNaming miss -> fallback to name.
1266        assert_eq!(unbound.projection_name, "unbound_agent");
1267
1268        let orphan = resp
1269            .agents
1270            .iter()
1271            .find(|a| a.name == "orphan_agent")
1272            .expect("orphan_agent row");
1273        assert_eq!(orphan.projection_name, "orphan_agent");
1274    }
1275
1276    #[tokio::test]
1277    async fn explain_agents_batch_zero_agents_returns_empty_list_not_404() {
1278        let bp = Blueprint {
1279            schema_version: current_schema_version(),
1280            id: "explain-batch-empty-bp".into(),
1281            flow: serde_json::from_value(json!({
1282                "kind": "step",
1283                "ref": "unused",
1284                "in": {"op": "path", "at": "$.input"},
1285                "out": {"op": "path", "at": "$.out"},
1286            }))
1287            .expect("flow parse"),
1288            agents: vec![],
1289            operators: vec![],
1290            metas: vec![],
1291            hints: CompilerHints::default(),
1292            strategy: CompilerStrategy::default(),
1293            metadata: BlueprintMetadata::default(),
1294            spawner_hints: Default::default(),
1295            default_agent_kind: AgentKind::Operator,
1296            default_operator_kind: None,
1297            default_init_ctx: None,
1298            default_agent_ctx: None,
1299            default_context_policy: None,
1300            projection_placement: None,
1301            audits: vec![],
1302            degradation_policy: None,
1303            runners: vec![],
1304            default_runner: None,
1305            check_policy: None,
1306        };
1307        let store = InMemoryBlueprintStore::new();
1308        seed(&store, &bp).await;
1309
1310        let resp = explain_agents_batch(
1311            State(state_with(store)),
1312            Path("explain-batch-empty-bp".to_string()),
1313        )
1314        .await
1315        .expect("explain_agents_batch")
1316        .0;
1317
1318        assert!(resp.agents.is_empty());
1319    }
1320
1321    #[tokio::test]
1322    async fn explain_agents_batch_unknown_blueprint_id_returns_404_same_as_get_head() {
1323        let store = InMemoryBlueprintStore::new();
1324
1325        let err = explain_agents_batch(State(state_with(store)), Path("no-such-bp".to_string()))
1326            .await
1327            .expect_err("expected 404");
1328
1329        assert_eq!(err.0, StatusCode::NOT_FOUND);
1330    }
1331}