1use 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#[derive(Clone)]
27pub struct BlueprintsState {
28 pub store: Arc<dyn BlueprintStore>,
30 pub ref_base: Option<PathBuf>,
32 pub cli_default_agent_kind: Option<AgentKind>,
34}
35
36pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
38 build_blueprints_router_with_refs(store, None, None)
39}
40
41pub 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
71async 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
100async 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
124fn 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
134async 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 let default_kind = match pre_read_default_agent_kind(&raw_body) {
168 kind if raw_body.get("default_agent_kind").is_some() => kind,
170 _ => 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 hash: String,
286 version_label: Option<String>,
288 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}