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 serde::{Deserialize, Serialize};
18use std::path::PathBuf;
19use std::sync::Arc;
20
21/// Router state: BP store + the base dir used to resolve `$file` / `$agent_md`
22/// refs + `default_agent_kind` from the CLI (= layer (2) of the 4-tier cascade —
23/// the CLI override layer).
24/// When `ref_base = None`, ref expansion is skipped (= seed bodies are parsed
25/// as raw JSON).
26#[derive(Clone)]
27pub struct BlueprintsState {
28    /// Backing Blueprint store (git2 or in-memory backend).
29    pub store: Arc<dyn BlueprintStore>,
30    /// Base dir for `$file` / `$agent_md` ref expansion; `None` skips expansion.
31    pub ref_base: Option<PathBuf>,
32    /// CLI-level `default_agent_kind` override (layer (2) of the 4-tier cascade).
33    pub cli_default_agent_kind: Option<AgentKind>,
34}
35
36/// Minimal entry: no `ref_base` (ref expansion skipped) and no CLI default kind override.
37pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
38    build_blueprints_router_with_refs(store, None, None)
39}
40
41/// When `ref_base` is set, `seed_blueprint` resolves `{"$file": ...}` /
42/// `{"$agent_md": ...}` refs in the body under that base dir and expands them.
43/// Path hygiene (absolute paths and `..` are rejected) is enforced inside
44/// `expand_file_refs`, sandboxed to the subtree under the base dir.
45///
46/// `cli_default_agent_kind` = the override from CLI `--default-agent-kind`
47/// (= layer (2) of the 4-tier cascade). Falls back when the BP JSON top-level
48/// `default_agent_kind` (= (3)) is absent; if that too is absent, uses the
49/// Schema `impl Default` = `Operator` (= (1)).
50pub fn build_blueprints_router_with_refs(
51    store: Arc<dyn BlueprintStore>,
52    ref_base: Option<PathBuf>,
53    cli_default_agent_kind: Option<AgentKind>,
54) -> Router {
55    let state = BlueprintsState {
56        store,
57        ref_base,
58        cli_default_agent_kind,
59    };
60    Router::new()
61        .route("/v1/blueprints/:id/head", get(get_head))
62        .route("/v1/blueprints/:id/history", get(get_history))
63        .route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
64        .route(
65            "/v1/blueprints/:id",
66            post(seed_blueprint).delete(archive_blueprint),
67        )
68        .with_state(state)
69}
70
71/// `DELETE /v1/blueprints/:id` — archive (logical soft-delete) the id.
72/// Appends an archive marker commit; the underlying Blueprint YAML is
73/// preserved as history. After archive, `read_head` /
74/// `TaskApplication::resolve` reject with `Archived`, and `list_ids`
75/// filters the id out by default.
76///
77/// Semantic rename: the HTTP path stays `DELETE` for client
78/// compatibility, but the behavior is archive, not physical delete.
79/// Restore via `POST /v1/blueprints/:id/unarchive`.
80///
81/// Returns: 204 No Content.
82async fn archive_blueprint(
83    State(state): State<BlueprintsState>,
84    Path(id): Path<String>,
85) -> Result<StatusCode, (StatusCode, String)> {
86    let bp_id = BlueprintId::new(id.clone());
87    state.store.archive_id(&bp_id).await.map_err(|e| match e {
88        mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
89        | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
90            (StatusCode::NOT_FOUND, format!("archive_id: {e}"))
91        }
92        other => (
93            StatusCode::INTERNAL_SERVER_ERROR,
94            format!("archive_id: {other}"),
95        ),
96    })?;
97    Ok(StatusCode::NO_CONTENT)
98}
99
100/// `POST /v1/blueprints/:id/unarchive` — reverse of archive. Appends
101/// an unarchive marker commit so the audit trail records the event.
102async fn unarchive_blueprint(
103    State(state): State<BlueprintsState>,
104    Path(id): Path<String>,
105) -> Result<StatusCode, (StatusCode, String)> {
106    let bp_id = BlueprintId::new(id.clone());
107    state
108        .store
109        .unarchive_id(&bp_id)
110        .await
111        .map_err(|e| match e {
112            mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
113            | mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
114                (StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
115            }
116            other => (
117                StatusCode::INTERNAL_SERVER_ERROR,
118                format!("unarchive_id: {other}"),
119            ),
120        })?;
121    Ok(StatusCode::NO_CONTENT)
122}
123
124/// Format a Blueprint deserialization failure with a schema pointer, so a
125/// register error is self-serviceable (the schema export is the MCP adapter
126/// `bp_schema` tool = schemars JSON Schema of `Blueprint`).
127fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
128    format!(
129        "blueprint parse: {e} \
130         (hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
131    )
132}
133
134/// `POST /v1/blueprints/:id` — register / re-register a Blueprint.
135///
136/// Semantics:
137/// - No prior head → seed as first commit (`write_new`, empty
138///   parents). Returns 201.
139/// - Prior head with **same** `ContentHash` → idempotent no-op.
140///   Returns 200 with `seeded: false`.
141/// - Prior head with **different** `ContentHash` → append a new
142///   commit on top of the current head (Git-native commit graph
143///   advance). Returns 201.
144/// - Prior head archived → returns 409 `Archived` (call
145///   `POST /:id/unarchive` first).
146/// - Concurrent POST on the same id → per-id lock contention returns
147///   429 Too Many Requests (client retry).
148///
149/// Path id vs body.id mismatch returns 400.
150///
151/// When `BlueprintsState.ref_base = Some(dir)`, `{"$file": ...}` /
152/// `{"$agent_md": ...}` refs in the body are expanded under the base
153/// dir via `expand_file_refs` before being parsed into a typed
154/// `Blueprint` (= path hygiene is applied by the loader, rejecting
155/// absolute paths and `..`).
156async fn seed_blueprint(
157    State(state): State<BlueprintsState>,
158    Path(id): Path<String>,
159    Json(raw_body): Json<serde_json::Value>,
160) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
161    let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
162        // Four-tier cascade for the kind resolution: (3) BP JSON top-level
163        // `default_agent_kind` → (2) CLI value → (1) Schema impl Default =
164        // Operator. Handed to expand_file_refs so the loader can resolve the
165        // kind when the $agent_md sibling is missing. The sibling `"kind"`
166        // literal (tier 4) wins first inside expand_file_refs.
167        let default_kind = match pre_read_default_agent_kind(&raw_body) {
168            // BP top-level carries a literal → use it verbatim.
169            kind if raw_body.get("default_agent_kind").is_some() => kind,
170            // BP top-level absent → CLI value fallback → Schema default.
171            _ => state
172                .cli_default_agent_kind
173                .clone()
174                .unwrap_or_else(default_global_agent_kind),
175        };
176        let expanded = expand_file_refs(raw_body, base, default_kind)
177            .map_err(|e| (StatusCode::BAD_REQUEST, format!("ref expand: {e}")))?;
178        serde_json::from_value(expanded)
179            .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
180    } else {
181        serde_json::from_value(raw_body)
182            .map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
183    };
184    let store = state.store;
185    if id != body.id {
186        return Err((
187            StatusCode::BAD_REQUEST,
188            format!("path id={id} != body.id={}", body.id),
189        ));
190    }
191    let bp_id = BlueprintId::new(id.clone());
192    let v = blueprint_version(&body).map_err(|e| {
193        (
194            StatusCode::INTERNAL_SERVER_ERROR,
195            format!("bp version: {e}"),
196        )
197    })?;
198    let prev_head = match store.read_head(&bp_id).await {
199        Ok(traced) => Some(traced),
200        Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
201        Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
202            return Err((
203                StatusCode::CONFLICT,
204                format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
205            ));
206        }
207        Err(e) => {
208            return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
209        }
210    };
211    if let Some(traced) = &prev_head {
212        if traced.trace.version == v {
213            return Ok((
214                StatusCode::OK,
215                Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
216            ));
217        }
218    }
219    let parents: Vec<_> = prev_head
220        .as_ref()
221        .map(|t| vec![t.trace.version])
222        .unwrap_or_default();
223    let now_ms = std::time::SystemTime::now()
224        .duration_since(std::time::UNIX_EPOCH)
225        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
226        .as_millis() as i64;
227    let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
228    store
229        .write_new(&bp_id, &body, &parents, meta)
230        .await
231        .map_err(|e| match &e {
232            mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
233                StatusCode::TOO_MANY_REQUESTS,
234                format!("blueprint {id} lock busy; retry"),
235            ),
236            mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
237                StatusCode::CONFLICT,
238                format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
239            ),
240            _ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
241        })?;
242    Ok((
243        StatusCode::CREATED,
244        Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
245    ))
246}
247
248#[derive(Debug, Serialize)]
249struct HeadResponse {
250    id: String,
251    version: String,
252    blueprint: Blueprint,
253}
254
255async fn get_head(
256    State(state): State<BlueprintsState>,
257    Path(id): Path<String>,
258) -> Result<Json<HeadResponse>, (StatusCode, String)> {
259    let store = state.store;
260    let bp_id = BlueprintId::new(id.clone());
261    let traced = store
262        .read_head(&bp_id)
263        .await
264        .map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
265    Ok(Json(HeadResponse {
266        id,
267        version: format!("{:?}", traced.trace.version),
268        blueprint: traced.value,
269    }))
270}
271
272#[derive(Debug, Deserialize)]
273struct HistoryQuery {
274    #[serde(default = "default_limit")]
275    limit: usize,
276}
277
278fn default_limit() -> usize {
279    20
280}
281
282#[derive(Debug, Serialize)]
283struct HistoryEntry {
284    /// Content hash (= debug representation of `BlueprintVersion`).
285    hash: String,
286    /// SemVer label (`Blueprint.metadata.version_label`); `null` when unset.
287    version_label: Option<String>,
288    /// One-line changelog (= `CommitMetadata.rationale`).
289    rationale: String,
290}
291
292#[derive(Debug, Serialize)]
293struct HistoryResponse {
294    count: usize,
295    entries: Vec<HistoryEntry>,
296}
297
298async fn get_history(
299    State(state): State<BlueprintsState>,
300    Path(id): Path<String>,
301    Query(q): Query<HistoryQuery>,
302) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
303    let store = state.store;
304    let bp_id = BlueprintId::new(id);
305    let versions = store
306        .history(&bp_id, q.limit)
307        .await
308        .map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
309    let mut entries = Vec::with_capacity(versions.len());
310    for v in versions {
311        let traced = store.read_version(&bp_id, v).await.map_err(|e| {
312            (
313                StatusCode::INTERNAL_SERVER_ERROR,
314                format!("read_version: {e}"),
315            )
316        })?;
317        let rationale = store
318            .read_commit_rationale(&bp_id, v)
319            .await
320            .unwrap_or(None)
321            .unwrap_or_default();
322        entries.push(HistoryEntry {
323            hash: format!("{:?}", v),
324            version_label: traced.value.metadata.version_label.clone(),
325            rationale,
326        });
327    }
328    let count = entries.len();
329    Ok(Json(HistoryResponse { count, entries }))
330}