diff --git a/gemini-integration.lua b/gemini-integration.lua index a4f3ad0..2f6c00e 100644 --- a/gemini-integration.lua +++ b/gemini-integration.lua @@ -26,11 +26,38 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos '{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}', server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col ) + local current_buf = ev.buf vim.fn.jobstart({ "curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry", "-H", "Content-Type: application/json", "-d", payload - }, { detach = true }) + }, { + stdout_buffered = true, + on_stdout = function(_, data) + if data and #data > 0 then + local raw = table.concat(data, "") + if raw ~= "" then + local ok, resp = pcall(vim.fn.json_decode, raw) + if ok and resp and (resp.tech_debts or resp.adrs) then + vim.schedule(function() + if _G.gemini and _G.gemini.project_knowledge then + _G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf) + end + end) + end + end + end + end, + }) + + -- 3. UDP Mirroring (Native fast sync) + local uv = vim.uv or vim.loop + local client = uv.new_udp() + if client then + client:send(payload, "127.0.0.1", 3002, function(err) + client:close() + end) + end end end end, diff --git a/gemini-ui.lua b/gemini-ui.lua index ae13cc9..a11f76b 100644 --- a/gemini-ui.lua +++ b/gemini-ui.lua @@ -94,7 +94,123 @@ function M.prompt_user(prompt_text, options, request_id) end local ns_id = vim.api.nvim_create_namespace("gemini_ghost_text") +local knowledge_ns = vim.api.nvim_create_namespace("gemini_knowledge_projection") local active_diffs = {} +local active_knowledge = {} + +function M.project_knowledge(tech_debts, adrs, bufnr) + bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr + if not vim.api.nvim_buf_is_valid(bufnr) then return end + + vim.api.nvim_buf_clear_namespace(bufnr, knowledge_ns, 0, -1) + active_knowledge[bufnr] = { + tech_debts = tech_debts or {}, + adrs = adrs or {}, + file = vim.api.nvim_buf_get_name(bufnr) + } + + local line_count = vim.api.nvim_buf_line_count(bufnr) + + -- 1. Project Tech Debts + if tech_debts and #tech_debts > 0 then + for _, debt in ipairs(tech_debts) do + local target_line = 0 + if debt.line_range then + local num = tonumber(string.match(debt.line_range, "%d+")) + if num and num > 0 then + target_line = num - 1 + end + end + target_line = math.max(0, math.min(target_line, line_count - 1)) + + local sev = debt.severity or "debt" + local hl = (sev == "critical" or sev == "high") and "DiagnosticError" or "DiagnosticWarn" + vim.api.nvim_buf_set_extmark(bufnr, knowledge_ns, target_line, 0, { + virt_text = {{" ⚡ [" .. sev:upper() .. " DEBT] " .. debt.description, hl}}, + virt_text_pos = "eol", + hl_mode = "combine", + }) + end + end + + -- 2. Project ADRs + if adrs and #adrs > 0 then + for _, adr in ipairs(adrs) do + vim.api.nvim_buf_set_extmark(bufnr, knowledge_ns, 0, 0, { + virt_text = {{" 📜 [" .. adr.id .. "] " .. adr.title, "DiagnosticInfo"}}, + virt_text_pos = "eol", + hl_mode = "combine", + }) + end + end +end + +function M.show_knowledge() + local bufnr = vim.api.nvim_get_current_buf() + local data = active_knowledge[bufnr] + if not data or ((#data.tech_debts == 0) and (#data.adrs == 0)) then + print("No active Tech Debt or ADRs found for this file.") + return + end + + local lines = {} + table.insert(lines, "# Antigravity Knowledge Graph Projection") + table.insert(lines, "File: " .. (data.file or vim.api.nvim_buf_get_name(bufnr))) + table.insert(lines, string.rep("=", 50)) + table.insert(lines, "") + + if #data.tech_debts > 0 then + table.insert(lines, "## Active Tech Debts (" .. #data.tech_debts .. ")") + for _, d in ipairs(data.tech_debts) do + table.insert(lines, string.format("- [%s] (%s): %s", d.id, d.severity or "unspecified", d.description)) + if d.line_range then + table.insert(lines, " Lines: " .. d.line_range) + end + if d.ideal_solution and d.ideal_solution ~= "" then + table.insert(lines, " Ideal Solution: " .. d.ideal_solution) + end + end + table.insert(lines, "") + end + + if #data.adrs > 0 then + table.insert(lines, "## Architectural Decision Records (ADRs) (" .. #data.adrs .. ")") + for _, a in ipairs(data.adrs) do + table.insert(lines, string.format("- [%s] %s (Status: %s)", a.id, a.title, a.status)) + table.insert(lines, " Decision: " .. a.decision) + end + table.insert(lines, "") + end + + local kbuf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(kbuf, 0, -1, false, lines) + vim.bo[kbuf].filetype = "markdown" + + local width = math.min(math.floor(vim.o.columns * 0.7), 90) + local height = math.min(#lines + 2, math.floor(vim.o.lines * 0.6)) + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + local kwin = vim.api.nvim_open_win(kbuf, true, { + relative = 'editor', + width = width, + height = height, + row = row, + col = col, + style = 'minimal', + border = 'rounded', + title = ' Antigravity Knowledge Projection ', + title_pos = 'center' + }) + + local function close_kwin() + if kwin and vim.api.nvim_win_is_valid(kwin) then + vim.api.nvim_win_close(kwin, true) + end + end + vim.keymap.set('n', 'q', close_kwin, { buffer = kbuf, noremap = true, silent = true }) + vim.keymap.set('n', '', close_kwin, { buffer = kbuf, noremap = true, silent = true }) +end function M.preview_diff(diff_id, bufnr, start_row, end_row, new_lines) bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr @@ -195,6 +311,7 @@ function M.setup() vim.keymap.set('n', 'aa', M.accept_diff, { desc = "Accept Agent Diff", noremap = true, silent = true }) vim.keymap.set('n', 'ar', M.reject_diff, { desc = "Reject Agent Diff", noremap = true, silent = true }) + vim.keymap.set('n', 'ak', M.show_knowledge, { desc = "Show Knowledge (Tech Debt & ADRs)", noremap = true, silent = true }) end function M.send_to_terminal(command) diff --git a/nvim-core/src/lib.rs b/nvim-core/src/lib.rs index d156735..6720288 100644 --- a/nvim-core/src/lib.rs +++ b/nvim-core/src/lib.rs @@ -214,7 +214,98 @@ pub struct NvimRequest { pub reply: oneshot::Sender>, } -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CircuitState { + Closed = 0, + Open = 1, + HalfOpen = 2, +} + +pub struct NvimCircuitBreaker { + state: AtomicU8, + consecutive_failures: AtomicU32, + last_state_change_millis: AtomicU64, + failure_threshold: u32, + cooldown_millis: u64, +} + +impl NvimCircuitBreaker { + pub fn new(failure_threshold: u32, cooldown_millis: u64) -> Self { + Self { + state: AtomicU8::new(CircuitState::Closed as u8), + consecutive_failures: AtomicU32::new(0), + last_state_change_millis: AtomicU64::new(0), + failure_threshold, + cooldown_millis, + } + } + + pub fn current_state(&self) -> CircuitState { + match self.state.load(Ordering::SeqCst) { + 1 => CircuitState::Open, + 2 => CircuitState::HalfOpen, + _ => CircuitState::Closed, + } + } + + pub fn can_execute(&self) -> bool { + let state = self.current_state(); + match state { + CircuitState::Closed => true, + CircuitState::HalfOpen => true, + CircuitState::Open => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let last = self.last_state_change_millis.load(Ordering::SeqCst); + if now.saturating_sub(last) >= self.cooldown_millis { + self.state + .store(CircuitState::HalfOpen as u8, Ordering::SeqCst); + tracing::info!("Neovim RPC circuit breaker transitioned to HalfOpen"); + true + } else { + false + } + } + } + } + + pub fn record_success(&self) { + self.consecutive_failures.store(0, Ordering::SeqCst); + let prev = self.state.swap(CircuitState::Closed as u8, Ordering::SeqCst); + if prev != CircuitState::Closed as u8 { + tracing::info!("Neovim RPC circuit breaker transitioned to Closed (recovered)"); + } + } + + pub fn record_failure(&self) { + let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1; + if failures >= self.failure_threshold { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + self.last_state_change_millis.store(now, Ordering::SeqCst); + self.state.store(CircuitState::Open as u8, Ordering::SeqCst); + tracing::warn!( + "Neovim RPC circuit breaker tripped to Open (consecutive failures: {})", + failures + ); + } + } + + pub fn reset(&self) { + self.consecutive_failures.store(0, Ordering::SeqCst); + self.state.store(CircuitState::Closed as u8, Ordering::SeqCst); + } +} + +pub static CIRCUIT_BREAKER: LazyLock = + LazyLock::new(|| NvimCircuitBreaker::new(2, 5000)); + static NEXT_MSGID: AtomicU64 = AtomicU64::new(1); static RPC_SEMAPHORE: LazyLock> = LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(100))); @@ -529,6 +620,10 @@ async fn get_nvim_connection() -> Result, String> { } async fn call_nvim(req: rmpv::Value) -> Result { + if !CIRCUIT_BREAKER.can_execute() { + return Err("Neovim RPC circuit breaker is OPEN (consecutive failures detected). Failing fast.".to_string()); + } + let msgid = if let rmpv::Value::Array(ref arr) = req { if arr.len() > 1 { match &arr[1] { @@ -542,7 +637,13 @@ async fn call_nvim(req: rmpv::Value) -> Result { 0 }; - let tx = get_nvim_connection().await?; + let tx = match get_nvim_connection().await { + Ok(t) => t, + Err(e) => { + CIRCUIT_BREAKER.record_failure(); + return Err(e); + } + }; let (reply_tx, reply_rx) = oneshot::channel(); if let Err(_) = tx @@ -556,20 +657,30 @@ async fn call_nvim(req: rmpv::Value) -> Result { PENDING_REQUESTS.remove(&msgid); let mut conn = NVIM_CONN.lock().await; *conn = None; + CIRCUIT_BREAKER.record_failure(); return Err( "Failed to send request to Neovim connection manager: connection closed".to_string(), ); } - match tokio::time::timeout(tokio::time::Duration::from_secs(15), reply_rx).await { - Ok(Ok(res)) => res, + match tokio::time::timeout(tokio::time::Duration::from_secs(5), reply_rx).await { + Ok(Ok(res)) => { + CIRCUIT_BREAKER.record_success(); + res + } Ok(Err(_)) => { PENDING_REQUESTS.remove(&msgid); + let mut conn = NVIM_CONN.lock().await; + *conn = None; + CIRCUIT_BREAKER.record_failure(); Err("Response channel dropped".to_string()) } Err(_) => { PENDING_REQUESTS.remove(&msgid); - Err("Timeout waiting for Neovim response (15s)".to_string()) + let mut conn = NVIM_CONN.lock().await; + *conn = None; + CIRCUIT_BREAKER.record_failure(); + Err("Timeout waiting for Neovim response (5s)".to_string()) } } } @@ -1999,4 +2110,33 @@ mod tests { panic!("Expected array response for nvim_get_api_info"); } } + + #[test] + fn test_nvim_circuit_breaker_transitions() { + let cb = NvimCircuitBreaker::new(2, 50); // 2 failures, 50ms cooldown + assert_eq!(cb.current_state(), CircuitState::Closed); + assert!(cb.can_execute()); + + // 1st failure - remains Closed + cb.record_failure(); + assert_eq!(cb.current_state(), CircuitState::Closed); + assert!(cb.can_execute()); + + // 2nd failure - trips to Open + cb.record_failure(); + assert_eq!(cb.current_state(), CircuitState::Open); + assert!(!cb.can_execute(), "Circuit breaker should fail fast when Open"); + + // Wait for cooldown + std::thread::sleep(std::time::Duration::from_millis(60)); + + // After cooldown, can_execute transitions to HalfOpen + assert!(cb.can_execute(), "After cooldown, should allow HalfOpen probe"); + assert_eq!(cb.current_state(), CircuitState::HalfOpen); + + // Success in HalfOpen recovers back to Closed + cb.record_success(); + assert_eq!(cb.current_state(), CircuitState::Closed); + assert!(cb.can_execute()); + } } diff --git a/server/src/api/setup.rs b/server/src/api/setup.rs index 21fd20c..c79bf15 100644 --- a/server/src/api/setup.rs +++ b/server/src/api/setup.rs @@ -63,6 +63,7 @@ pub fn create_router(app_state: Arc) -> 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)) diff --git a/server/src/api/telemetry.rs b/server/src/api/telemetry.rs index dc35534..2ee9309 100644 --- a/server/src/api/telemetry.rs +++ b/server/src/api/telemetry.rs @@ -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, + pub line_range: Option, + pub file_path: Option, + 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, +} + +pub fn find_projected_knowledge( + state: &crate::state::MemoryState, + file_path: &str, +) -> (Vec, Vec) { + 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>, + Query(params): Query>, +) -> 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); + } } diff --git a/server/src/handlers/meta.rs b/server/src/handlers/meta.rs index 8dad604..2683c99 100644 --- a/server/src/handlers/meta.rs +++ b/server/src/handlers/meta.rs @@ -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"); + }); + } +} + diff --git a/server/src/lib.rs b/server/src/lib.rs index 64a9169..536ff5a 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -304,9 +304,17 @@ pub async fn run_server(state: Arc) -> Result<(), Box