use super::AppState;
use super::api::require_auth;
use super::api_agents::build_kumiho_client;
use super::kumiho_client::KumihoError;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn kumiho_err(e: KumihoError) -> axum::response::Response {
super::kumiho_client::kumiho_error_to_response(e)
}
async fn call_operator_tool(
state: &AppState,
tool: &str,
args: serde_json::Map<String, serde_json::Value>,
) -> Result<serde_json::Value, String> {
let tool_name = format!("{}__{}", crate::agent::operator::OPERATOR_SERVER_NAME, tool);
let registry = state
.mcp_registry()
.ok_or_else(|| "MCP registry not available — operator not connected".to_string())?;
let fut = registry.call_tool(&tool_name, serde_json::Value::Object(args));
let result_str = match tokio::time::timeout(std::time::Duration::from_secs(30), fut).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(format!("operator {tool} failed: {e:#}")),
Err(_) => return Err(format!("operator {tool} timed out (30s)")),
};
let outer: serde_json::Value = serde_json::from_str(&result_str)
.map_err(|e| format!("{tool}: outer JSON parse failed: {e}"))?;
let inner_text = outer
.get("content")
.and_then(|c| c.get(0))
.and_then(|c0| c0.get("text"))
.and_then(|t| t.as_str())
.ok_or_else(|| format!("{tool}: missing content[0].text"))?;
serde_json::from_str(inner_text).map_err(|e| format!("{tool}: inner JSON parse failed: {e}"))
}
fn normalize_kref(raw: &str) -> String {
let stripped = raw.strip_prefix("kref://").unwrap_or(raw);
format!("kref://{stripped}")
}
#[derive(Deserialize)]
pub struct ReviseBody {
pub workflow_kref: String,
pub operations: Vec<serde_json::Value>,
pub rationale: Option<String>,
}
#[derive(Deserialize)]
pub struct ValidateYamlBody {
pub yaml: String,
pub base_yaml: Option<String>,
pub intent_summary: Option<String>,
}
#[derive(Deserialize)]
pub struct RepublishBody {
pub revision_kref: String,
}
#[derive(Deserialize)]
pub struct RevisionsQuery {
pub workflow_kref: String,
}
#[derive(Serialize)]
pub struct RevisionSummary {
pub kref: String,
pub number: i32,
pub created_at: Option<String>,
pub tags: Vec<String>,
pub metadata: HashMap<String, String>,
}
pub async fn handle_architect_revise(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<ReviseBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if body.workflow_kref.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "workflow_kref required").into_response();
}
if body.operations.is_empty() {
return (StatusCode::BAD_REQUEST, "operations must be non-empty").into_response();
}
let mut args = serde_json::Map::new();
args.insert(
"workflow_kref".to_string(),
serde_json::Value::String(body.workflow_kref),
);
args.insert(
"operations".to_string(),
serde_json::Value::Array(body.operations),
);
if let Some(r) = body.rationale {
args.insert("rationale".to_string(), serde_json::Value::String(r));
}
match call_operator_tool(&state, "revise_workflow", args).await {
Ok(result) => Json(result).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("revise_workflow failed: {e}"),
)
.into_response(),
}
}
pub async fn handle_architect_validate_yaml(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<ValidateYamlBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if body.yaml.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "yaml required").into_response();
}
let mut args = serde_json::Map::new();
args.insert(
"proposed_yaml".to_string(),
serde_json::Value::String(body.yaml),
);
args.insert(
"intent_summary".to_string(),
serde_json::Value::String(body.intent_summary.unwrap_or_default()),
);
if let Some(b) = body.base_yaml {
args.insert("base_yaml".to_string(), serde_json::Value::String(b));
}
match call_operator_tool(&state, "propose_workflow_yaml", args).await {
Ok(result) => Json(result).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("propose_workflow_yaml failed: {e}"),
)
.into_response(),
}
}
pub async fn handle_list_workflow_revisions(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<RevisionsQuery>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if q.workflow_kref.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "workflow_kref required").into_response();
}
let kref = normalize_kref(&q.workflow_kref);
let client = build_kumiho_client(&state);
match client.list_item_revisions(&kref).await {
Ok(revs) => {
let summary: Vec<RevisionSummary> = revs
.iter()
.map(|r| RevisionSummary {
kref: r.kref.clone(),
number: r.number,
created_at: r.created_at.clone(),
tags: r.tags.clone(),
metadata: r.metadata.clone(),
})
.collect();
Json(serde_json::json!({ "revisions": summary })).into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}
pub async fn handle_republish_revision(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RepublishBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if body.revision_kref.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "revision_kref required").into_response();
}
let revision_kref = normalize_kref(&body.revision_kref);
let client = build_kumiho_client(&state);
match client.tag_revision(&revision_kref, "published").await {
Ok(()) => {
super::api_workflows::invalidate_cache();
let workflow_kref = revision_kref
.split_once("?r=")
.map(|(base, _)| base.to_string())
.unwrap_or_else(|| revision_kref.clone());
let revision_number: i32 = revision_kref
.split_once("?r=")
.and_then(|(_, n)| n.split('&').next())
.and_then(|n| n.parse::<i32>().ok())
.unwrap_or(0);
let name = workflow_kref
.rsplit('/')
.next()
.map(|seg| {
seg.rsplit_once('.')
.map(|(n, _)| n)
.unwrap_or(seg)
.to_string()
})
.unwrap_or_default();
let published_at = chrono::Utc::now().to_rfc3339();
let payload = serde_json::json!({
"type": "workflow.revision.published",
"workflow_kref": workflow_kref,
"revision_kref": revision_kref.clone(),
"revision_number": revision_number,
"name": name,
"published_at": published_at,
"originating_session": serde_json::Value::Null,
});
if let Err(err) = state.event_tx.send(payload) {
tracing::debug!("workflow.revision.published broadcast skipped: {err}");
}
Json(serde_json::json!({
"ok": true,
"revision_kref": revision_kref,
}))
.into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_kref_adds_prefix() {
assert_eq!(
normalize_kref("Project/Workflows/foo"),
"kref://Project/Workflows/foo"
);
}
#[test]
fn normalize_kref_idempotent() {
assert_eq!(
normalize_kref("kref://Project/Workflows/foo"),
"kref://Project/Workflows/foo"
);
}
}