609 lines
21 KiB
Rust
609 lines
21 KiB
Rust
use crate::AppState;
|
|
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();
|
|
|
|
// An empty file path matches everything due to `ends_with("")` behavior.
|
|
// Return empty results immediately for [No Name] buffers.
|
|
if normalized_file.trim().is_empty() {
|
|
return (Vec::new(), Vec::new());
|
|
}
|
|
|
|
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,
|
|
pub event: String,
|
|
pub file: Option<String>,
|
|
pub line: Option<i64>,
|
|
pub col: Option<i64>,
|
|
// New interactive fields
|
|
pub prompt: Option<String>,
|
|
pub response: Option<String>,
|
|
pub index: Option<i64>,
|
|
pub request_id: Option<String>,
|
|
pub diff_id: Option<String>,
|
|
pub start_row: Option<i64>,
|
|
pub end_row: Option<i64>,
|
|
}
|
|
|
|
pub async fn nvim_telemetry_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::Json(payload): axum::Json<NvimTelemetry>,
|
|
) -> impl axum::response::IntoResponse {
|
|
// 1. Update active_nvim.txt if FocusGained, BufEnter, or VimEnter
|
|
if payload.event == "FocusGained" || payload.event == "BufEnter" || payload.event == "VimEnter"
|
|
{
|
|
let session = &payload.session_id;
|
|
let is_unix_socket = session.starts_with('/') || session.starts_with('~');
|
|
|
|
if is_unix_socket {
|
|
let wsl_path = "\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
|
|
let _ = tokio::fs::write(wsl_path, session).await;
|
|
} else {
|
|
let profile =
|
|
std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
|
|
let win_path = format!("{}\\.gemini\\active_nvim.txt", profile);
|
|
let _ = tokio::fs::write(&win_path, session).await;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
"tech_debts": tech_debts,
|
|
"adrs": adrs
|
|
});
|
|
|
|
let msg_str = ws_msg.to_string();
|
|
let senders: Vec<_> = state
|
|
.clients
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
for tx in senders {
|
|
let _ = tx.try_send(msg_str.clone());
|
|
}
|
|
|
|
if payload.event == "BufWritePost"
|
|
&& let Some(ref file_path) = payload.file
|
|
{
|
|
let normalized_file = file_path.replace("\\", "/");
|
|
let topic = format!("nvim:save:{}", normalized_file);
|
|
let event = crate::state::GenericEvent {
|
|
topic,
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: serde_json::json!(&payload),
|
|
};
|
|
let _ = state.handler.state.event_bus_tx.send(event);
|
|
}
|
|
|
|
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
|
let payload_val = serde_json::json!(&payload);
|
|
let _ = state
|
|
.handler
|
|
.state
|
|
.event_bus_tx
|
|
.send(crate::state::GenericEvent {
|
|
topic: format!("nvim:ui:{}", payload.event),
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: payload_val.clone(),
|
|
});
|
|
|
|
if let Some(ref req_id) = payload.request_id {
|
|
let _ = state
|
|
.handler
|
|
.state
|
|
.event_bus_tx
|
|
.send(crate::state::GenericEvent {
|
|
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: payload_val.clone(),
|
|
});
|
|
}
|
|
|
|
if let Some(ref diff_id) = payload.diff_id {
|
|
let _ = state
|
|
.handler
|
|
.state
|
|
.event_bus_tx
|
|
.send(crate::state::GenericEvent {
|
|
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: payload_val.clone(),
|
|
});
|
|
let _ = state
|
|
.handler
|
|
.state
|
|
.event_bus_tx
|
|
.send(crate::state::GenericEvent {
|
|
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: payload_val.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(ref f) = payload.file {
|
|
state.handler.state.record_activity(
|
|
"editor",
|
|
&format!("Neovim {}: {}", payload.event, f),
|
|
None,
|
|
);
|
|
}
|
|
|
|
// 4. Extract active task & push gate for zero-polling statusline
|
|
let active_task = state.handler.state.project.tasks.read_with(|tasks| {
|
|
tasks
|
|
.iter()
|
|
.find(|t| t.is_active())
|
|
.or_else(|| {
|
|
tasks
|
|
.iter()
|
|
.find(|t| t.status == "pending" && t.parent_id.is_none())
|
|
})
|
|
.map(|t| t.title.clone())
|
|
});
|
|
|
|
let active_gate = state.handler.state.env.gates.read_with(|gates| {
|
|
gates
|
|
.iter()
|
|
.find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate"))
|
|
.map(|g| g.status.clone())
|
|
});
|
|
|
|
axum::Json(serde_json::json!({
|
|
"status": "ok",
|
|
"tech_debts": tech_debts,
|
|
"adrs": adrs,
|
|
"active_task": active_task,
|
|
"active_gate": active_gate
|
|
}))
|
|
}
|
|
|
|
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;
|
|
|
|
pub async fn get_terminal_history_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> impl axum::response::IntoResponse {
|
|
let history_json = state
|
|
.handler
|
|
.state
|
|
.telemetry
|
|
.terminal_history
|
|
.read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string()));
|
|
(
|
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
|
history_json,
|
|
)
|
|
}
|
|
|
|
pub async fn terminal_telemetry_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
axum::Json(payload): axum::Json<TerminalHistory>,
|
|
) -> impl axum::response::IntoResponse {
|
|
state.handler.state.record_activity(
|
|
"terminal",
|
|
&format!("Ran command: {}", payload.command),
|
|
Some(&format!(
|
|
"exit_code: {}, cwd: {}",
|
|
payload.exit_code, payload.cwd
|
|
)),
|
|
);
|
|
|
|
state.handler.state.record_terminal_history(payload.clone());
|
|
|
|
let ws_msg = serde_json::json!({
|
|
"type": "terminal_telemetry",
|
|
"data": payload
|
|
});
|
|
|
|
let msg_str = ws_msg.to_string();
|
|
let senders: Vec<_> = state
|
|
.clients
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
for tx in senders {
|
|
let _ = tx.try_send(msg_str.clone());
|
|
}
|
|
|
|
axum::Json(serde_json::json!({"status": "ok"}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::router::MemoryHandler;
|
|
use crate::state::MemoryState;
|
|
use axum::http::Request;
|
|
use axum::routing::get;
|
|
use axum::routing::post;
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use tempfile::tempdir;
|
|
use tower::ServiceExt;
|
|
|
|
#[tokio::test]
|
|
async fn test_terminal_history() {
|
|
let dir = tempdir().unwrap();
|
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
|
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("/api/terminal/history", get(get_terminal_history_handler))
|
|
.route("/terminal/telemetry", post(terminal_telemetry_handler))
|
|
.with_state(app_state.clone());
|
|
|
|
// Create a fake terminal telemetry payload
|
|
let payload = TerminalHistory {
|
|
command: "echo test".to_string(),
|
|
exit_code: 0,
|
|
cwd: "C:/Users/reazul.ashraf/workspace".to_string(),
|
|
os: "windows".to_string(),
|
|
timestamp: 12345,
|
|
error_output: None,
|
|
..Default::default()
|
|
};
|
|
|
|
// Post the telemetry
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/terminal/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);
|
|
|
|
// Get the telemetry
|
|
let req2 = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/terminal/history")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
|
|
let response2 = app.oneshot(req2).await.unwrap();
|
|
assert_eq!(response2.status(), 200);
|
|
|
|
let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let history: Vec<TerminalHistory> = serde_json::from_slice(&body_bytes).unwrap();
|
|
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);
|
|
}
|
|
}
|