feat(nvim,telemetry): implement ADR-0102 circuit breaker and ADR-0103 knowledge graph projection
This commit is contained in:
1 parent
3d77e60a02
commit
ec977c24dd
7 files changed
+692
-11
No files matched your search
+28
-1
@@ -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,
|
||||
|
||||
+117
@@ -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', '<Esc>', 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', '<leader>aa', M.accept_diff, { desc = "Accept Agent Diff", noremap = true, silent = true })
|
||||
vim.keymap.set('n', '<leader>ar', M.reject_diff, { desc = "Reject Agent Diff", noremap = true, silent = true })
|
||||
vim.keymap.set('n', '<leader>ak', M.show_knowledge, { desc = "Show Knowledge (Tech Debt & ADRs)", noremap = true, silent = true })
|
||||
end
|
||||
|
||||
function M.send_to_terminal(command)
|
||||
|
||||
+145
-5
@@ -214,7 +214,98 @@ pub struct NvimRequest {
|
||||
pub reply: oneshot::Sender<Result<rmpv::Value, String>>,
|
||||
}
|
||||
|
||||
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<NvimCircuitBreaker> =
|
||||
LazyLock::new(|| NvimCircuitBreaker::new(2, 5000));
|
||||
|
||||
static NEXT_MSGID: AtomicU64 = AtomicU64::new(1);
|
||||
static RPC_SEMAPHORE: LazyLock<Arc<tokio::sync::Semaphore>> =
|
||||
LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(100)));
|
||||
@@ -529,6 +620,10 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
||||
}
|
||||
|
||||
async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
|
||||
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<rmpv::Value, String> {
|
||||
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<rmpv::Value, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user