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(
|
||||
"/api/tech_debts",
|
||||
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(
|
||||
"/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<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 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>
|
||||
<h2>Session Summaries</h2>
|
||||
<div id="summaries-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||
<h2>Project Milestones</h2>
|
||||
<div id="milestones-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
||||
</div>
|
||||
|
||||
+28
-5
@@ -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 += `<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 || [];
|
||||
if (criteria.length > 0) {
|
||||
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;
|
||||
}
|
||||
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>
|
||||
<strong>${highlightText(r.title, q)}</strong>
|
||||
@@ -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 = `<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 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 = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
function loadLedger() {
|
||||
@@ -832,6 +845,16 @@ function loadMemos() {
|
||||
<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>
|
||||
`);
|
||||
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) => `
|
||||
<strong>${item.title || item.name}</strong>
|
||||
<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);
|
||||
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) {
|
||||
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 += `<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 || [];
|
||||
if (criteria.length > 0) {
|
||||
@@ -598,7 +602,7 @@ async function handleSearch(e: KeyboardEvent): Promise<void> {
|
||||
container.innerHTML = data.results
|
||||
.map(
|
||||
(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>
|
||||
<strong>${highlightText(r.title, q)}</strong>
|
||||
@@ -965,10 +969,16 @@ async function loadGenericList(
|
||||
containerId: string,
|
||||
formatter: (item: any) => string,
|
||||
): Promise<void> {
|
||||
const container = document.getElementById(containerId);
|
||||
try {
|
||||
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 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 = `<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(
|
||||
"/api/milestones",
|
||||
"milestones-container",
|
||||
|
||||
@@ -323,14 +323,15 @@ impl McpTool for QueryRecentChangesHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -138,6 +138,8 @@ pub struct QueryRecentChangesTool {
|
||||
pub vcs_type: Option<String>,
|
||||
/// Maximum number of records to return (defaults to 50).
|
||||
pub limit: Option<usize>,
|
||||
/// Optional offset for pagination.
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<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.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
@@ -683,6 +697,8 @@ pub struct TasksTool {
|
||||
/// Optional assigned subagent role or identifier.
|
||||
pub assigned_agent: Option<String>,
|
||||
/// Optional verification command to validate criteria completion.
|
||||
/// Optional pagination offset for 'list'.
|
||||
pub offset: Option<usize>,
|
||||
pub verification_command: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user