fix(dashboard,server): restore code change ledger UI and enrich model schema parity and pagination
This commit is contained in:
1 parent
3b146f91c2
commit
73c3d41c87
9 files changed
+297
-19
No files matched your search
@@ -235,6 +235,26 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ledger",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Query(params): axum::extract::Query<
|
||||||
|
std::collections::HashMap<String, String>,
|
||||||
|
>| async move {
|
||||||
|
let limit = params
|
||||||
|
.get("limit")
|
||||||
|
.and_then(|l| l.parse::<usize>().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(
|
.route(
|
||||||
"/api/tech_debts",
|
"/api/tech_debts",
|
||||||
get({
|
get({
|
||||||
@@ -265,6 +285,26 @@ pub fn create_router(app_state: Arc<AppState>) -> 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<String, String>,
|
||||||
|
>| async move {
|
||||||
|
let limit = params
|
||||||
|
.get("limit")
|
||||||
|
.and_then(|l| l.parse::<usize>().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(
|
.route(
|
||||||
"/api/milestones",
|
"/api/milestones",
|
||||||
get({
|
get({
|
||||||
@@ -789,4 +829,83 @@ mod tests {
|
|||||||
assert!(js_str.contains("/api/activity/stream"));
|
assert!(js_str.contains("/api/activity/stream"));
|
||||||
assert!(js_str.contains("parseActivityPayload"));
|
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<crate::models::CodeChange> = 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<crate::models::SessionSummary> = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
assert_eq!(summaries.len(), 1);
|
||||||
|
assert_eq!(summaries[0].summary, "Session 2 summary");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -565,8 +565,10 @@
|
|||||||
|
|
||||||
<div id="memos-tab" class="tab-content">
|
<div id="memos-tab" class="tab-content">
|
||||||
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
||||||
<h2>Handoff Memos & Session Summaries</h2>
|
<h2>Handoff Memos</h2>
|
||||||
<div id="memos-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
<div id="memos-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Session Summaries</h2>
|
||||||
|
<div id="summaries-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
<h2>Project Milestones</h2>
|
<h2>Project Milestones</h2>
|
||||||
<div id="milestones-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
<div id="milestones-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+28
-5
@@ -7,8 +7,9 @@ function switchTab(tabId, btn) {
|
|||||||
const targetTab = document.getElementById(tabId);
|
const targetTab = document.getElementById(tabId);
|
||||||
if (targetTab)
|
if (targetTab)
|
||||||
targetTab.classList.add("active");
|
targetTab.classList.add("active");
|
||||||
if (btn)
|
const targetBtn = btn || document.querySelector(`.tab-button[onclick*="'${tabId}'"]`);
|
||||||
btn.classList.add("active");
|
if (targetBtn)
|
||||||
|
targetBtn.classList.add("active");
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case "graph-tab":
|
case "graph-tab":
|
||||||
if (network) {
|
if (network) {
|
||||||
@@ -334,7 +335,10 @@ function buildTaskTreeHTML(tasks, parentId, depth = 0) {
|
|||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
|
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
|
||||||
}
|
}
|
||||||
html += `<strong>${t.title}</strong>${t.description}`;
|
html += `<strong>${escapeHtml(t.title)}</strong>`;
|
||||||
|
if (t.description) {
|
||||||
|
html += `<div style="margin-top:5px; font-size:0.9em; color:var(--text-secondary); line-height:1.4;">${escapeHtml(t.description)}</div>`;
|
||||||
|
}
|
||||||
const criteria = t.acceptance_criteria || [];
|
const criteria = t.acceptance_criteria || [];
|
||||||
if (criteria.length > 0) {
|
if (criteria.length > 0) {
|
||||||
html += `<ul style="margin:8px 0 0 0; padding-left:20px; font-size: 0.9em; color: var(--text-secondary);">`;
|
html += `<ul style="margin:8px 0 0 0; padding-left:20px; font-size: 0.9em; color: var(--text-secondary);">`;
|
||||||
@@ -420,7 +424,7 @@ async function handleSearch(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = data.results.map((r) => `
|
container.innerHTML = data.results.map((r) => `
|
||||||
<div class="search-result-item" onclick="if(rawEntities['${r.id}']){ showInspector('${r.id}'); network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); }" style="cursor:pointer;">
|
<div class="search-result-item" onclick="switchTab('graph-tab'); if(rawEntities['${r.id}']){ showInspector('${r.id}'); if(network){ network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); } }" style="cursor:pointer;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||||
<div>
|
<div>
|
||||||
<strong>${highlightText(r.title, q)}</strong>
|
<strong>${highlightText(r.title, q)}</strong>
|
||||||
@@ -726,10 +730,16 @@ function setupWS() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function loadGenericList(endpoint, containerId, formatter) {
|
async function loadGenericList(endpoint, containerId, formatter) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint);
|
const res = await fetch(endpoint);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})</div>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
if (!container)
|
if (!container)
|
||||||
return;
|
return;
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
@@ -745,6 +755,9 @@ async function loadGenericList(endpoint, containerId, formatter) {
|
|||||||
}).join("");
|
}).join("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to load ${endpoint}`, err);
|
console.error(`Failed to load ${endpoint}`, err);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function loadLedger() {
|
function loadLedger() {
|
||||||
@@ -832,6 +845,16 @@ function loadMemos() {
|
|||||||
<strong>Memo from ${item.author || "System"}</strong>
|
<strong>Memo from ${item.author || "System"}</strong>
|
||||||
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4;">${item.content || item.summary || ""}</div>
|
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4;">${item.content || item.summary || ""}</div>
|
||||||
`);
|
`);
|
||||||
|
loadGenericList("/api/session_summaries", "summaries-container", (item) => `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<strong>Session ${item.session_id ? escapeHtml(item.session_id) : "Summary"}</strong>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "global")}</span>
|
||||||
|
</div>
|
||||||
|
${item.git_commit || item.git_branch ? `<div style="font-size:0.8em; color:var(--text-secondary); margin-top:3px; font-family:monospace;">${escapeHtml(item.git_branch || "")} @ ${escapeHtml((item.git_commit || "").substring(0, 8))}</div>` : ""}
|
||||||
|
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4; margin-top:8px;">${escapeHtml(item.summary || "")}</div>
|
||||||
|
${item.tasks_completed && item.tasks_completed.length > 0 ? `<div style="margin-top:8px; font-size:0.85em;"><strong>Tasks Completed:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.tasks_completed.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul></div>` : ""}
|
||||||
|
${item.next_steps && item.next_steps.length > 0 ? `<div style="margin-top:6px; font-size:0.85em;"><strong>Next Steps:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.next_steps.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul></div>` : ""}
|
||||||
|
`);
|
||||||
loadGenericList("/api/milestones", "milestones-container", (item) => `
|
loadGenericList("/api/milestones", "milestones-container", (item) => `
|
||||||
<strong>${item.title || item.name}</strong>
|
<strong>${item.title || item.name}</strong>
|
||||||
<div style="margin-top:5px; font-size:0.85em;">${item.description || ""}</div>
|
<div style="margin-top:5px; font-size:0.85em;">${item.description || ""}</div>
|
||||||
|
|||||||
+32
-4
@@ -97,7 +97,8 @@ function switchTab(tabId: string, btn?: HTMLElement | null): void {
|
|||||||
|
|
||||||
const targetTab = document.getElementById(tabId);
|
const targetTab = document.getElementById(tabId);
|
||||||
if (targetTab) targetTab.classList.add("active");
|
if (targetTab) targetTab.classList.add("active");
|
||||||
if (btn) btn.classList.add("active");
|
const targetBtn = btn || document.querySelector<HTMLElement>(`.tab-button[onclick*="'${tabId}'"]`);
|
||||||
|
if (targetBtn) targetBtn.classList.add("active");
|
||||||
|
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case "graph-tab":
|
case "graph-tab":
|
||||||
@@ -497,7 +498,10 @@ function buildTaskTreeHTML(
|
|||||||
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
|
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<strong>${t.title}</strong>${t.description}`;
|
html += `<strong>${escapeHtml(t.title)}</strong>`;
|
||||||
|
if (t.description) {
|
||||||
|
html += `<div style="margin-top:5px; font-size:0.9em; color:var(--text-secondary); line-height:1.4;">${escapeHtml(t.description)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const criteria = t.acceptance_criteria || [];
|
const criteria = t.acceptance_criteria || [];
|
||||||
if (criteria.length > 0) {
|
if (criteria.length > 0) {
|
||||||
@@ -598,7 +602,7 @@ async function handleSearch(e: KeyboardEvent): Promise<void> {
|
|||||||
container.innerHTML = data.results
|
container.innerHTML = data.results
|
||||||
.map(
|
.map(
|
||||||
(r: SearchResultItem) => `
|
(r: SearchResultItem) => `
|
||||||
<div class="search-result-item" onclick="if(rawEntities['${r.id}']){ showInspector('${r.id}'); network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); }" style="cursor:pointer;">
|
<div class="search-result-item" onclick="switchTab('graph-tab'); if(rawEntities['${r.id}']){ showInspector('${r.id}'); if(network){ network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); } }" style="cursor:pointer;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||||
<div>
|
<div>
|
||||||
<strong>${highlightText(r.title, q)}</strong>
|
<strong>${highlightText(r.title, q)}</strong>
|
||||||
@@ -965,10 +969,16 @@ async function loadGenericList(
|
|||||||
containerId: string,
|
containerId: string,
|
||||||
formatter: (item: any) => string,
|
formatter: (item: any) => string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint);
|
const res = await fetch(endpoint);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})</div>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
@@ -990,6 +1000,9 @@ async function loadGenericList(
|
|||||||
.join("");
|
.join("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to load ${endpoint}`, err);
|
console.error(`Failed to load ${endpoint}`, err);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,6 +1135,21 @@ function loadMemos(): void {
|
|||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
loadGenericList(
|
||||||
|
"/api/session_summaries",
|
||||||
|
"summaries-container",
|
||||||
|
(item) => `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<strong>Session ${item.session_id ? escapeHtml(item.session_id) : "Summary"}</strong>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "global")}</span>
|
||||||
|
</div>
|
||||||
|
${item.git_commit || item.git_branch ? `<div style="font-size:0.8em; color:var(--text-secondary); margin-top:3px; font-family:monospace;">${escapeHtml(item.git_branch || "")} @ ${escapeHtml((item.git_commit || "").substring(0, 8))}</div>` : ""}
|
||||||
|
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4; margin-top:8px;">${escapeHtml(item.summary || "")}</div>
|
||||||
|
${item.tasks_completed && item.tasks_completed.length > 0 ? `<div style="margin-top:8px; font-size:0.85em;"><strong>Tasks Completed:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.tasks_completed.map((t: string) => `<li>${escapeHtml(t)}</li>`).join("")}</ul></div>` : ""}
|
||||||
|
${item.next_steps && item.next_steps.length > 0 ? `<div style="margin-top:6px; font-size:0.85em;"><strong>Next Steps:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.next_steps.map((s: string) => `<li>${escapeHtml(s)}</li>`).join("")}</ul></div>` : ""}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
loadGenericList(
|
loadGenericList(
|
||||||
"/api/milestones",
|
"/api/milestones",
|
||||||
"milestones-container",
|
"milestones-container",
|
||||||
|
|||||||
@@ -323,14 +323,15 @@ impl McpTool for QueryRecentChangesHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: QueryRecentChangesTool =
|
let req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
||||||
serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
namespace: None,
|
||||||
namespace: None,
|
repo_name: None,
|
||||||
repo_name: None,
|
vcs_type: None,
|
||||||
vcs_type: None,
|
limit: None,
|
||||||
limit: None,
|
offset: None,
|
||||||
});
|
});
|
||||||
let limit = req.limit.unwrap_or(50);
|
let limit = req.limit.unwrap_or(50);
|
||||||
|
let offset = req.offset.unwrap_or(0);
|
||||||
let data = state.code.ledger.read_with(|l| {
|
let data = state.code.ledger.read_with(|l| {
|
||||||
let filtered: Vec<_> = l
|
let filtered: Vec<_> = l
|
||||||
.iter()
|
.iter()
|
||||||
@@ -357,6 +358,7 @@ impl McpTool for QueryRecentChangesHandler {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
|
.skip(offset)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ impl McpTool for AddSessionSummaryHandler {
|
|||||||
namespace: req.namespace,
|
namespace: req.namespace,
|
||||||
timestamp: crate::handlers::utils::now_secs(),
|
timestamp: crate::handlers::utils::now_secs(),
|
||||||
expires_at: None,
|
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 {
|
if summaries.len() > 200 {
|
||||||
summaries.remove(0);
|
summaries.remove(0);
|
||||||
|
|||||||
@@ -266,8 +266,9 @@ impl McpTool for TasksHandler {
|
|||||||
}
|
}
|
||||||
TaskAction::List => {
|
TaskAction::List => {
|
||||||
let level = req.summary_level.as_deref().unwrap_or("detailed");
|
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 data = state.project.tasks.read_with(|tasks| {
|
||||||
let mut filtered: Vec<_> = tasks
|
let matching: Vec<_> = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
let status_match = match req.status.as_deref() {
|
let status_match = match req.status.as_deref() {
|
||||||
@@ -281,6 +282,11 @@ impl McpTool for TasksHandler {
|
|||||||
};
|
};
|
||||||
status_match && branch_match
|
status_match && branch_match
|
||||||
})
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut filtered: Vec<_> = matching
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
.map(|t| match level {
|
.map(|t| match level {
|
||||||
"compact" => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status }),
|
"compact" => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status }),
|
||||||
"full" => serde_json::to_value(t).unwrap_or_default(),
|
"full" => serde_json::to_value(t).unwrap_or_default(),
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ pub struct QueryRecentChangesTool {
|
|||||||
pub vcs_type: Option<String>,
|
pub vcs_type: Option<String>,
|
||||||
/// Maximum number of records to return (defaults to 50).
|
/// Maximum number of records to return (defaults to 50).
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
|
/// Optional offset for pagination.
|
||||||
|
pub offset: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a visual representation of the knowledge graph.
|
/// Generate a visual representation of the knowledge graph.
|
||||||
@@ -255,6 +257,18 @@ pub struct AddSessionSummaryTool {
|
|||||||
/// The namespace to add the summary to.
|
/// The namespace to add the summary to.
|
||||||
#[serde(default = "crate::models::default_namespace")]
|
#[serde(default = "crate::models::default_namespace")]
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
|
/// Optional conversation or session ID.
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
/// Optional repository name.
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
/// Optional git branch.
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
/// Optional git commit hash.
|
||||||
|
pub git_commit: Option<String>,
|
||||||
|
/// Optional list of completed task IDs or task summaries in this session.
|
||||||
|
pub tasks_completed: Option<Vec<String>>,
|
||||||
|
/// Optional list of recommended next steps for incoming agents.
|
||||||
|
pub next_steps: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
/// Generate a standup report for a specific time window.
|
/// Generate a standup report for a specific time window.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
@@ -683,6 +697,8 @@ pub struct TasksTool {
|
|||||||
/// Optional assigned subagent role or identifier.
|
/// Optional assigned subagent role or identifier.
|
||||||
pub assigned_agent: Option<String>,
|
pub assigned_agent: Option<String>,
|
||||||
/// Optional verification command to validate criteria completion.
|
/// Optional verification command to validate criteria completion.
|
||||||
|
/// Optional pagination offset for 'list'.
|
||||||
|
pub offset: Option<usize>,
|
||||||
pub verification_command: Option<String>,
|
pub verification_command: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user