fix(dashboard,server): restore code change ledger UI and enrich model schema parity and pagination

This commit is contained in:
Riz Ashraf committed 2026-10-07 13:14:33 +01:00
1 parent 3b146f91c2
commit 73c3d41c87
9 files changed
+297 -19

No files matched your search

+119
View File
@@ -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");
}
} }
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+9 -7
View File
@@ -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();
+6 -1
View File
@@ -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);
+7 -1
View File
@@ -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(),
+16
View File
@@ -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>,
} }
+77
View File
@@ -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."
);
}
}