use super::AppState;
use super::api::require_auth;
use super::api_agents::build_kumiho_client;
use super::kumiho_client::{KumihoClient, KumihoError};
fn normalize_kref(raw: &str) -> String {
let stripped = raw.strip_prefix("kref://").unwrap_or(raw);
format!("kref://{stripped}")
}
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::OnceLock;
use std::time::Instant;
struct TeamCache {
teams: Vec<TeamResponse>,
include_deprecated: bool,
fetched_at: Instant,
}
static TEAM_CACHE: OnceLock<Mutex<Option<TeamCache>>> = OnceLock::new();
const CACHE_TTL_SECS: u64 = 30;
fn get_cached_teams(include_deprecated: bool) -> Option<Vec<TeamResponse>> {
let lock = TEAM_CACHE.get_or_init(|| Mutex::new(None));
let cache = lock.lock();
if let Some(ref c) = *cache {
if c.include_deprecated == include_deprecated
&& c.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS
{
return Some(c.teams.clone());
}
}
None
}
fn set_cached_teams(teams: &[TeamResponse], include_deprecated: bool) {
let lock = TEAM_CACHE.get_or_init(|| Mutex::new(None));
let mut cache = lock.lock();
*cache = Some(TeamCache {
teams: teams.to_vec(),
include_deprecated,
fetched_at: Instant::now(),
});
}
pub fn invalidate_team_cache() {
if let Some(lock) = TEAM_CACHE.get() {
let mut cache = lock.lock();
*cache = None;
}
}
const TEAM_SPACE_NAME: &str = "Teams";
fn team_project(state: &AppState) -> String {
state.config.lock().kumiho.harness_project.clone()
}
fn team_space_path(state: &AppState) -> String {
format!("/{}/{}", team_project(state), TEAM_SPACE_NAME)
}
#[derive(Deserialize)]
pub struct TeamListQuery {
#[serde(default)]
pub include_deprecated: bool,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[derive(Deserialize)]
pub struct CreateTeamBody {
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub members: Vec<String>, #[serde(default)]
pub edges: Vec<TeamEdgeBody>,
}
#[derive(Deserialize)]
pub struct TeamEdgeBody {
pub from_kref: String,
pub to_kref: String,
pub edge_type: String, }
#[derive(Deserialize)]
pub struct DeprecateBody {
pub kref: String,
pub deprecated: bool,
}
#[derive(Debug, Serialize, Clone)]
pub struct TeamResponse {
pub kref: String,
pub name: String,
pub description: String,
pub deprecated: bool,
pub created_at: Option<String>,
pub members: Vec<TeamMemberResponse>,
pub edges: Vec<TeamEdgeResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_count: Option<u32>,
}
#[derive(Debug, Serialize, Clone)]
pub struct TeamMemberResponse {
pub kref: String,
pub name: String,
pub role: String,
pub agent_type: String,
pub identity: String,
pub expertise: Vec<String>,
}
#[derive(Debug, Serialize, Clone)]
pub struct TeamEdgeResponse {
pub from_kref: String,
pub to_kref: String,
pub edge_type: String,
}
fn kumiho_err(e: KumihoError) -> (StatusCode, Json<serde_json::Value>) {
match &e {
KumihoError::Unreachable(_) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": format!("Kumiho service unavailable: {e}") })),
),
KumihoError::Api { status, body } => {
let code = if *status == 401 || *status == 403 {
StatusCode::BAD_GATEWAY
} else {
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
};
(
code,
Json(serde_json::json!({ "error": format!("Kumiho upstream: {body}") })),
)
}
KumihoError::Decode(msg) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": format!("Bad response from Kumiho: {msg}") })),
),
}
}
async fn build_team_response(
client: &KumihoClient,
bundle_kref: &str,
name: &str,
description: &str,
deprecated: bool,
created_at: Option<String>,
) -> Result<TeamResponse, KumihoError> {
let members_resp = match client.list_bundle_members(bundle_kref).await {
Ok(resp) => resp,
Err(_) => {
return Ok(TeamResponse {
kref: bundle_kref.to_string(),
name: name.to_string(),
description: description.to_string(),
deprecated,
created_at,
members: Vec::new(),
edges: Vec::new(),
member_count: None,
member_names: None,
edge_count: None,
});
}
};
let member_krefs: Vec<String> = members_resp
.members
.iter()
.map(|m| m.item_kref.clone())
.collect();
let rev_map = client
.batch_get_revisions(&member_krefs, "published")
.await
.unwrap_or_default();
let missing: Vec<String> = member_krefs
.iter()
.filter(|k| !rev_map.contains_key(*k))
.cloned()
.collect();
let latest_map = if !missing.is_empty() {
client
.batch_get_revisions(&missing, "latest")
.await
.unwrap_or_default()
} else {
std::collections::HashMap::new()
};
let mut member_responses = Vec::with_capacity(member_krefs.len());
for member_kref in &member_krefs {
let rev = rev_map
.get(member_kref)
.or_else(|| latest_map.get(member_kref));
let meta = rev.map(|r| &r.metadata);
let get =
|key: &str| -> String { meta.and_then(|m| m.get(key)).cloned().unwrap_or_default() };
let expertise_str = get("expertise");
let expertise: Vec<String> = if expertise_str.is_empty() {
Vec::new()
} else {
expertise_str
.split(',')
.map(|s| s.trim().to_string())
.collect()
};
let item_name = {
let name_from_kref = member_kref
.rsplit('/')
.next()
.and_then(|s| s.split('.').next())
.unwrap_or("")
.to_string();
if name_from_kref.is_empty() {
member_kref.clone()
} else {
name_from_kref
}
};
member_responses.push(TeamMemberResponse {
kref: member_kref.clone(),
name: item_name,
role: get("role"),
agent_type: get("agent_type"),
identity: get("identity"),
expertise,
});
}
let member_kref_set: HashSet<String> = member_krefs.iter().cloned().collect();
let edge_handles: Vec<_> = member_krefs
.iter()
.filter_map(|kref| {
let rev = rev_map.get(kref).or_else(|| latest_map.get(kref));
rev.map(|r| {
let client = client.clone();
let item_kref = kref.clone();
let rev_kref = r.kref.clone();
tokio::spawn(async move {
let edges = tokio::time::timeout(
std::time::Duration::from_secs(8),
client.list_edges(&rev_kref, None, Some("outgoing")),
)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
(item_kref, edges)
})
})
})
.collect();
let mut edge_results = Vec::new();
for handle in edge_handles {
if let Ok(result) = handle.await {
edge_results.push(result);
}
}
let mut edge_responses = Vec::new();
for (member_kref, edges) in edge_results {
for edge in edges {
let target_item_kref = edge
.target_kref
.split('?')
.next()
.unwrap_or(&edge.target_kref);
if target_item_kref == member_kref {
continue;
}
if member_kref_set.contains(target_item_kref)
|| member_krefs
.iter()
.any(|mk| edge.target_kref.starts_with(mk))
{
edge_responses.push(TeamEdgeResponse {
from_kref: member_kref.clone(),
to_kref: target_item_kref.to_string(),
edge_type: edge.edge_type,
});
}
}
}
Ok(TeamResponse {
kref: bundle_kref.to_string(),
name: name.to_string(),
description: description.to_string(),
deprecated,
created_at,
member_count: None,
member_names: None,
edge_count: None,
members: member_responses,
edges: edge_responses,
})
}
#[derive(Debug, Serialize)]
struct TeamValidationError {
code: String,
message: String,
}
fn validate_team_edges(members: &[String], edges: &[TeamEdgeBody]) -> Vec<TeamValidationError> {
let mut errors = Vec::new();
let member_set: HashSet<&str> = members.iter().map(|s| s.as_str()).collect();
for edge in edges {
if edge.from_kref == edge.to_kref {
errors.push(TeamValidationError {
code: "self_edge".into(),
message: format!(
"Self-referencing edge: {} ({}).",
&edge.from_kref, edge.edge_type
),
});
}
}
for edge in edges {
if !member_set.contains(edge.from_kref.as_str()) {
errors.push(TeamValidationError {
code: "dangling_ref".into(),
message: format!("Edge from_kref not a team member: {}", &edge.from_kref),
});
}
if !member_set.contains(edge.to_kref.as_str()) {
errors.push(TeamValidationError {
code: "dangling_ref".into(),
message: format!("Edge to_kref not a team member: {}", &edge.to_kref),
});
}
}
let mut depends_pairs: HashSet<(&str, &str)> = HashSet::new();
for edge in edges {
let et = edge.edge_type.to_uppercase();
if et == "DEPENDS_ON" {
let pair = (edge.from_kref.as_str(), edge.to_kref.as_str());
let reverse = (pair.1, pair.0);
if depends_pairs.contains(&reverse) {
errors.push(TeamValidationError {
code: "reciprocal_depends".into(),
message: format!(
"Reciprocal DEPENDS_ON between {} and {}. Pick one direction.",
&edge.from_kref, &edge.to_kref,
),
});
}
depends_pairs.insert(pair);
}
}
let execution_types: HashSet<&str> = ["DEPENDS_ON", "SUPPORTS", "FEEDS_INTO"]
.into_iter()
.collect();
let mut in_degree: HashMap<&str, usize> = HashMap::new();
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for m in members {
in_degree.insert(m.as_str(), 0);
adj.insert(m.as_str(), Vec::new());
}
for edge in edges {
let et = edge.edge_type.to_uppercase();
if !execution_types.contains(et.as_str()) {
continue;
}
let from = edge.from_kref.as_str();
let to = edge.to_kref.as_str();
if !member_set.contains(from) || !member_set.contains(to) {
continue;
}
if et == "DEPENDS_ON" {
adj.entry(to).or_default().push(from);
*in_degree.entry(from).or_default() += 1;
} else {
adj.entry(from).or_default().push(to);
*in_degree.entry(to).or_default() += 1;
}
}
let mut queue: VecDeque<&str> = in_degree
.iter()
.filter(|&(_, deg)| *deg == 0)
.map(|(&k, _)| k)
.collect();
let mut visited = 0usize;
while let Some(node) = queue.pop_front() {
visited += 1;
for &dep in adj.get(node).unwrap_or(&Vec::new()) {
if let Some(deg) = in_degree.get_mut(dep) {
*deg -= 1;
if *deg == 0 {
queue.push_back(dep);
}
}
}
}
if visited < members.len() {
let cycle_members: Vec<&str> = in_degree
.iter()
.filter(|&(_, deg)| *deg > 0)
.map(|(&k, _)| k)
.collect();
errors.push(TeamValidationError {
code: "cycle".into(),
message: format!(
"Dependency cycle detected among {} member(s). Break the cycle by removing or reversing an edge.",
cycle_members.len(),
),
});
}
errors
}
pub async fn handle_list_teams(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<TeamListQuery>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let client = build_kumiho_client(&state);
let project_name = team_project(&state);
let space_path = team_space_path(&state);
let items = match client
.list_teams_in(&space_path, query.include_deprecated)
.await
{
Ok(items) => items,
Err(ref e) if matches!(e, KumihoError::Api { status: 404, .. }) => {
let _ = client.ensure_project(&project_name).await;
let _ = client.ensure_space(&project_name, TEAM_SPACE_NAME).await;
return Json(
serde_json::json!({ "teams": [], "total_count": 0, "page": 1, "per_page": 9 }),
)
.into_response();
}
Err(ref e) if matches!(e, KumihoError::Api { status: 500, .. }) => {
tracing::warn!("Teams list failed (Kumiho 500, likely corrupted data): {e}");
return Json(serde_json::json!({ "teams": [], "total_count": 0, "page": 1, "per_page": 9, "warning": "Kumiho returned a server error." })).into_response();
}
Err(e) => return kumiho_err(e).into_response(),
};
let total_count = items.len() as u32;
let per_page = query.per_page.unwrap_or(9).min(50).max(1);
let page = query.page.unwrap_or(1).max(1);
let skip = ((page - 1) * per_page) as usize;
let teams: Vec<TeamResponse> = items
.iter()
.skip(skip)
.take(per_page as usize)
.map(|item| {
let member_count = item
.metadata
.get("member_count")
.and_then(|v| v.parse::<u32>().ok());
let member_names = item.metadata.get("member_names").map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
});
let edge_count = item
.metadata
.get("edge_count")
.and_then(|v| v.parse::<u32>().ok());
TeamResponse {
kref: item.kref.clone(),
name: item.item_name.clone(),
description: item
.metadata
.get("description")
.cloned()
.unwrap_or_default(),
deprecated: item.deprecated,
created_at: item.created_at.clone(),
members: Vec::new(),
edges: Vec::new(),
member_count,
member_names,
edge_count,
}
})
.collect();
Json(serde_json::json!({
"teams": teams,
"total_count": total_count,
"page": page,
"per_page": per_page,
}))
.into_response()
}
pub async fn handle_create_team(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CreateTeamBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let client = build_kumiho_client(&state);
let project_name = team_project(&state);
let space_path = team_space_path(&state);
let (proj_res, space_res) = tokio::join!(
client.ensure_project(&project_name),
client.ensure_space(&project_name, TEAM_SPACE_NAME),
);
if let Err(e) = proj_res {
return kumiho_err(e).into_response();
}
if let Err(e) = space_res {
return kumiho_err(e).into_response();
}
let validation_errors = validate_team_edges(&body.members, &body.edges);
if !validation_errors.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Team graph is invalid",
"validation_errors": validation_errors,
})),
)
.into_response();
}
let mut metadata = HashMap::new();
if let Some(ref desc) = body.description {
metadata.insert("description".to_string(), desc.clone());
}
let bundle = match client
.create_bundle(&space_path, &body.name, metadata)
.await
{
Ok(b) => b,
Err(e) => return kumiho_err(e).into_response(),
};
let member_handles: Vec<_> = body
.members
.iter()
.map(|member_kref| {
let client = client.clone();
let bundle_kref = bundle.kref.clone();
let member_kref = member_kref.clone();
tokio::spawn(async move {
client
.add_bundle_member(&bundle_kref, &member_kref, HashMap::new())
.await
})
})
.collect();
for handle in member_handles {
match handle.await {
Ok(Ok(_)) => {}
Ok(Err(e)) => return kumiho_err(e).into_response(),
Err(_) => return kumiho_err(KumihoError::Decode("task failed".into())).into_response(),
}
}
if !body.edges.is_empty() {
let edge_krefs: Vec<String> = body
.edges
.iter()
.flat_map(|e| [e.from_kref.clone(), e.to_kref.clone()])
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let rev_map = client
.batch_get_revisions(&edge_krefs, "published")
.await
.unwrap_or_default();
let missing: Vec<String> = edge_krefs
.iter()
.filter(|k| !rev_map.contains_key(*k))
.cloned()
.collect();
let latest_map = if !missing.is_empty() {
client
.batch_get_revisions(&missing, "latest")
.await
.unwrap_or_default()
} else {
HashMap::new()
};
let edge_handles: Vec<_> = body
.edges
.iter()
.filter(|edge| edge.from_kref != edge.to_kref)
.filter_map(|edge| {
let source = rev_map
.get(&edge.from_kref)
.or_else(|| latest_map.get(&edge.from_kref));
let target = rev_map
.get(&edge.to_kref)
.or_else(|| latest_map.get(&edge.to_kref));
match (source, target) {
(Some(s), Some(t)) => {
let client = client.clone();
let src_kref = s.kref.clone();
let tgt_kref = t.kref.clone();
let edge_type = edge.edge_type.clone();
Some(tokio::spawn(async move {
client
.create_edge(&src_kref, &tgt_kref, &edge_type, HashMap::new())
.await
}))
}
_ => None,
}
})
.collect();
for handle in edge_handles {
if let Ok(Err(e)) = handle.await {
return kumiho_err(e).into_response();
}
}
}
let member_names: Vec<String> = body
.members
.iter()
.map(|k| {
k.rsplit('/')
.next()
.and_then(|s| s.split('.').next())
.unwrap_or("")
.to_string()
})
.collect();
let mut summary_meta = HashMap::new();
summary_meta.insert("member_count".to_string(), body.members.len().to_string());
summary_meta.insert("member_names".to_string(), member_names.join(","));
summary_meta.insert("edge_count".to_string(), body.edges.len().to_string());
if let Some(ref desc) = body.description {
summary_meta.insert("description".to_string(), desc.clone());
}
if let Ok(rev) = client.create_revision(&bundle.kref, summary_meta).await {
let _ = client.tag_revision(&rev.kref, "published").await;
}
let description = body.description.as_deref().unwrap_or("");
match build_team_response(
&client,
&bundle.kref,
&bundle.item_name,
description,
bundle.deprecated,
bundle.created_at.clone(),
)
.await
{
Ok(team) => {
invalidate_team_cache();
(
StatusCode::CREATED,
Json(serde_json::json!({ "team": team })),
)
.into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}
pub async fn handle_get_team(
State(state): State<AppState>,
headers: HeaderMap,
Path(kref): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = normalize_kref(&kref);
let client = build_kumiho_client(&state);
let bundle = match client.get_bundle(&kref).await {
Ok(b) => b,
Err(e) => return kumiho_err(e).into_response(),
};
let description = bundle
.metadata
.get("description")
.cloned()
.unwrap_or_default();
match build_team_response(
&client,
&bundle.kref,
&bundle.item_name,
&description,
bundle.deprecated,
bundle.created_at.clone(),
)
.await
{
Ok(team) => Json(serde_json::json!({ "team": team })).into_response(),
Err(e) => kumiho_err(e).into_response(),
}
}
pub async fn handle_update_team(
State(state): State<AppState>,
headers: HeaderMap,
Path(kref): Path<String>,
Json(body): Json<CreateTeamBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = normalize_kref(&kref);
let client = build_kumiho_client(&state);
let bundle = match client.get_bundle(&kref).await {
Ok(b) => b,
Err(e) => return kumiho_err(e).into_response(),
};
let validation_errors = validate_team_edges(&body.members, &body.edges);
if !validation_errors.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Team graph is invalid",
"validation_errors": validation_errors,
})),
)
.into_response();
}
let member_names: Vec<String> = body
.members
.iter()
.map(|k| {
k.rsplit('/')
.next()
.and_then(|s| s.split('.').next())
.unwrap_or("")
.to_string()
})
.collect();
let mut metadata = HashMap::new();
metadata.insert("name".to_string(), body.name.clone());
if let Some(ref desc) = body.description {
metadata.insert("description".to_string(), desc.clone());
}
metadata.insert("member_count".to_string(), body.members.len().to_string());
metadata.insert("member_names".to_string(), member_names.join(","));
metadata.insert("edge_count".to_string(), body.edges.len().to_string());
if let Ok(rev) = client.create_revision(&kref, metadata).await {
let _ = client.tag_revision(&rev.kref, "published").await;
}
let current_members = match client.list_bundle_members(&kref).await {
Ok(m) => m,
Err(e) => return kumiho_err(e).into_response(),
};
let current_krefs: Vec<String> = current_members
.members
.iter()
.map(|m| m.item_kref.clone())
.collect();
let desired_krefs: Vec<String> = body.members.clone();
let to_add: Vec<_> = desired_krefs
.iter()
.filter(|k| !current_krefs.contains(k))
.cloned()
.collect();
let to_remove: Vec<_> = current_krefs
.iter()
.filter(|k| !desired_krefs.contains(k))
.cloned()
.collect();
let add_handles: Vec<_> = to_add
.iter()
.map(|member_kref| {
let client = client.clone();
let bundle_kref = kref.clone();
let member_kref = member_kref.clone();
tokio::spawn(async move {
client
.add_bundle_member(&bundle_kref, &member_kref, HashMap::new())
.await
})
})
.collect();
let remove_handles: Vec<_> = to_remove
.iter()
.map(|member_kref| {
let client = client.clone();
let bundle_kref = kref.clone();
let member_kref = member_kref.clone();
tokio::spawn(async move {
client
.remove_bundle_member(&bundle_kref, &member_kref)
.await
})
})
.collect();
for handle in add_handles {
if let Ok(Err(e)) = handle.await {
return kumiho_err(e).into_response();
}
}
for handle in remove_handles {
if let Ok(Err(e)) = handle.await {
return kumiho_err(e).into_response();
}
}
let all_member_krefs: Vec<String> = current_krefs
.iter()
.chain(desired_krefs.iter())
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let rev_map = client
.batch_get_revisions(&all_member_krefs, "published")
.await
.unwrap_or_default();
let missing: Vec<String> = all_member_krefs
.iter()
.filter(|k| !rev_map.contains_key(*k))
.cloned()
.collect();
let latest_map = if !missing.is_empty() {
client
.batch_get_revisions(&missing, "latest")
.await
.unwrap_or_default()
} else {
HashMap::new()
};
let delete_handles: Vec<_> = all_member_krefs
.iter()
.filter_map(|mk| {
let rev = rev_map.get(mk).or_else(|| latest_map.get(mk));
rev.map(|r| {
let client = client.clone();
let rev_kref = r.kref.clone();
tokio::spawn(async move {
if let Ok(edges) = client.list_edges(&rev_kref, None, Some("outgoing")).await {
for edge in edges {
let _ = client
.delete_edge(&edge.source_kref, &edge.target_kref, &edge.edge_type)
.await;
}
}
})
})
})
.collect();
for handle in delete_handles {
let _ = handle.await;
}
if !body.edges.is_empty() {
let edge_handles: Vec<_> = body
.edges
.iter()
.filter(|edge| edge.from_kref != edge.to_kref)
.filter_map(|edge| {
let source = rev_map
.get(&edge.from_kref)
.or_else(|| latest_map.get(&edge.from_kref));
let target = rev_map
.get(&edge.to_kref)
.or_else(|| latest_map.get(&edge.to_kref));
match (source, target) {
(Some(s), Some(t)) => {
let client = client.clone();
let src_kref = s.kref.clone();
let tgt_kref = t.kref.clone();
let edge_type = edge.edge_type.clone();
Some(tokio::spawn(async move {
client
.create_edge(&src_kref, &tgt_kref, &edge_type, HashMap::new())
.await
}))
}
_ => None,
}
})
.collect();
for handle in edge_handles {
if let Ok(Err(e)) = handle.await {
return kumiho_err(e).into_response();
}
}
}
let description = body.description.as_deref().unwrap_or("");
match build_team_response(
&client,
&kref,
&body.name,
description,
bundle.deprecated,
bundle.created_at.clone(),
)
.await
{
Ok(team) => {
invalidate_team_cache();
Json(serde_json::json!({ "team": team })).into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}
pub async fn handle_delete_team(
State(state): State<AppState>,
headers: HeaderMap,
Path(kref): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = normalize_kref(&kref);
let client = build_kumiho_client(&state);
if let Ok(members_resp) = client.list_bundle_members(&kref).await {
for member in &members_resp.members {
if let Ok(rev) = client.get_published_or_latest(&member.item_kref).await {
if let Ok(edges) = client.list_edges(&rev.kref, None, Some("outgoing")).await {
for edge in edges {
let _ = client
.delete_edge(&edge.source_kref, &edge.target_kref, &edge.edge_type)
.await;
}
}
}
}
for member in &members_resp.members {
let _ = client.remove_bundle_member(&kref, &member.item_kref).await;
}
}
match client.delete_bundle(&kref).await {
Ok(()) => {
invalidate_team_cache();
StatusCode::NO_CONTENT.into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}
pub async fn handle_deprecate_team(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<DeprecateBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = body.kref.clone();
let client = build_kumiho_client(&state);
match client.deprecate_team(&kref, body.deprecated).await {
Ok(()) => {
invalidate_team_cache();
Json(serde_json::json!({ "success": true })).into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}