huskies: merge 921
This commit is contained in:
@@ -54,6 +54,21 @@ export interface ServerMode {
|
||||
mode: "gateway" | "standard";
|
||||
}
|
||||
|
||||
/// Type guard: verify that an unknown value has the AllProjectsPipeline shape.
|
||||
/// Prevents silent "no active stories" when the backend response shape drifts.
|
||||
function isAllProjectsPipeline(value: unknown): value is AllProjectsPipeline {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v.active !== "string") return false;
|
||||
if (typeof v.projects !== "object" || v.projects === null) return false;
|
||||
for (const proj of Object.values(v.projects as Record<string, unknown>)) {
|
||||
if (typeof proj !== "object" || proj === null) return false;
|
||||
const p = proj as Record<string, unknown>;
|
||||
if (!Array.isArray(p.active) && typeof p.error !== "string") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function gatewayRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -164,11 +179,15 @@ export const gatewayApi = {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
const rpc = await res.json() as { result?: AllProjectsPipeline; error?: { message: string } };
|
||||
const rpc = await res.json() as { result?: unknown; error?: { message: string } };
|
||||
if (rpc.error) {
|
||||
throw new Error(rpc.error.message);
|
||||
}
|
||||
return rpc.result!;
|
||||
const result = rpc.result;
|
||||
if (!isAllProjectsPipeline(result)) {
|
||||
throw new Error("pipeline.get returned unexpected shape");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/// Switch the active project via the MCP switch_project tool.
|
||||
|
||||
@@ -547,8 +547,9 @@ fn handle_agents_list_tool(id: Option<Value>) -> JsonRpcResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle the `pipeline.get` read-RPC — returns the same shape as the old
|
||||
/// `GET /api/gateway/pipeline` endpoint: `{ "active": "...", "projects": {...} }`.
|
||||
/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
|
||||
/// shape expected by the gateway web UI:
|
||||
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
|
||||
async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
|
||||
let project_urls: BTreeMap<String, String> = state
|
||||
.projects
|
||||
@@ -558,8 +559,7 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpc
|
||||
.map(|(n, e)| (n.clone(), e.url.clone()))
|
||||
.collect();
|
||||
|
||||
let results =
|
||||
gateway::io::fetch_all_project_pipeline_statuses(&project_urls, &state.client).await;
|
||||
let results = gateway::io::fetch_all_project_pipeline_items(&project_urls, &state.client).await;
|
||||
let active = state.active_project.read().await.clone();
|
||||
|
||||
JsonRpcResponse::success(id, json!({ "active": active, "projects": results }))
|
||||
|
||||
@@ -215,6 +215,82 @@ pub async fn fetch_all_project_pipeline_statuses(
|
||||
join_all(futures).await.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Fetch pipeline items for a single project URL.
|
||||
///
|
||||
/// Returns `{ "active": [...], "backlog_count": N }` preserving individual
|
||||
/// story items so the gateway UI can render them. On error returns
|
||||
/// `{ "error": "..." }`. This is distinct from
|
||||
/// `fetch_one_project_pipeline_status` which discards items and returns
|
||||
/// aggregated counts.
|
||||
pub async fn fetch_one_project_pipeline_items(url: &str, client: &Client) -> Value {
|
||||
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
|
||||
let rpc_body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_pipeline_status",
|
||||
"arguments": {}
|
||||
}
|
||||
});
|
||||
|
||||
match client.post(&mcp_url).json(&rpc_body).send().await {
|
||||
Ok(resp) => match resp.json::<Value>().await {
|
||||
Ok(upstream) => {
|
||||
if let Some(text) = upstream
|
||||
.get("result")
|
||||
.and_then(|r| r.get("content"))
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
match serde_json::from_str::<Value>(text) {
|
||||
Ok(pipeline) => {
|
||||
let active = pipeline.get("active").cloned().unwrap_or(json!([]));
|
||||
let backlog_count = pipeline
|
||||
.get("backlog_count")
|
||||
.and_then(|n| n.as_u64())
|
||||
.unwrap_or(0);
|
||||
json!({ "active": active, "backlog_count": backlog_count })
|
||||
}
|
||||
Err(_) => json!({ "error": "invalid pipeline JSON" }),
|
||||
}
|
||||
} else {
|
||||
json!({ "error": "unexpected response shape" })
|
||||
}
|
||||
}
|
||||
Err(e) => json!({ "error": format!("invalid response: {e}") }),
|
||||
},
|
||||
Err(e) => json!({ "error": format!("unreachable: {e}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch pipeline items from every registered project URL in parallel.
|
||||
///
|
||||
/// Returns per-project `{ "active": [...], "backlog_count": N }` objects
|
||||
/// suitable for the gateway web UI.
|
||||
pub async fn fetch_all_project_pipeline_items(
|
||||
project_urls: &BTreeMap<String, String>,
|
||||
client: &Client,
|
||||
) -> BTreeMap<String, Value> {
|
||||
use futures::future::join_all;
|
||||
|
||||
let futures: Vec<_> = project_urls
|
||||
.iter()
|
||||
.map(|(name, url)| {
|
||||
let name = name.clone();
|
||||
let url = url.clone();
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let result = fetch_one_project_pipeline_items(&url, &client).await;
|
||||
(name, result)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
join_all(futures).await.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Fetch the pipeline status from a single project for the `gateway_status` tool.
|
||||
pub async fn fetch_pipeline_status_for_project(
|
||||
client: &Client,
|
||||
|
||||
Reference in New Issue
Block a user