use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use mlua_swarm::blueprint::loader::{expand_file_refs, pre_read_default_agent_kind};
use mlua_swarm::blueprint::store::{
blueprint_version, BlueprintId, BlueprintStore, CommitMetadata,
};
use mlua_swarm::blueprint::{default_global_agent_kind, AgentKind, Blueprint};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone)]
pub struct BlueprintsState {
pub store: Arc<dyn BlueprintStore>,
pub ref_base: Option<PathBuf>,
pub cli_default_agent_kind: Option<AgentKind>,
}
pub fn build_blueprints_router(store: Arc<dyn BlueprintStore>) -> Router {
build_blueprints_router_with_refs(store, None, None)
}
pub fn build_blueprints_router_with_refs(
store: Arc<dyn BlueprintStore>,
ref_base: Option<PathBuf>,
cli_default_agent_kind: Option<AgentKind>,
) -> Router {
let state = BlueprintsState {
store,
ref_base,
cli_default_agent_kind,
};
Router::new()
.route("/v1/blueprints/:id/head", get(get_head))
.route("/v1/blueprints/:id/history", get(get_history))
.route("/v1/blueprints/:id/unarchive", post(unarchive_blueprint))
.route(
"/v1/blueprints/:id",
post(seed_blueprint).delete(archive_blueprint),
)
.with_state(state)
}
async fn archive_blueprint(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let bp_id = BlueprintId::new(id.clone());
state.store.archive_id(&bp_id).await.map_err(|e| match e {
mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
| mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
(StatusCode::NOT_FOUND, format!("archive_id: {e}"))
}
other => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("archive_id: {other}"),
),
})?;
Ok(StatusCode::NO_CONTENT)
}
async fn unarchive_blueprint(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let bp_id = BlueprintId::new(id.clone());
state
.store
.unarchive_id(&bp_id)
.await
.map_err(|e| match e {
mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)
| mlua_swarm::blueprint::store::BlueprintStoreError::IdNotFound(_) => {
(StatusCode::NOT_FOUND, format!("unarchive_id: {e}"))
}
other => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("unarchive_id: {other}"),
),
})?;
Ok(StatusCode::NO_CONTENT)
}
fn parse_error_with_schema_hint(e: &serde_json::Error) -> String {
format!(
"blueprint parse: {e} \
(hint: fetch the Blueprint JSON Schema via the MCP adapter bp_schema tool)"
)
}
async fn seed_blueprint(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
Json(raw_body): Json<serde_json::Value>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
let body: Blueprint = if let Some(base) = state.ref_base.as_ref() {
let default_kind = match pre_read_default_agent_kind(&raw_body) {
kind if raw_body.get("default_agent_kind").is_some() => kind,
_ => state
.cli_default_agent_kind
.clone()
.unwrap_or_else(default_global_agent_kind),
};
let expanded = expand_file_refs(raw_body, base, default_kind)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("ref expand: {e}")))?;
serde_json::from_value(expanded)
.map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
} else {
serde_json::from_value(raw_body)
.map_err(|e| (StatusCode::BAD_REQUEST, parse_error_with_schema_hint(&e)))?
};
let store = state.store;
if id != body.id {
return Err((
StatusCode::BAD_REQUEST,
format!("path id={id} != body.id={}", body.id),
));
}
let bp_id = BlueprintId::new(id.clone());
let v = blueprint_version(&body).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bp version: {e}"),
)
})?;
let prev_head = match store.read_head(&bp_id).await {
Ok(traced) => Some(traced),
Err(mlua_swarm::blueprint::store::BlueprintStoreError::HeadEmpty(_)) => None,
Err(mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_)) => {
return Err((
StatusCode::CONFLICT,
format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
));
}
Err(e) => {
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read_head: {e}")));
}
};
if let Some(traced) = &prev_head {
if traced.trace.version == v {
return Ok((
StatusCode::OK,
Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": false})),
));
}
}
let parents: Vec<_> = prev_head
.as_ref()
.map(|t| vec![t.trace.version])
.unwrap_or_default();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.as_millis() as i64;
let meta = CommitMetadata::seed(bp_id.clone(), v, now_ms);
store
.write_new(&bp_id, &body, &parents, meta)
.await
.map_err(|e| match &e {
mlua_swarm::blueprint::store::BlueprintStoreError::LockBusy => (
StatusCode::TOO_MANY_REQUESTS,
format!("blueprint {id} lock busy; retry"),
),
mlua_swarm::blueprint::store::BlueprintStoreError::Archived(_) => (
StatusCode::CONFLICT,
format!("blueprint {id} is archived; POST /v1/blueprints/{id}/unarchive first"),
),
_ => (StatusCode::INTERNAL_SERVER_ERROR, format!("write_new: {e}")),
})?;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({"id": id, "version": format!("{:?}", v), "seeded": true})),
))
}
#[derive(Debug, Serialize)]
struct HeadResponse {
id: String,
version: String,
blueprint: Blueprint,
}
async fn get_head(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
) -> Result<Json<HeadResponse>, (StatusCode, String)> {
let store = state.store;
let bp_id = BlueprintId::new(id.clone());
let traced = store
.read_head(&bp_id)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("read_head: {e}")))?;
Ok(Json(HeadResponse {
id,
version: format!("{:?}", traced.trace.version),
blueprint: traced.value,
}))
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
20
}
#[derive(Debug, Serialize)]
struct HistoryEntry {
hash: String,
version_label: Option<String>,
rationale: String,
}
#[derive(Debug, Serialize)]
struct HistoryResponse {
count: usize,
entries: Vec<HistoryEntry>,
}
async fn get_history(
State(state): State<BlueprintsState>,
Path(id): Path<String>,
Query(q): Query<HistoryQuery>,
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
let store = state.store;
let bp_id = BlueprintId::new(id);
let versions = store
.history(&bp_id, q.limit)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("history: {e}")))?;
let mut entries = Vec::with_capacity(versions.len());
for v in versions {
let traced = store.read_version(&bp_id, v).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("read_version: {e}"),
)
})?;
let rationale = store
.read_commit_rationale(&bp_id, v)
.await
.unwrap_or(None)
.unwrap_or_default();
entries.push(HistoryEntry {
hash: format!("{:?}", v),
version_label: traced.value.metadata.version_label.clone(),
rationale,
});
}
let count = entries.len();
Ok(Json(HistoryResponse { count, entries }))
}