From 93443e2ff176d5f5bfdd3437b5d3ce52996b3c3b Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 12 May 2026 21:04:33 +0000 Subject: [PATCH] huskies: merge 921 --- frontend/src/api/gateway.ts | 23 +++++++++- server/src/http/gateway/mcp.rs | 8 ++-- server/src/service/gateway/io.rs | 76 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts index ac68bc99..cd91305f 100644 --- a/frontend/src/api/gateway.ts +++ b/frontend/src/api/gateway.ts @@ -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; + 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)) { + if (typeof proj !== "object" || proj === null) return false; + const p = proj as Record; + if (!Array.isArray(p.active) && typeof p.error !== "string") return false; + } + return true; +} + async function gatewayRequest( 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. diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index dce5b9b7..45ba0135 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -547,8 +547,9 @@ fn handle_agents_list_tool(id: Option) -> 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) -> JsonRpcResponse { let project_urls: BTreeMap = state .projects @@ -558,8 +559,7 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option) -> 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 })) diff --git a/server/src/service/gateway/io.rs b/server/src/service/gateway/io.rs index f7a39d57..705f30df 100644 --- a/server/src/service/gateway/io.rs +++ b/server/src/service/gateway/io.rs @@ -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::().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::(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, + client: &Client, +) -> BTreeMap { + 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,