feat(nvim,telemetry): implement ADR-0102 circuit breaker and ADR-0103 knowledge graph projection

This commit is contained in:
Riz Ashraf committed 2026-10-07 17:40:30 +01:00
1 parent 3d77e60a02
commit ec977c24dd
7 files changed
+692 -11

No files matched your search

+1
View File
@@ -63,6 +63,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
}),
)
.route("/nvim/telemetry", post(nvim_telemetry_handler))
.route("/api/nvim/projection", get(crate::api::telemetry::nvim_knowledge_projection_handler))
.route("/api/terminal/history", get(crate::api::telemetry::get_terminal_history_handler))
.route("/terminal/telemetry", post(crate::api::telemetry::terminal_telemetry_handler))
.route("/events/wait", get(crate::api::events::wait_for_event_handler))
+331 -4
View File
@@ -1,7 +1,114 @@
use crate::AppState;
use axum::extract::State;
use axum::extract::{Query, State};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
pub struct ProjectedTechDebt {
pub id: String,
pub description: String,
pub severity: Option<String>,
pub line_range: Option<String>,
pub file_path: Option<String>,
pub ideal_solution: String,
}
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
pub struct ProjectedAdr {
pub id: String,
pub title: String,
pub decision: String,
pub status: String,
pub affected_components: Vec<String>,
}
pub fn find_projected_knowledge(
state: &crate::state::MemoryState,
file_path: &str,
) -> (Vec<ProjectedTechDebt>, Vec<ProjectedAdr>) {
let normalized_file = file_path.replace('\\', "/").to_lowercase();
let file_name = std::path::Path::new(&normalized_file)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let debts = state.code.tech_debts.read_with(|all_debts| {
all_debts
.iter()
.filter(|d| !d.is_resolved)
.filter(|d| {
if let Some(ref dfp) = d.file_path {
let ndfp = dfp.replace('\\', "/").to_lowercase();
if normalized_file == ndfp
|| normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/')))
|| ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/')))
|| (!file_name.is_empty()
&& (ndfp == file_name
|| ndfp.ends_with(&format!("/{}", file_name))))
{
return true;
}
}
if !file_name.is_empty()
&& file_name.len() > 3
&& d.description.to_lowercase().contains(&file_name)
{
return true;
}
false
})
.map(|d| ProjectedTechDebt {
id: d.id.clone(),
description: d.description.clone(),
severity: d.severity.clone(),
line_range: d.line_range.clone(),
file_path: d.file_path.clone(),
ideal_solution: d.ideal_solution.clone(),
})
.collect()
});
let adrs = state.code.adrs.read_with(|all_adrs| {
all_adrs
.iter()
.filter(|a| {
a.status.eq_ignore_ascii_case("accepted")
|| a.status.eq_ignore_ascii_case("active")
})
.filter(|a| {
for comp in &a.affected_components {
let ncomp = comp.replace('\\', "/").to_lowercase();
if !ncomp.is_empty()
&& (normalized_file.ends_with(&ncomp)
|| ncomp.ends_with(&normalized_file)
|| (!file_name.is_empty() && ncomp.contains(&file_name)))
{
return true;
}
}
if !file_name.is_empty()
&& file_name.len() > 3
&& (a.title.to_lowercase().contains(&file_name)
|| a.decision.to_lowercase().contains(&file_name))
{
return true;
}
false
})
.map(|a| ProjectedAdr {
id: a.id.clone(),
title: a.title.clone(),
decision: a.decision.clone(),
status: a.status.clone(),
affected_components: a.affected_components.clone(),
})
.collect()
});
(debts, adrs)
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct NvimTelemetry {
pub session_id: String,
@@ -40,10 +147,19 @@ pub async fn nvim_telemetry_handler(
}
}
// 2. Broadcast to UI WebSockets
// 2. Query Knowledge Graph for matching Tech Debts and ADRs (ADR-0103)
let (tech_debts, adrs) = if let Some(ref f) = payload.file {
find_projected_knowledge(&state.handler.state, f)
} else {
(Vec::new(), Vec::new())
};
// 3. Broadcast to UI WebSockets
let ws_msg = serde_json::json!({
"type": "nvim_telemetry",
"data": payload
"data": payload,
"tech_debts": tech_debts,
"adrs": adrs
});
let msg_str = ws_msg.to_string();
@@ -79,7 +195,28 @@ pub async fn nvim_telemetry_handler(
);
}
axum::Json(serde_json::json!({"status": "ok"}))
axum::Json(serde_json::json!({
"status": "ok",
"tech_debts": tech_debts,
"adrs": adrs
}))
}
pub async fn nvim_knowledge_projection_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl axum::response::IntoResponse {
let (tech_debts, adrs) = if let Some(file_path) = params.get("file") {
find_projected_knowledge(&state.handler.state, file_path)
} else {
(Vec::new(), Vec::new())
};
axum::Json(serde_json::json!({
"status": "ok",
"tech_debts": tech_debts,
"adrs": adrs
}))
}
use crate::models::TerminalHistory;
@@ -205,4 +342,194 @@ mod tests {
assert_eq!(history.len(), 1);
assert_eq!(history[0].command, "echo test");
}
#[tokio::test]
async fn test_find_projected_knowledge_matching() {
let dir = tempdir().unwrap();
let state = MemoryState::new(dir.path().to_str().unwrap());
// Add 1 unresolved Tech Debt for server/src/lib.rs
state.code.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "td-001".to_string(),
namespace: "default".to_string(),
description: "Refactor router dispatch in lib.rs".to_string(),
ideal_solution: "Use modular sub-routers".to_string(),
is_resolved: false,
created_at: 1000,
file_path: Some("server/src/lib.rs".to_string()),
line_range: Some("100-150".to_string()),
severity: Some("high".to_string()),
..Default::default()
});
// Add 1 resolved Tech Debt which should NOT be projected
debts.push(crate::models::TechDebt {
id: "td-002".to_string(),
namespace: "default".to_string(),
description: "Old resolved debt".to_string(),
ideal_solution: "Done".to_string(),
is_resolved: true,
created_at: 500,
file_path: Some("server/src/lib.rs".to_string()),
..Default::default()
});
});
// Add 1 accepted ADR for server/src/lib.rs
state.code.adrs.modify(|adrs| {
adrs.push(crate::models::Adr {
id: "ADR-0103".to_string(),
title: "Virtual Text Knowledge Projection".to_string(),
context: "Context".to_string(),
decision: "Project tech debt and ADRs into active buffer".to_string(),
consequence: "High developer awareness".to_string(),
status: "accepted".to_string(),
timestamp: 2000,
affected_components: vec![
"server/src/lib.rs".to_string(),
"gemini-ui.lua".to_string(),
],
..Default::default()
});
// Add 1 superseded ADR which should NOT be projected
adrs.push(crate::models::Adr {
id: "ADR-0099".to_string(),
title: "Old decision".to_string(),
context: "Context".to_string(),
decision: "Old".to_string(),
consequence: "Old".to_string(),
status: "superseded".to_string(),
timestamp: 1000,
affected_components: vec!["server/src/lib.rs".to_string()],
..Default::default()
});
});
// Test with Windows path format
let (debts, adrs) = find_projected_knowledge(
&state,
"C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\lib.rs",
);
assert_eq!(debts.len(), 1);
assert_eq!(debts[0].id, "td-001");
assert_eq!(debts[0].severity.as_deref(), Some("high"));
assert_eq!(adrs.len(), 1);
assert_eq!(adrs[0].id, "ADR-0103");
// Test with unrelated file
let (debts_unrelated, adrs_unrelated) =
find_projected_knowledge(&state, "crates/unrelated/src/foo.rs");
assert!(debts_unrelated.is_empty());
assert!(adrs_unrelated.is_empty());
}
#[tokio::test]
async fn test_nvim_telemetry_and_projection_endpoints() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
state.code.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "td-active".to_string(),
namespace: "default".to_string(),
description: "Fix concurrency lock".to_string(),
ideal_solution: "Use tokio rwlock".to_string(),
is_resolved: false,
created_at: 1000,
file_path: Some("server/src/api/telemetry.rs".to_string()),
line_range: Some("25".to_string()),
severity: Some("medium".to_string()),
..Default::default()
});
});
state.code.adrs.modify(|adrs| {
adrs.push(crate::models::Adr {
id: "ADR-0102".to_string(),
title: "Neovim RPC Circuit Breaker".to_string(),
context: "Context".to_string(),
decision: "Fail fast".to_string(),
consequence: "No hangs".to_string(),
status: "accepted".to_string(),
timestamp: 1000,
affected_components: vec!["telemetry.rs".to_string()],
..Default::default()
});
});
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(state.clone())),
clients: std::sync::RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
let app = axum::Router::new()
.route("/nvim/telemetry", post(nvim_telemetry_handler))
.route(
"/api/nvim/projection",
get(nvim_knowledge_projection_handler),
)
.with_state(app_state.clone());
// 1. Post BufEnter telemetry
let payload = NvimTelemetry {
session_id: "test-session".to_string(),
event: "BufEnter".to_string(),
file: Some(
"C:/Users/reazul.ashraf/workspace/rust/mcp-memory/server/src/api/telemetry.rs"
.to_string(),
),
line: Some(25),
col: Some(1),
prompt: None,
response: None,
index: None,
request_id: None,
diff_id: None,
start_row: None,
end_row: None,
};
let req = Request::builder()
.method("POST")
.uri("/nvim/telemetry")
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&payload).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), 200);
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["status"], "ok");
assert_eq!(body["tech_debts"].as_array().unwrap().len(), 1);
assert_eq!(body["tech_debts"][0]["id"], "td-active");
assert_eq!(body["adrs"].as_array().unwrap().len(), 1);
assert_eq!(body["adrs"][0]["id"], "ADR-0102");
// 2. Query GET /api/nvim/projection
let req2 = Request::builder()
.method("GET")
.uri("/api/nvim/projection?file=server/src/api/telemetry.rs")
.body(axum::body::Body::empty())
.unwrap();
let response2 = app.oneshot(req2).await.unwrap();
assert_eq!(response2.status(), 200);
let body_bytes2 = axum::body::to_bytes(response2.into_body(), usize::MAX)
.await
.unwrap();
let body2: serde_json::Value = serde_json::from_slice(&body_bytes2).unwrap();
assert_eq!(body2["status"], "ok");
assert_eq!(body2["tech_debts"].as_array().unwrap().len(), 1);
assert_eq!(body2["adrs"].as_array().unwrap().len(), 1);
}
}
+61
View File
@@ -2841,3 +2841,64 @@ mod tests {
assert!(pre_res.contains("active_tasks"));
}
#[tokio::test]
async fn test_tech_debt_priority_aware_eviction() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = TechDebtHandler;
// Populate 300 tech debts: 1 critical, 1 resolved, 298 low
state.code.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "critical-debt".to_string(),
namespace: "global".to_string(),
description: "Critical security issue".to_string(),
severity: Some("critical".to_string()),
is_resolved: false,
created_at: 100,
..Default::default()
});
debts.push(crate::models::TechDebt {
id: "resolved-debt".to_string(),
namespace: "global".to_string(),
description: "Old resolved issue".to_string(),
severity: Some("high".to_string()),
is_resolved: true,
created_at: 50,
..Default::default()
});
for i in 0..298 {
debts.push(crate::models::TechDebt {
id: format!("low-debt-{}", i),
namespace: "global".to_string(),
description: format!("Low debt {}", i),
severity: Some("low".to_string()),
is_resolved: false,
created_at: 200 + i,
..Default::default()
});
}
});
// Add 301st item: should evict the resolved debt first
let res = handler
.execute(
serde_json::json!({
"action": "log",
"description": "New medium debt",
"severity": "medium"
}),
state.clone(),
)
.await
.unwrap();
assert_eq!(res, "Tech debt logged");
state.code.tech_debts.read_with(|debts| {
assert_eq!(debts.len(), 300);
assert!(debts.iter().any(|d| d.id == "critical-debt"), "Critical unresolved debt must be retained");
assert!(!debts.iter().any(|d| d.id == "resolved-debt"), "Resolved debt should have been evicted first");
});
}
}
+9 -1
View File
@@ -304,9 +304,17 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
}
}
let (tech_debts, adrs) = if let Some(ref f) = payload.file {
crate::api::telemetry::find_projected_knowledge(&nvim_udp_state.handler.state, f)
} else {
(Vec::new(), Vec::new())
};
let ws_msg = serde_json::json!({
"type": "nvim_telemetry",
"data": payload
"data": payload,
"tech_debts": tech_debts,
"adrs": adrs
});
let msg_str = ws_msg.to_string();