From 73c3d41c8755637a839f2158b9a41e9ef007865b Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Wed, 7 Oct 2026 13:14:33 +0100 Subject: [PATCH] fix(dashboard,server): restore code change ledger UI and enrich model schema parity and pagination --- server/src/api/setup.rs | 119 +++++++++++++++++++++++++++++++++++ server/src/dashboard.html | 4 +- server/src/dashboard.js | 33 ++++++++-- server/src/dashboard.ts | 36 +++++++++-- server/src/handlers/meta.rs | 16 ++--- server/src/handlers/notes.rs | 7 ++- server/src/handlers/tasks.rs | 8 ++- server/src/tools.rs | 16 +++++ server/tests/parity_test.rs | 77 +++++++++++++++++++++++ 9 files changed, 297 insertions(+), 19 deletions(-) diff --git a/server/src/api/setup.rs b/server/src/api/setup.rs index 467c518..21fd20c 100644 --- a/server/src/api/setup.rs +++ b/server/src/api/setup.rs @@ -235,6 +235,26 @@ pub fn create_router(app_state: Arc) -> Router { } }), ) + .route( + "/api/ledger", + get({ + let state_clone = app_state.handler.state.clone(); + move |axum::extract::Query(params): axum::extract::Query< + std::collections::HashMap, + >| async move { + let limit = params + .get("limit") + .and_then(|l| l.parse::().ok()) + .unwrap_or(100); + + let json = state_clone.code.ledger.read_with(|items| { + let recent: Vec<_> = items.iter().rev().take(limit).collect(); + serde_json::to_string(&recent).unwrap_or_else(|_| "[]".to_string()) + }); + ([(axum::http::header::CONTENT_TYPE, "application/json")], json) + } + }), + ) .route( "/api/tech_debts", get({ @@ -265,6 +285,26 @@ pub fn create_router(app_state: Arc) -> Router { } }), ) + .route( + "/api/session_summaries", + get({ + let state_clone = app_state.handler.state.clone(); + move |axum::extract::Query(params): axum::extract::Query< + std::collections::HashMap, + >| async move { + let limit = params + .get("limit") + .and_then(|l| l.parse::().ok()) + .unwrap_or(100); + + let json = state_clone.telemetry.session_summaries.read_with(|items| { + let recent: Vec<_> = items.iter().rev().take(limit).collect(); + serde_json::to_string(&recent).unwrap_or_else(|_| "[]".to_string()) + }); + ([(axum::http::header::CONTENT_TYPE, "application/json")], json) + } + }), + ) .route( "/api/milestones", get({ @@ -789,4 +829,83 @@ mod tests { assert!(js_str.contains("/api/activity/stream")); assert!(js_str.contains("parseActivityPayload")); } + + #[tokio::test] + async fn test_ledger_endpoint() { + let (app, app_state, _dir) = setup_app().await; + + app_state.handler.state.code.ledger.modify(|l| { + l.push(crate::models::CodeChange { + timestamp: 1728000000, + file_path: "src/main.rs".to_string(), + description: "Test commit 1".to_string(), + git_commit: Some("abc1234".to_string()), + ..Default::default() + }); + l.push(crate::models::CodeChange { + timestamp: 1728000100, + file_path: "src/lib.rs".to_string(), + description: "Test commit 2".to_string(), + git_commit: Some("def5678".to_string()), + ..Default::default() + }); + }); + + let request = Request::builder() + .uri("/api/ledger?limit=1") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let changes: Vec = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].description, "Test commit 2"); + } + + #[tokio::test] + async fn test_session_summaries_endpoint() { + let (app, app_state, _dir) = setup_app().await; + + app_state + .handler + .state + .telemetry + .session_summaries + .modify(|summaries| { + summaries.push(crate::models::SessionSummary { + session_id: Some("session-1".to_string()), + summary: "Session 1 summary".to_string(), + namespace: "test".to_string(), + timestamp: 100, + ..Default::default() + }); + summaries.push(crate::models::SessionSummary { + session_id: Some("session-2".to_string()), + summary: "Session 2 summary".to_string(), + namespace: "test".to_string(), + timestamp: 200, + ..Default::default() + }); + }); + + let request = Request::builder() + .uri("/api/session_summaries?limit=1") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let summaries: Vec = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].summary, "Session 2 summary"); + } } diff --git a/server/src/dashboard.html b/server/src/dashboard.html index 72f2429..462a6cf 100644 --- a/server/src/dashboard.html +++ b/server/src/dashboard.html @@ -565,8 +565,10 @@
-

Handoff Memos & Session Summaries

+

Handoff Memos

+

Session Summaries

+

Project Milestones

diff --git a/server/src/dashboard.js b/server/src/dashboard.js index 3dda126..03dade1 100644 --- a/server/src/dashboard.js +++ b/server/src/dashboard.js @@ -7,8 +7,9 @@ function switchTab(tabId, btn) { const targetTab = document.getElementById(tabId); if (targetTab) targetTab.classList.add("active"); - if (btn) - btn.classList.add("active"); + const targetBtn = btn || document.querySelector(`.tab-button[onclick*="'${tabId}'"]`); + if (targetBtn) + targetBtn.classList.add("active"); switch (tabId) { case "graph-tab": if (network) { @@ -334,7 +335,10 @@ function buildTaskTreeHTML(tasks, parentId, depth = 0) { if (isCancelled) { html += `
[CANCELLED]
`; } - html += `${t.title}${t.description}`; + html += `${escapeHtml(t.title)}`; + if (t.description) { + html += `
${escapeHtml(t.description)}
`; + } const criteria = t.acceptance_criteria || []; if (criteria.length > 0) { html += `
    `; @@ -420,7 +424,7 @@ async function handleSearch(e) { return; } container.innerHTML = data.results.map((r) => ` -
    +
    ${highlightText(r.title, q)} @@ -726,10 +730,16 @@ function setupWS() { }; } async function loadGenericList(endpoint, containerId, formatter) { + const container = document.getElementById(containerId); try { const res = await fetch(endpoint); + if (!res.ok) { + if (container) { + container.innerHTML = `
    ⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})
    `; + } + return; + } const data = await res.json(); - const container = document.getElementById(containerId); if (!container) return; if (!data || data.length === 0) { @@ -745,6 +755,9 @@ async function loadGenericList(endpoint, containerId, formatter) { }).join(""); } catch (err) { console.error(`Failed to load ${endpoint}`, err); + if (container) { + container.innerHTML = `
    ⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})
    `; + } } } function loadLedger() { @@ -832,6 +845,16 @@ function loadMemos() { Memo from ${item.author || "System"}
    ${item.content || item.summary || ""}
    `); + loadGenericList("/api/session_summaries", "summaries-container", (item) => ` +
    + Session ${item.session_id ? escapeHtml(item.session_id) : "Summary"} + ${escapeHtml(item.namespace || "global")} +
    + ${item.git_commit || item.git_branch ? `
    ${escapeHtml(item.git_branch || "")} @ ${escapeHtml((item.git_commit || "").substring(0, 8))}
    ` : ""} +
    ${escapeHtml(item.summary || "")}
    + ${item.tasks_completed && item.tasks_completed.length > 0 ? `
    Tasks Completed:
      ${item.tasks_completed.map((t) => `
    • ${escapeHtml(t)}
    • `).join("")}
    ` : ""} + ${item.next_steps && item.next_steps.length > 0 ? `
    Next Steps:
      ${item.next_steps.map((s) => `
    • ${escapeHtml(s)}
    • `).join("")}
    ` : ""} + `); loadGenericList("/api/milestones", "milestones-container", (item) => ` ${item.title || item.name}
    ${item.description || ""}
    diff --git a/server/src/dashboard.ts b/server/src/dashboard.ts index f23b4f9..0871a77 100644 --- a/server/src/dashboard.ts +++ b/server/src/dashboard.ts @@ -97,7 +97,8 @@ function switchTab(tabId: string, btn?: HTMLElement | null): void { const targetTab = document.getElementById(tabId); if (targetTab) targetTab.classList.add("active"); - if (btn) btn.classList.add("active"); + const targetBtn = btn || document.querySelector(`.tab-button[onclick*="'${tabId}'"]`); + if (targetBtn) targetBtn.classList.add("active"); switch (tabId) { case "graph-tab": @@ -497,7 +498,10 @@ function buildTaskTreeHTML( html += `
    [CANCELLED]
    `; } - html += `${t.title}${t.description}`; + html += `${escapeHtml(t.title)}`; + if (t.description) { + html += `
    ${escapeHtml(t.description)}
    `; + } const criteria = t.acceptance_criteria || []; if (criteria.length > 0) { @@ -598,7 +602,7 @@ async function handleSearch(e: KeyboardEvent): Promise { container.innerHTML = data.results .map( (r: SearchResultItem) => ` -
    +
    ${highlightText(r.title, q)} @@ -965,10 +969,16 @@ async function loadGenericList( containerId: string, formatter: (item: any) => string, ): Promise { + const container = document.getElementById(containerId); try { const res = await fetch(endpoint); + if (!res.ok) { + if (container) { + container.innerHTML = `
    ⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})
    `; + } + return; + } const data = await res.json(); - const container = document.getElementById(containerId); if (!container) return; if (!data || data.length === 0) { @@ -990,6 +1000,9 @@ async function loadGenericList( .join(""); } catch (err) { console.error(`Failed to load ${endpoint}`, err); + if (container) { + container.innerHTML = `
    ⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})
    `; + } } } @@ -1122,6 +1135,21 @@ function loadMemos(): void { `, ); + loadGenericList( + "/api/session_summaries", + "summaries-container", + (item) => ` +
    + Session ${item.session_id ? escapeHtml(item.session_id) : "Summary"} + ${escapeHtml(item.namespace || "global")} +
    + ${item.git_commit || item.git_branch ? `
    ${escapeHtml(item.git_branch || "")} @ ${escapeHtml((item.git_commit || "").substring(0, 8))}
    ` : ""} +
    ${escapeHtml(item.summary || "")}
    + ${item.tasks_completed && item.tasks_completed.length > 0 ? `
    Tasks Completed:
      ${item.tasks_completed.map((t: string) => `
    • ${escapeHtml(t)}
    • `).join("")}
    ` : ""} + ${item.next_steps && item.next_steps.length > 0 ? `
    Next Steps:
      ${item.next_steps.map((s: string) => `
    • ${escapeHtml(s)}
    • `).join("")}
    ` : ""} + `, + ); + loadGenericList( "/api/milestones", "milestones-container", diff --git a/server/src/handlers/meta.rs b/server/src/handlers/meta.rs index 4c9d052..52ebf4b 100644 --- a/server/src/handlers/meta.rs +++ b/server/src/handlers/meta.rs @@ -323,14 +323,15 @@ impl McpTool for QueryRecentChangesHandler { } async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: QueryRecentChangesTool = - serde_json::from_value(args).unwrap_or(QueryRecentChangesTool { - namespace: None, - repo_name: None, - vcs_type: None, - limit: None, - }); + let req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool { + namespace: None, + repo_name: None, + vcs_type: None, + limit: None, + offset: None, + }); let limit = req.limit.unwrap_or(50); + let offset = req.offset.unwrap_or(0); let data = state.code.ledger.read_with(|l| { let filtered: Vec<_> = l .iter() @@ -357,6 +358,7 @@ impl McpTool for QueryRecentChangesHandler { } true }) + .skip(offset) .take(limit) .cloned() .collect(); diff --git a/server/src/handlers/notes.rs b/server/src/handlers/notes.rs index 1bd3cd6..6ec454d 100644 --- a/server/src/handlers/notes.rs +++ b/server/src/handlers/notes.rs @@ -100,7 +100,12 @@ impl McpTool for AddSessionSummaryHandler { namespace: req.namespace, timestamp: crate::handlers::utils::now_secs(), expires_at: None, - ..Default::default() + session_id: req.session_id, + repo_name: req.repo_name, + git_branch: req.git_branch, + git_commit: req.git_commit, + tasks_completed: req.tasks_completed.unwrap_or_default(), + next_steps: req.next_steps.unwrap_or_default(), }); if summaries.len() > 200 { summaries.remove(0); diff --git a/server/src/handlers/tasks.rs b/server/src/handlers/tasks.rs index fdefb8f..c22d252 100644 --- a/server/src/handlers/tasks.rs +++ b/server/src/handlers/tasks.rs @@ -266,8 +266,9 @@ impl McpTool for TasksHandler { } TaskAction::List => { let level = req.summary_level.as_deref().unwrap_or("detailed"); + let offset = req.offset.unwrap_or(0); let data = state.project.tasks.read_with(|tasks| { - let mut filtered: Vec<_> = tasks + let matching: Vec<_> = tasks .iter() .filter(|t| { let status_match = match req.status.as_deref() { @@ -281,6 +282,11 @@ impl McpTool for TasksHandler { }; status_match && branch_match }) + .collect(); + + let mut filtered: Vec<_> = matching + .into_iter() + .skip(offset) .map(|t| match level { "compact" => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status }), "full" => serde_json::to_value(t).unwrap_or_default(), diff --git a/server/src/tools.rs b/server/src/tools.rs index b14d345..35a983e 100644 --- a/server/src/tools.rs +++ b/server/src/tools.rs @@ -138,6 +138,8 @@ pub struct QueryRecentChangesTool { pub vcs_type: Option, /// Maximum number of records to return (defaults to 50). pub limit: Option, + /// Optional offset for pagination. + pub offset: Option, } /// Generate a visual representation of the knowledge graph. @@ -255,6 +257,18 @@ pub struct AddSessionSummaryTool { /// The namespace to add the summary to. #[serde(default = "crate::models::default_namespace")] pub namespace: String, + /// Optional conversation or session ID. + pub session_id: Option, + /// Optional repository name. + pub repo_name: Option, + /// Optional git branch. + pub git_branch: Option, + /// Optional git commit hash. + pub git_commit: Option, + /// Optional list of completed task IDs or task summaries in this session. + pub tasks_completed: Option>, + /// Optional list of recommended next steps for incoming agents. + pub next_steps: Option>, } /// Generate a standup report for a specific time window. #[derive(Debug, Deserialize, Serialize, JsonSchema)] @@ -683,6 +697,8 @@ pub struct TasksTool { /// Optional assigned subagent role or identifier. pub assigned_agent: Option, /// Optional verification command to validate criteria completion. + /// Optional pagination offset for 'list'. + pub offset: Option, pub verification_command: Option, } diff --git a/server/tests/parity_test.rs b/server/tests/parity_test.rs index 2797559..c6797e5 100644 --- a/server/tests/parity_test.rs +++ b/server/tests/parity_test.rs @@ -78,3 +78,80 @@ fn test_eager_tools_parity() { } } } + +#[test] +fn test_dashboard_route_parity() { + let dashboard_path = if std::path::Path::new("src/dashboard.ts").exists() { + "src/dashboard.ts" + } else { + "server/src/dashboard.ts" + }; + + let setup_path = if std::path::Path::new("src/api/setup.rs").exists() { + "src/api/setup.rs" + } else { + "server/src/api/setup.rs" + }; + + let dashboard_content = std::fs::read_to_string(dashboard_path) + .expect("Failed to read dashboard.ts"); + let setup_content = std::fs::read_to_string(setup_path) + .expect("Failed to read setup.rs"); + + // 1. Extract routes from setup.rs + let mut registered_routes = HashSet::new(); + for part in setup_content.split(".route(") { + let trimmed = part.trim_start(); + if trimmed.starts_with('"') { + if let Some(end_quote) = trimmed[1..].find('"') { + let route = &trimmed[1..=end_quote]; + registered_routes.insert(route.to_string()); + } + } + } + + assert!(!registered_routes.is_empty(), "No routes found in setup.rs"); + + // 2. Extract /api/ endpoints called in dashboard.ts + let mut frontend_endpoints = HashSet::new(); + let mut cursor = 0; + while let Some(idx) = dashboard_content[cursor..].find("/api/") { + let start = cursor + idx; + let rest = &dashboard_content[start..]; + let quote_char = dashboard_content[..start].chars().last(); + let mut endpoint = String::new(); + if let Some(q) = quote_char { + if q == '`' || q == '"' || q == '\'' { + if let Some(close_idx) = rest.find(q) { + let full_call = &rest[..close_idx]; + let clean = if let Some(q_idx) = full_call.find('?') { + &full_call[..q_idx] + } else { + full_call + }; + let normalized = clean.replace("${id}", "{id}"); + endpoint = normalized.to_string(); + } + } + } + if endpoint.is_empty() { + let end_idx = rest.find(['"', '`', '\'', ' ', '\n', '?']) + .unwrap_or(rest.len()); + endpoint = rest[..end_idx].to_string(); + } + + if endpoint.starts_with("/api/") { + frontend_endpoints.insert(endpoint); + } + cursor = start + 5; + } + + assert!(!frontend_endpoints.is_empty(), "No frontend endpoints found in dashboard.ts"); + + for endpoint in &frontend_endpoints { + assert!( + registered_routes.contains(endpoint), + "Frontend endpoint '{endpoint}' is not registered in setup.rs! Parity violation detected." + ); + } +}