Compare commits
4
Commits
f546579596
...
d792b50343
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d792b50343 | ||
|
|
8f924b793a | ||
|
|
ec977c24dd | ||
|
|
3d77e60a02 |
No files matched your search
@@ -5,6 +5,7 @@ llvm-cov.zip
|
|||||||
|
|
||||||
.fastembed_cache/
|
.fastembed_cache/
|
||||||
*.py
|
*.py
|
||||||
|
!scripts/*.py
|
||||||
temp.json
|
temp.json
|
||||||
*.txt
|
*.txt
|
||||||
summary.md
|
summary.md
|
||||||
@@ -12,7 +12,7 @@ Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to
|
|||||||
* **Role:** The Knowledge Graph Curator
|
* **Role:** The Knowledge Graph Curator
|
||||||
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries.
|
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries.
|
||||||
* **Trigger:** After completing a coding task, refactor, or bug fix.
|
* **Trigger:** After completing a coding task, refactor, or bug fix.
|
||||||
* **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph.
|
* **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph. **CRITICAL:** The Librarian MUST inspect recent git commits and commit messages for any referenced ADRs (e.g. `ADR-\d+`), and automatically reconcile their lifecycle status in `decisions` to `implemented` with the exact `git_commit` and `git_branch`. No ADR whose code has been committed should ever remain in `accepted` status.
|
||||||
|
|
||||||
## 2. PrePushAuditor
|
## 2. PrePushAuditor
|
||||||
* **Role:** Quality Gate Enforcer
|
* **Role:** Quality Gate Enforcer
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ The MCP Memory server is the central brain. You must be PROACTIVE, not reactive,
|
|||||||
- **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task.
|
- **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task.
|
||||||
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match.
|
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match.
|
||||||
- **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
|
- **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
|
||||||
- **Architectural Decisions (ADR)**: When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
|
- **Architectural Decisions (ADR) & Lifecycle Closure**:
|
||||||
|
- When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
|
||||||
|
- **MANDATORY Definition of Done**: When code implementing an ADR is committed, you MUST IMMEDIATELY call `decisions` (action: "update", id: "ADR-XXXX", status: "implemented", git_commit: <commit_hash>, git_branch: <branch>). NEVER leave an ADR in `accepted` once the implementing code is committed. The user should NEVER have to manually flag or remind that an implemented ADR is still marked as 'accepted'.
|
||||||
- **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`.
|
- **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`.
|
||||||
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
|
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
|
||||||
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server. Query `/terminal/history` or recent logs when analyzing shell execution context.
|
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server. Query `/terminal/history` or recent logs when analyzing shell execution context.
|
||||||
|
|||||||
+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}',
|
'{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}',
|
||||||
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
|
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
|
||||||
)
|
)
|
||||||
|
local current_buf = ev.buf
|
||||||
vim.fn.jobstart({
|
vim.fn.jobstart({
|
||||||
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
|
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
|
||||||
"-H", "Content-Type: application/json",
|
"-H", "Content-Type: application/json",
|
||||||
"-d", payload
|
"-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
|
end
|
||||||
end,
|
end,
|
||||||
|
|||||||
+117
@@ -94,7 +94,123 @@ function M.prompt_user(prompt_text, options, request_id)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ns_id = vim.api.nvim_create_namespace("gemini_ghost_text")
|
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_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)
|
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
|
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>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>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
|
end
|
||||||
|
|
||||||
function M.send_to_terminal(command)
|
function M.send_to_terminal(command)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ build-nvim:
|
|||||||
# Stage updated server binary, perform graceful shutdown, and auto-restart (skips if binary is unchanged)
|
# Stage updated server binary, perform graceful shutdown, and auto-restart (skips if binary is unchanged)
|
||||||
deploy-server:
|
deploy-server:
|
||||||
@Write-Host "Deploying Windows Server (Staged Hot-Swap)..." -ForegroundColor Cyan
|
@Write-Host "Deploying Windows Server (Staged Hot-Swap)..." -ForegroundColor Cyan
|
||||||
-& { $src = "target\release\mcp-memory-server.exe"; $targets = @("$env:USERPROFILE\.local\bin\mcp-memory-server.exe", "$env:USERPROFILE\.cargo\bin\mcp-memory-server.exe"); $srcHash = if (Test-Path $src) { (Get-FileHash $src -Algorithm SHA256).Hash } else { $null }; $updated = $false; foreach ($target in $targets) { $parent = Split-Path $target; if (Test-Path $parent) { if ((Test-Path $target) -and $srcHash -and ((Get-FileHash $target -Algorithm SHA256).Hash -eq $srcHash)) { Write-Host "Skipping copy to $target (identical checksum)." -ForegroundColor Yellow } else { if (Test-Path $target) { $bak = "$target.old.$([guid]::NewGuid().ToString().Substring(0,8))"; Move-Item -Force $target $bak -ErrorAction SilentlyContinue }; Copy-Item -Force $src $target; Get-ChildItem "$parent\mcp-memory-server.exe.old.*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue; Write-Host "Copied updated binary to $target" -ForegroundColor Green; $updated = $true } } }; if ($updated -or -not (Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue)) { just stop; just start } else { Write-Host "Server binary unchanged and server is running. Skipping restart." -ForegroundColor Green } }
|
-& { $src = "target\release\mcp-memory-server.exe"; $targets = @("$env:USERPROFILE\.local\bin\mcp-memory-server.exe", "$env:USERPROFILE\.cargo\bin\mcp-memory-server.exe", "$env:USERPROFILE\.gemini\antigravity-cli\mcp\mcp-memory\mcp-memory.exe"); $srcHash = if (Test-Path $src) { (Get-FileHash $src -Algorithm SHA256).Hash } else { $null }; $updated = $false; foreach ($target in $targets) { $parent = Split-Path $target; if (Test-Path $parent) { if ((Test-Path $target) -and $srcHash -and ((Get-FileHash $target -Algorithm SHA256).Hash -eq $srcHash)) { Write-Host "Skipping copy to $target (identical checksum)." -ForegroundColor Yellow } else { if (Test-Path $target) { $bak = "$target.old.$([guid]::NewGuid().ToString().Substring(0,8))"; Move-Item -Force $target $bak -ErrorAction SilentlyContinue }; Copy-Item -Force $src $target; Get-ChildItem "$parent\mcp-memory-server.exe.old.*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue; Write-Host "Copied updated binary to $target" -ForegroundColor Green; $updated = $true } } }; if ($updated -or -not (Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue)) { just stop; just start } else { Write-Host "Server binary unchanged and server is running. Skipping restart." -ForegroundColor Green } }
|
||||||
|
|
||||||
# Deploy Windows-native stub binary (skips if identical checksum)
|
# Deploy Windows-native stub binary (skips if identical checksum)
|
||||||
deploy-stub-win:
|
deploy-stub-win:
|
||||||
@@ -190,3 +190,9 @@ all-nvim-wsl: build-nvim deploy-nvim
|
|||||||
|
|
||||||
# Complete lifecycle: stop, build, deploy, start, and verify WSL server
|
# Complete lifecycle: stop, build, deploy, start, and verify WSL server
|
||||||
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
||||||
|
|
||||||
|
# Install git post-commit hook for automatic ADR/Task/TechDebt state reconciliation
|
||||||
|
install-git-hooks:
|
||||||
|
@Write-Host "Installing git post-commit reconciliation hook..." -ForegroundColor Cyan
|
||||||
|
-& { $hookContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; $hookPath = ".git\hooks\post-commit"; [System.IO.File]::WriteAllText($hookPath, $hookContent); Write-Host "✅ Git post-commit hook installed to $hookPath" -ForegroundColor Green }
|
||||||
|
|
||||||
+145
-5
@@ -214,7 +214,98 @@ pub struct NvimRequest {
|
|||||||
pub reply: oneshot::Sender<Result<rmpv::Value, String>>,
|
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 NEXT_MSGID: AtomicU64 = AtomicU64::new(1);
|
||||||
static RPC_SEMAPHORE: LazyLock<Arc<tokio::sync::Semaphore>> =
|
static RPC_SEMAPHORE: LazyLock<Arc<tokio::sync::Semaphore>> =
|
||||||
LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(100)));
|
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> {
|
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 {
|
let msgid = if let rmpv::Value::Array(ref arr) = req {
|
||||||
if arr.len() > 1 {
|
if arr.len() > 1 {
|
||||||
match &arr[1] {
|
match &arr[1] {
|
||||||
@@ -542,7 +637,13 @@ async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
|
|||||||
0
|
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();
|
let (reply_tx, reply_rx) = oneshot::channel();
|
||||||
|
|
||||||
if let Err(_) = tx
|
if let Err(_) = tx
|
||||||
@@ -556,20 +657,30 @@ async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
|
|||||||
PENDING_REQUESTS.remove(&msgid);
|
PENDING_REQUESTS.remove(&msgid);
|
||||||
let mut conn = NVIM_CONN.lock().await;
|
let mut conn = NVIM_CONN.lock().await;
|
||||||
*conn = None;
|
*conn = None;
|
||||||
|
CIRCUIT_BREAKER.record_failure();
|
||||||
return Err(
|
return Err(
|
||||||
"Failed to send request to Neovim connection manager: connection closed".to_string(),
|
"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 {
|
match tokio::time::timeout(tokio::time::Duration::from_secs(5), reply_rx).await {
|
||||||
Ok(Ok(res)) => res,
|
Ok(Ok(res)) => {
|
||||||
|
CIRCUIT_BREAKER.record_success();
|
||||||
|
res
|
||||||
|
}
|
||||||
Ok(Err(_)) => {
|
Ok(Err(_)) => {
|
||||||
PENDING_REQUESTS.remove(&msgid);
|
PENDING_REQUESTS.remove(&msgid);
|
||||||
|
let mut conn = NVIM_CONN.lock().await;
|
||||||
|
*conn = None;
|
||||||
|
CIRCUIT_BREAKER.record_failure();
|
||||||
Err("Response channel dropped".to_string())
|
Err("Response channel dropped".to_string())
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
PENDING_REQUESTS.remove(&msgid);
|
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");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Deterministic Git Reconciliation Hook for MCP Memory
|
||||||
|
Automatically syncs git commit events to mcp-memory to transition ADRs, resolve tech debts,
|
||||||
|
complete tasks, cascade-unblock dependent tasks, and recalculate milestone statuses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
def run_git(cmd):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True)
|
||||||
|
return res.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = os.environ.get("MCP_PORT", "3000")
|
||||||
|
|
||||||
|
# 1. Quick check if server is running
|
||||||
|
ping_url = f"http://127.0.0.1:{port}/ping"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(ping_url, timeout=1.0) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
# Server not running, silently exit
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 2. Extract git metadata
|
||||||
|
commit = run_git(["rev-parse", "HEAD"])
|
||||||
|
branch = run_git(["branch", "--show-current"])
|
||||||
|
author = run_git(["log", "-1", "--pretty=format:%an"])
|
||||||
|
message = run_git(["log", "-1", "--pretty=format:%B"])
|
||||||
|
|
||||||
|
# Get changed files in this commit
|
||||||
|
files_raw = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])
|
||||||
|
files = [f.strip() for f in files_raw.splitlines() if f.strip()]
|
||||||
|
|
||||||
|
if not commit or not message:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"commit": commit,
|
||||||
|
"branch": branch,
|
||||||
|
"author": author,
|
||||||
|
"message": message,
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Post to reconciliation endpoint
|
||||||
|
reconcile_url = f"http://127.0.0.1:{port}/api/git/commit"
|
||||||
|
req_data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
reconcile_url,
|
||||||
|
data=req_data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
res = body.get("reconciliation", {})
|
||||||
|
adrs = res.get("implemented_adrs", [])
|
||||||
|
debts = res.get("resolved_tech_debts", [])
|
||||||
|
tasks = res.get("completed_tasks", [])
|
||||||
|
unblocked = res.get("unblocked_tasks", [])
|
||||||
|
milestones = res.get("updated_milestones", [])
|
||||||
|
|
||||||
|
if any([adrs, debts, tasks, unblocked, milestones]):
|
||||||
|
print("\033[36m[MCP Memory Auto-Reconciled]\033[0m")
|
||||||
|
if adrs:
|
||||||
|
print(f" \033[32m✔ Implemented ADRs:\033[0m {', '.join(adrs)}")
|
||||||
|
if debts:
|
||||||
|
print(f" \033[32m✔ Resolved Tech Debts:\033[0m {', '.join(debts)}")
|
||||||
|
if tasks:
|
||||||
|
print(f" \033[32m✔ Completed Tasks:\033[0m {', '.join(tasks)}")
|
||||||
|
if unblocked:
|
||||||
|
print(f" \033[33m⚡ Unblocked Dependent Tasks:\033[0m {', '.join(unblocked)}")
|
||||||
|
if milestones:
|
||||||
|
print(f" \033[35m🎯 Updated Milestones:\033[0m {', '.join(milestones)}")
|
||||||
|
except Exception:
|
||||||
|
# Never break commit flow on failure
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -36,6 +36,10 @@ pub async fn gate_verify_handler(
|
|||||||
State(app_state): State<Arc<AppState>>,
|
State(app_state): State<Arc<AppState>>,
|
||||||
Query(q): Query<GateVerifyReq>,
|
Query(q): Query<GateVerifyReq>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
let mut found = None;
|
let mut found = None;
|
||||||
let mut to_remove = None;
|
let mut to_remove = None;
|
||||||
app_state.handler.state.env.gates.modify(|gates| {
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
@@ -44,6 +48,7 @@ pub async fn gate_verify_handler(
|
|||||||
&& g.target == q.target
|
&& g.target == q.target
|
||||||
&& g.namespace == q.namespace
|
&& g.namespace == q.namespace
|
||||||
&& g.params == q.params
|
&& g.params == q.params
|
||||||
|
&& g.expires_at.is_none_or(|exp| exp > now)
|
||||||
}) {
|
}) {
|
||||||
found = Some(gates[idx].clone());
|
found = Some(gates[idx].clone());
|
||||||
if q.consume {
|
if q.consume {
|
||||||
|
|||||||
+98
-1
@@ -63,6 +63,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
.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("/api/terminal/history", get(crate::api::telemetry::get_terminal_history_handler))
|
||||||
.route("/terminal/telemetry", post(crate::api::telemetry::terminal_telemetry_handler))
|
.route("/terminal/telemetry", post(crate::api::telemetry::terminal_telemetry_handler))
|
||||||
.route("/events/wait", get(crate::api::events::wait_for_event_handler))
|
.route("/events/wait", get(crate::api::events::wait_for_event_handler))
|
||||||
@@ -187,11 +188,52 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
for t in tasks.iter_mut() {
|
for t in tasks.iter_mut() {
|
||||||
if t.id == id {
|
if t.id == id {
|
||||||
t.status = "completed".to_string();
|
t.status = "completed".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
axum::Json(serde_json::json!({"status": "success"}))
|
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&state_clone);
|
||||||
|
let milestones = crate::handlers::reconciliation::recalculate_milestones(&state_clone);
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"status": "success",
|
||||||
|
"unblocked_tasks": unblocked,
|
||||||
|
"updated_milestones": milestones
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/git/commit",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::Json(payload): axum::Json<crate::handlers::reconciliation::GitCommitPayload>| async move {
|
||||||
|
let result = crate::handlers::reconciliation::reconcile_commit_or_code_change(
|
||||||
|
&state_clone,
|
||||||
|
&payload.message,
|
||||||
|
payload.files.first().map(|s| s.as_str()),
|
||||||
|
Some(&payload.commit),
|
||||||
|
payload.branch.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
axum::Json(result)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/reconcile/commit",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::Json(payload): axum::Json<crate::handlers::reconciliation::GitCommitPayload>| async move {
|
||||||
|
let result = crate::handlers::reconciliation::reconcile_commit_or_code_change(
|
||||||
|
&state_clone,
|
||||||
|
&payload.message,
|
||||||
|
payload.files.first().map(|s| s.as_str()),
|
||||||
|
Some(&payload.commit),
|
||||||
|
payload.branch.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
axum::Json(result)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -313,6 +355,25 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
let json = state_clone.project.milestones.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
let json = state_clone.project.milestones.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::Json(payload): axum::Json<serde_json::Value>| async move {
|
||||||
|
use crate::router::McpTool;
|
||||||
|
let handler = crate::handlers::tasks::MilestonesHandler;
|
||||||
|
match handler.execute(payload, state_clone).await {
|
||||||
|
Ok(msg) => (
|
||||||
|
axum::http::StatusCode::OK,
|
||||||
|
axum::Json(serde_json::json!({ "status": "success", "message": msg })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
axum::Json(serde_json::json!({ "status": "error", "message": e.to_string() })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
@@ -908,4 +969,40 @@ mod tests {
|
|||||||
assert_eq!(summaries.len(), 1);
|
assert_eq!(summaries.len(), 1);
|
||||||
assert_eq!(summaries[0].summary, "Session 2 summary");
|
assert_eq!(summaries[0].summary, "Session 2 summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_git_commit_endpoint() {
|
||||||
|
let (app, app_state, _dir) = setup_app().await;
|
||||||
|
|
||||||
|
app_state.handler.state.code.adrs.modify(|adrs| {
|
||||||
|
adrs.push(crate::models::Adr {
|
||||||
|
id: "ADR-0102".to_string(),
|
||||||
|
title: "Neovim Dynamic Projection".to_string(),
|
||||||
|
status: "accepted".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"commit": "git-hash-999",
|
||||||
|
"branch": "master",
|
||||||
|
"message": "feat: implement ADR-0102 and finish architecture",
|
||||||
|
"files": ["nvim-core/src/lib.rs"]
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/git/commit")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(payload.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = app.oneshot(request).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let adr = app_state.handler.state.code.adrs.read_with(|adrs| adrs[0].clone());
|
||||||
|
assert_eq!(adr.status, "implemented");
|
||||||
|
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
|
||||||
|
assert!(adr.resolved_at.is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+331
-4
@@ -1,7 +1,114 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::extract::State;
|
use axum::extract::{Query, State};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
||||||
pub struct NvimTelemetry {
|
pub struct NvimTelemetry {
|
||||||
pub session_id: String,
|
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!({
|
let ws_msg = serde_json::json!({
|
||||||
"type": "nvim_telemetry",
|
"type": "nvim_telemetry",
|
||||||
"data": payload
|
"data": payload,
|
||||||
|
"tech_debts": tech_debts,
|
||||||
|
"adrs": adrs
|
||||||
});
|
});
|
||||||
|
|
||||||
let msg_str = ws_msg.to_string();
|
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;
|
use crate::models::TerminalHistory;
|
||||||
@@ -205,4 +342,194 @@ mod tests {
|
|||||||
assert_eq!(history.len(), 1);
|
assert_eq!(history.len(), 1);
|
||||||
assert_eq!(history[0].command, "echo test");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+191
-1
@@ -21,6 +21,7 @@
|
|||||||
--btn-border: #dcdde1;
|
--btn-border: #dcdde1;
|
||||||
--btn-hover: #f8f9fa;
|
--btn-hover: #f8f9fa;
|
||||||
--search-hover: #f0f2f5;
|
--search-hover: #f0f2f5;
|
||||||
|
--input-bg: #ffffff;
|
||||||
--input-text: #2c3e50;
|
--input-text: #2c3e50;
|
||||||
--inspector-bg: rgba(255,255,255,0.98);
|
--inspector-bg: rgba(255,255,255,0.98);
|
||||||
--success-color: #27ae60;
|
--success-color: #27ae60;
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
--btn-border: #3d4a57;
|
--btn-border: #3d4a57;
|
||||||
--btn-hover: #3d4a57;
|
--btn-hover: #3d4a57;
|
||||||
--search-hover: #2c3643;
|
--search-hover: #2c3643;
|
||||||
|
--input-bg: #1e242b;
|
||||||
--input-text: #d1d8e0;
|
--input-text: #d1d8e0;
|
||||||
--inspector-bg: rgba(30, 36, 43, 0.98);
|
--inspector-bg: rgba(30, 36, 43, 0.98);
|
||||||
--success-color: #2ecc71;
|
--success-color: #2ecc71;
|
||||||
@@ -464,6 +466,182 @@
|
|||||||
color: #7f8c8d;
|
color: #7f8c8d;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Enterprise UX/HCI Styles --- */
|
||||||
|
.enterprise-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.enterprise-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.enterprise-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 30px 8px 32px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
color: var(--input-text) !important;
|
||||||
|
font-size: 0.88em !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.enterprise-search-input:focus {
|
||||||
|
border-color: #3498db !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2) !important;
|
||||||
|
}
|
||||||
|
.enterprise-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.enterprise-clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.enterprise-clear-btn:hover {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
.enterprise-filters-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.enterprise-select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--input-text);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.enterprise-select:focus {
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.enterprise-btn {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.enterprise-btn:hover {
|
||||||
|
background: var(--btn-hover);
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.enterprise-btn.active {
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2980b9;
|
||||||
|
}
|
||||||
|
.enterprise-meta-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.enterprise-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.enterprise-pagination button {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.enterprise-pagination button:hover:not(:disabled) {
|
||||||
|
background: var(--btn-hover);
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.enterprise-pagination button.active {
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2980b9;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.enterprise-pagination button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.enterprise-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 36px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95em;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.enterprise-kbd {
|
||||||
|
font-size: 0.72em;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.enterprise-cards-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -541,7 +719,19 @@
|
|||||||
|
|
||||||
<div id="task-tab" class="tab-content">
|
<div id="task-tab" class="tab-content">
|
||||||
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
|
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
|
||||||
<h3 style="margin-top:0;">Task Network (HTN)</h3>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;">
|
||||||
|
<h3 style="margin:0;">Task Network (HTN)</h3>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<input type="text" id="task-filter-input" placeholder="Filter tasks..." oninput="handleTaskFilter(this.value)" style="padding:6px 10px; font-size:0.85em; border-radius:6px; border:1px solid var(--border-color); background:var(--input-bg); color:var(--input-text); width:200px; margin-bottom:0;">
|
||||||
|
<select id="task-status-filter" onchange="handleTaskStatusFilter(this.value)" class="enterprise-select">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="blocked">Blocked</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div>
|
<div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+661
-43
@@ -157,10 +157,10 @@ function toggleFilter(type) {
|
|||||||
updateGraphData();
|
updateGraphData();
|
||||||
}
|
}
|
||||||
function renderFilters() {
|
function renderFilters() {
|
||||||
const container2 = document.getElementById("graph-filters");
|
const container = document.getElementById("graph-filters");
|
||||||
if (!container2)
|
if (!container)
|
||||||
return;
|
return;
|
||||||
container2.innerHTML = Array.from(allTypes).sort().map((type) => {
|
container.innerHTML = Array.from(allTypes).sort().map((type) => {
|
||||||
const isActive = activeFilters.has(type);
|
const isActive = activeFilters.has(type);
|
||||||
const color = getColorForType(type).bg || getColorForType(type).background;
|
const color = getColorForType(type).bg || getColorForType(type).background;
|
||||||
return `<button class="filter-chip ${isActive ? "active" : ""}" onclick="toggleFilter('${type}')">
|
return `<button class="filter-chip ${isActive ? "active" : ""}" onclick="toggleFilter('${type}')">
|
||||||
@@ -227,8 +227,8 @@ async function loadGraph() {
|
|||||||
rawRelations = data.relations || [];
|
rawRelations = data.relations || [];
|
||||||
updateGraphData();
|
updateGraphData();
|
||||||
if (!network) {
|
if (!network) {
|
||||||
const container2 = document.getElementById("network-container");
|
const container = document.getElementById("network-container");
|
||||||
if (!container2)
|
if (!container)
|
||||||
return;
|
return;
|
||||||
const options = {
|
const options = {
|
||||||
nodes: { shape: "dot", size: 16, font: { size: 12 } },
|
nodes: { shape: "dot", size: 16, font: { size: 12 } },
|
||||||
@@ -257,7 +257,7 @@ async function loadGraph() {
|
|||||||
},
|
},
|
||||||
interaction: { hover: true, tooltipDelay: 100, zoomView: false }
|
interaction: { hover: true, tooltipDelay: 100, zoomView: false }
|
||||||
};
|
};
|
||||||
network = new vis.Network(container2, { nodes: nodesData, edges: edgesData }, options);
|
network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
|
||||||
network.once("stabilizationIterationsDone", function() {
|
network.once("stabilizationIterationsDone", function() {
|
||||||
network.setOptions({ physics: { enabled: false } });
|
network.setOptions({ physics: { enabled: false } });
|
||||||
});
|
});
|
||||||
@@ -267,7 +267,7 @@ async function loadGraph() {
|
|||||||
network.on("dragEnd", function() {
|
network.on("dragEnd", function() {
|
||||||
network.setOptions({ physics: { enabled: false } });
|
network.setOptions({ physics: { enabled: false } });
|
||||||
});
|
});
|
||||||
container2.addEventListener("wheel", function(event) {
|
container.addEventListener("wheel", function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const direction = event.deltaY > 0 ? -0.15 : 0.15;
|
const direction = event.deltaY > 0 ? -0.15 : 0.15;
|
||||||
zoomGraph(direction);
|
zoomGraph(direction);
|
||||||
@@ -283,9 +283,9 @@ async function loadGraph() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load graph", err);
|
console.error("Failed to load graph", err);
|
||||||
const container2 = document.getElementById("network-container");
|
const container = document.getElementById("network-container");
|
||||||
if (container2) {
|
if (container) {
|
||||||
container2.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + "</div>";
|
container.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + "</div>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,19 +414,54 @@ function buildTaskTreeHTML(tasks, parentId, depth = 0) {
|
|||||||
});
|
});
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
async function loadTasks() {
|
var cachedTasks = [];
|
||||||
try {
|
var taskFilterQuery = "";
|
||||||
const res = await fetch("/api/tasks");
|
var taskStatusFilter = "";
|
||||||
const tasks = await res.json();
|
function handleTaskFilter(val) {
|
||||||
|
taskFilterQuery = val.toLowerCase().trim();
|
||||||
|
applyTaskFilters();
|
||||||
|
}
|
||||||
|
function handleTaskStatusFilter(val) {
|
||||||
|
taskStatusFilter = val.toLowerCase().trim();
|
||||||
|
applyTaskFilters();
|
||||||
|
}
|
||||||
|
function applyTaskFilters() {
|
||||||
const taskContainer = document.getElementById("task-tree-container");
|
const taskContainer = document.getElementById("task-tree-container");
|
||||||
if (!taskContainer)
|
if (!taskContainer)
|
||||||
return;
|
return;
|
||||||
const rootHtml = buildTaskTreeHTML(tasks, null, 0);
|
let filtered = cachedTasks;
|
||||||
|
if (taskFilterQuery) {
|
||||||
|
filtered = filtered.filter((t) => t.title && t.title.toLowerCase().includes(taskFilterQuery) || t.description && t.description.toLowerCase().includes(taskFilterQuery) || t.id && t.id.toLowerCase().includes(taskFilterQuery) || t.assigned_agent && t.assigned_agent.toLowerCase().includes(taskFilterQuery));
|
||||||
|
}
|
||||||
|
if (taskStatusFilter) {
|
||||||
|
if (taskStatusFilter === "blocked") {
|
||||||
|
filtered = filtered.filter((t) => {
|
||||||
|
const deps = t.dependencies || [];
|
||||||
|
return deps.some((dId) => {
|
||||||
|
const dt = cachedTasks.find((x) => x.id === dId);
|
||||||
|
return dt && dt.status !== "completed" && dt.status !== "done";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter((t) => (t.status || "pending").toLowerCase() === taskStatusFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rootHtml = buildTaskTreeHTML(filtered, null, 0);
|
||||||
if (!rootHtml) {
|
if (!rootHtml) {
|
||||||
taskContainer.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No active tasks.</div>';
|
if (cachedTasks.length === 0) {
|
||||||
|
taskContainer.innerHTML = '<div class="enterprise-empty-state">No active tasks.</div>';
|
||||||
|
} else {
|
||||||
|
taskContainer.innerHTML = '<div class="enterprise-empty-state">No tasks matching current filter.</div>';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
taskContainer.innerHTML = rootHtml;
|
taskContainer.innerHTML = rootHtml;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tasks");
|
||||||
|
cachedTasks = await res.json();
|
||||||
|
applyTaskFilters();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load tasks", err);
|
console.error("Failed to load tasks", err);
|
||||||
}
|
}
|
||||||
@@ -462,14 +497,14 @@ async function handleSearch(e) {
|
|||||||
signal: activeSearchAbortController.signal
|
signal: activeSearchAbortController.signal
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const container2 = document.getElementById("search-results");
|
const container = document.getElementById("search-results");
|
||||||
if (!container2)
|
if (!container)
|
||||||
return;
|
return;
|
||||||
if (!data.results || data.results.length === 0) {
|
if (!data.results || data.results.length === 0) {
|
||||||
container2.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
|
container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container2.innerHTML = data.results.map((r) => `
|
container.innerHTML = data.results.map((r) => `
|
||||||
<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 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>
|
||||||
@@ -775,39 +810,341 @@ function setupWS() {
|
|||||||
setupSSE();
|
setupSSE();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function loadGenericList(endpoint, containerId, formatter) {
|
var listControllers = new Map;
|
||||||
|
function applyListFiltering(containerId) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
let result = state.items.slice();
|
||||||
|
const q = (state.searchQuery || "").trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
result = result.filter((item) => {
|
||||||
|
if (state.options.searchFields) {
|
||||||
|
const fields = state.options.searchFields(item);
|
||||||
|
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return JSON.stringify(item).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.options.filters) {
|
||||||
|
for (const filterDef of state.options.filters) {
|
||||||
|
const activeVal = state.activeFilters[filterDef.id];
|
||||||
|
if (activeVal !== undefined && activeVal !== "") {
|
||||||
|
result = result.filter((item) => filterDef.predicate(item, activeVal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const factor = state.sortOrder === "desc" ? -1 : 1;
|
||||||
|
const extractor = state.options.sortExtractor || ((item) => {
|
||||||
|
if (state.options.timestampExtractor) {
|
||||||
|
const ts = state.options.timestampExtractor(item);
|
||||||
|
if (ts != null)
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
return item.timestamp || item.created_at || item.id || 0;
|
||||||
|
});
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const valA = extractor(a);
|
||||||
|
const valB = extractor(b);
|
||||||
|
if (typeof valA === "number" && typeof valB === "number") {
|
||||||
|
return (valA - valB) * factor;
|
||||||
|
}
|
||||||
|
return String(valA).localeCompare(String(valB)) * factor;
|
||||||
|
});
|
||||||
|
state.filteredItems = result;
|
||||||
|
}
|
||||||
|
function renderListCardsAndPagination(containerId) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
const totalItems = state.filteredItems.length;
|
||||||
|
const pageSize = state.pageSize;
|
||||||
|
const totalPages = pageSize <= 0 ? 1 : Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
if (state.currentPage > totalPages) {
|
||||||
|
state.currentPage = totalPages;
|
||||||
|
}
|
||||||
|
if (state.currentPage < 1) {
|
||||||
|
state.currentPage = 1;
|
||||||
|
}
|
||||||
|
const startIndex = totalItems === 0 ? 0 : (state.currentPage - 1) * (pageSize > 0 ? pageSize : totalItems) + 1;
|
||||||
|
const endIndex = pageSize <= 0 ? totalItems : Math.min(state.currentPage * pageSize, totalItems);
|
||||||
|
const metaEl = document.getElementById(`${containerId}-meta`);
|
||||||
|
if (metaEl) {
|
||||||
|
const itemNoun = state.options.itemNoun || "items";
|
||||||
|
let summaryText = "";
|
||||||
|
if (totalItems === 0) {
|
||||||
|
summaryText = `0 ${itemNoun}`;
|
||||||
|
} else {
|
||||||
|
summaryText = `Showing <strong>${startIndex}–${endIndex}</strong> of <strong>${totalItems}</strong> ${itemNoun}`;
|
||||||
|
}
|
||||||
|
if (totalItems !== state.items.length) {
|
||||||
|
summaryText += ` <span style="color:var(--text-secondary);">(filtered from ${state.items.length} total)</span>`;
|
||||||
|
}
|
||||||
|
metaEl.innerHTML = `<div>${summaryText}</div><div>Page <strong>${state.currentPage}</strong> of <strong>${totalPages}</strong></div>`;
|
||||||
|
}
|
||||||
|
const cardsEl = document.getElementById(`${containerId}-cards`);
|
||||||
|
if (cardsEl) {
|
||||||
|
if (totalItems === 0) {
|
||||||
|
const itemNoun = state.options.itemNoun || "items";
|
||||||
|
if (state.items.length === 0) {
|
||||||
|
cardsEl.innerHTML = `<div class="enterprise-empty-state">No ${itemNoun} recorded.</div>`;
|
||||||
|
} else {
|
||||||
|
cardsEl.innerHTML = `
|
||||||
|
<div class="enterprise-empty-state">
|
||||||
|
<div>\uD83D\uDD0D No matching ${itemNoun} found for current filter.</div>
|
||||||
|
<button class="enterprise-btn" style="margin-top:12px;" onclick="onListResetFilters('${containerId}')">Reset Filters</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pageSlice = pageSize <= 0 ? state.filteredItems : state.filteredItems.slice((state.currentPage - 1) * pageSize, state.currentPage * pageSize);
|
||||||
|
cardsEl.innerHTML = pageSlice.map((item) => {
|
||||||
|
const ts = state.options.timestampExtractor ? state.options.timestampExtractor(item) : item.timestamp || item.created_at || null;
|
||||||
|
const dateStr = ts ? new Date(ts * 1000).toLocaleString() : "";
|
||||||
|
const borderCol = state.options.cardBorderColor || "#9b59b6";
|
||||||
|
return `
|
||||||
|
<div class="task-card" style="border-left-color: ${borderCol};">
|
||||||
|
${dateStr ? `<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${dateStr}</div>` : ""}
|
||||||
|
${state.formatter(item)}
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const paginationEl = document.getElementById(`${containerId}-pagination`);
|
||||||
|
if (paginationEl) {
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
paginationEl.style.display = "none";
|
||||||
|
paginationEl.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
paginationEl.style.display = "flex";
|
||||||
|
let pagHtml = "";
|
||||||
|
pagHtml += `<button onclick="onListPageChange('${containerId}', 1)" ${state.currentPage === 1 ? "disabled" : ""} title="First Page">⏮</button>`;
|
||||||
|
pagHtml += `<button onclick="onListPageChange('${containerId}', ${state.currentPage - 1})" ${state.currentPage === 1 ? "disabled" : ""} title="Previous Page">◀</button>`;
|
||||||
|
const cur = state.currentPage;
|
||||||
|
const startP = Math.max(1, cur - 2);
|
||||||
|
const endP = Math.min(totalPages, cur + 2);
|
||||||
|
if (startP > 1) {
|
||||||
|
pagHtml += `<button onclick="onListPageChange('${containerId}', 1)">1</button>`;
|
||||||
|
if (startP > 2)
|
||||||
|
pagHtml += `<span style="padding:0 4px; color:var(--text-secondary);">…</span>`;
|
||||||
|
}
|
||||||
|
for (let p = startP;p <= endP; p++) {
|
||||||
|
pagHtml += `<button class="${p === cur ? "active" : ""}" onclick="onListPageChange('${containerId}', ${p})">${p}</button>`;
|
||||||
|
}
|
||||||
|
if (endP < totalPages) {
|
||||||
|
if (endP < totalPages - 1)
|
||||||
|
pagHtml += `<span style="padding:0 4px; color:var(--text-secondary);">…</span>`;
|
||||||
|
pagHtml += `<button onclick="onListPageChange('${containerId}', ${totalPages})">${totalPages}</button>`;
|
||||||
|
}
|
||||||
|
pagHtml += `<button onclick="onListPageChange('${containerId}', ${state.currentPage + 1})" ${state.currentPage === totalPages ? "disabled" : ""} title="Next Page">▶</button>`;
|
||||||
|
pagHtml += `<button onclick="onListPageChange('${containerId}', ${totalPages})" ${state.currentPage === totalPages ? "disabled" : ""} title="Last Page">⏭</button>`;
|
||||||
|
paginationEl.innerHTML = pagHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clearBtn = document.getElementById(`${containerId}-clear`);
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.style.display = state.searchQuery ? "block" : "none";
|
||||||
|
}
|
||||||
|
const sortBtn = document.getElementById(`${containerId}-sort-btn`);
|
||||||
|
if (sortBtn) {
|
||||||
|
sortBtn.innerHTML = state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderListStructure(containerId) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container)
|
||||||
|
return;
|
||||||
|
const itemNoun = state.options.itemNoun || "items";
|
||||||
|
let filtersHtml = "";
|
||||||
|
if (state.options.filters && state.options.filters.length > 0) {
|
||||||
|
for (const f of state.options.filters) {
|
||||||
|
const activeVal = state.activeFilters[f.id] || "";
|
||||||
|
filtersHtml += `
|
||||||
|
<select class="enterprise-select" id="${containerId}-filter-${f.id}" onchange="onListFilterChange('${containerId}', '${f.id}', this.value)" title="Filter by ${f.label}">
|
||||||
|
${f.options.map((opt) => `<option value="${escapeHtml(opt.value)}" ${opt.value === activeVal ? "selected" : ""}>${escapeHtml(opt.label)}</option>`).join("")}
|
||||||
|
</select>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const curPageSize = state.pageSize;
|
||||||
|
const pageSizeOptions = [
|
||||||
|
{ label: "10 / page", value: "10" },
|
||||||
|
{ label: "25 / page", value: "25" },
|
||||||
|
{ label: "50 / page", value: "50" },
|
||||||
|
{ label: "100 / page", value: "100" },
|
||||||
|
{ label: "All items", value: "-1" }
|
||||||
|
];
|
||||||
|
const pageSizeHtml = `
|
||||||
|
<select class="enterprise-select" id="${containerId}-pagesize" onchange="onListPageSizeChange('${containerId}', this.value)" title="Items per page">
|
||||||
|
${pageSizeOptions.map((opt) => `<option value="${opt.value}" ${parseInt(opt.value, 10) === curPageSize ? "selected" : ""}>${opt.label}</option>`).join("")}
|
||||||
|
</select>`;
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="enterprise-toolbar" id="${containerId}-toolbar">
|
||||||
|
<div class="enterprise-search-wrap">
|
||||||
|
<span class="enterprise-search-icon">\uD83D\uDD0D</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="enterprise-search-input"
|
||||||
|
id="${containerId}-search"
|
||||||
|
placeholder="Filter ${itemNoun} (/)..."
|
||||||
|
value="${escapeHtml(state.searchQuery)}"
|
||||||
|
oninput="onListSearchInput('${containerId}', this.value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="enterprise-clear-btn"
|
||||||
|
id="${containerId}-clear"
|
||||||
|
onclick="onListClearSearch('${containerId}')"
|
||||||
|
title="Clear search"
|
||||||
|
style="display:${state.searchQuery ? "block" : "none"};"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="enterprise-filters-group">
|
||||||
|
${filtersHtml}
|
||||||
|
<button
|
||||||
|
class="enterprise-btn"
|
||||||
|
id="${containerId}-sort-btn"
|
||||||
|
onclick="onListToggleSort('${containerId}')"
|
||||||
|
title="Toggle sort direction"
|
||||||
|
>
|
||||||
|
${state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First"}
|
||||||
|
</button>
|
||||||
|
${pageSizeHtml}
|
||||||
|
<button
|
||||||
|
class="enterprise-btn"
|
||||||
|
onclick="onListResetFilters('${containerId}')"
|
||||||
|
title="Reset all filters and sorting"
|
||||||
|
>
|
||||||
|
\uD83D\uDD04 Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="enterprise-meta-bar" id="${containerId}-meta"></div>
|
||||||
|
<div class="enterprise-cards-list" id="${containerId}-cards"></div>
|
||||||
|
<div class="enterprise-pagination" id="${containerId}-pagination"></div>
|
||||||
|
`;
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
}
|
||||||
|
function onListSearchInput(containerId, value) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
if (state.debounceTimer)
|
||||||
|
clearTimeout(state.debounceTimer);
|
||||||
|
state.searchQuery = value;
|
||||||
|
state.debounceTimer = setTimeout(() => {
|
||||||
|
state.currentPage = 1;
|
||||||
|
applyListFiltering(containerId);
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
function onListClearSearch(containerId) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state.searchQuery = "";
|
||||||
|
const inputEl = document.getElementById(`${containerId}-search`);
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.value = "";
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
state.currentPage = 1;
|
||||||
|
applyListFiltering(containerId);
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
}
|
||||||
|
function onListFilterChange(containerId, filterId, value) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state.activeFilters[filterId] = value;
|
||||||
|
state.currentPage = 1;
|
||||||
|
applyListFiltering(containerId);
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
}
|
||||||
|
function onListToggleSort(containerId) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state.sortOrder = state.sortOrder === "desc" ? "asc" : "desc";
|
||||||
|
applyListFiltering(containerId);
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
}
|
||||||
|
function onListPageSizeChange(containerId, value) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state.pageSize = parseInt(value, 10);
|
||||||
|
state.currentPage = 1;
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
}
|
||||||
|
function onListPageChange(containerId, page) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state.currentPage = page;
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (container) {
|
||||||
|
container.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onListResetFilters(containerId) {
|
||||||
|
const state = listControllers.get(containerId);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state.searchQuery = "";
|
||||||
|
state.activeFilters = {};
|
||||||
|
state.sortOrder = state.options.defaultSort || "desc";
|
||||||
|
state.pageSize = state.options.pageSize || 25;
|
||||||
|
state.currentPage = 1;
|
||||||
|
applyListFiltering(containerId);
|
||||||
|
renderListStructure(containerId);
|
||||||
|
}
|
||||||
|
async function loadGenericList(endpoint, containerId, formatter, options) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container)
|
||||||
|
return;
|
||||||
|
const resolvedOptions = options || {};
|
||||||
|
const existingState = listControllers.get(containerId);
|
||||||
|
const state = existingState || {
|
||||||
|
endpoint,
|
||||||
|
containerId,
|
||||||
|
formatter,
|
||||||
|
options: resolvedOptions,
|
||||||
|
items: [],
|
||||||
|
filteredItems: [],
|
||||||
|
searchQuery: "",
|
||||||
|
activeFilters: {},
|
||||||
|
sortOrder: resolvedOptions.defaultSort || "desc",
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: resolvedOptions.pageSize || 25,
|
||||||
|
debounceTimer: null
|
||||||
|
};
|
||||||
|
state.options = resolvedOptions;
|
||||||
|
state.formatter = formatter;
|
||||||
|
listControllers.set(containerId, state);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint);
|
const res = await fetch(endpoint);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (container2) {
|
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${escapeHtml(endpoint)} (${res.status} ${escapeHtml(res.statusText)})</div>`;
|
||||||
container2.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})</div>`;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const container2 = document.getElementById(containerId);
|
state.items = Array.isArray(data) ? data : [];
|
||||||
if (!container2)
|
applyListFiltering(containerId);
|
||||||
return;
|
const toolbarExists = document.getElementById(`${containerId}-toolbar`);
|
||||||
if (!data || data.length === 0) {
|
if (!toolbarExists) {
|
||||||
container2.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No items recorded.</div>';
|
renderListStructure(containerId);
|
||||||
return;
|
} else {
|
||||||
|
renderListCardsAndPagination(containerId);
|
||||||
}
|
}
|
||||||
container2.innerHTML = data.map((item) => {
|
|
||||||
const date = item.timestamp ? new Date(item.timestamp * 1000).toLocaleString() : "";
|
|
||||||
return `<div class="task-card" style="border-left-color: #9b59b6;">
|
|
||||||
<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${date}</div>
|
|
||||||
${formatter(item)}
|
|
||||||
</div>`;
|
|
||||||
}).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 ${escapeHtml(endpoint)} (${escapeHtml(String(err))})</div>`;
|
||||||
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() {
|
||||||
loadGenericList("/api/ledger?limit=100", "ledger-container", (item) => {
|
loadGenericList("/api/ledger?limit=500", "ledger-container", (item) => {
|
||||||
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toUpperCase();
|
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toUpperCase();
|
||||||
const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db";
|
const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db";
|
||||||
const rev = item.revision || item.git_commit;
|
const rev = item.revision || item.git_commit;
|
||||||
@@ -848,6 +1185,48 @@ function loadLedger() {
|
|||||||
${authorBadge}
|
${authorBadge}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Ledger Entries",
|
||||||
|
cardBorderColor: "#3498db",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.file_path,
|
||||||
|
item.description,
|
||||||
|
item.author,
|
||||||
|
item.repo_name,
|
||||||
|
item.branch,
|
||||||
|
item.git_commit,
|
||||||
|
item.revision,
|
||||||
|
Array.isArray(item.symbols) ? item.symbols.join(" ") : ""
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "vcs",
|
||||||
|
label: "VCS",
|
||||||
|
options: [
|
||||||
|
{ label: "All VCS", value: "" },
|
||||||
|
{ label: "Git", value: "git" },
|
||||||
|
{ label: "SVN", value: "svn" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => {
|
||||||
|
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toLowerCase();
|
||||||
|
return vcs === val.toLowerCase();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kind",
|
||||||
|
label: "Change Kind",
|
||||||
|
options: [
|
||||||
|
{ label: "All Types", value: "" },
|
||||||
|
{ label: "+ Added", value: "added" },
|
||||||
|
{ label: "Modified", value: "modified" },
|
||||||
|
{ label: "- Deleted", value: "deleted" },
|
||||||
|
{ label: "→ Renamed", value: "renamed" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => (item.change_kind || "modified").toLowerCase() === val.toLowerCase()
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function loadTerminal() {
|
function loadTerminal() {
|
||||||
@@ -880,6 +1259,38 @@ function loadTerminal() {
|
|||||||
</details>
|
</details>
|
||||||
` : ""}
|
` : ""}
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Commands",
|
||||||
|
cardBorderColor: "#1abc9c",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.command,
|
||||||
|
item.cwd,
|
||||||
|
item.repo_name,
|
||||||
|
item.invoker,
|
||||||
|
item.shell,
|
||||||
|
item.stdout_summary,
|
||||||
|
item.error_output
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Exit Code",
|
||||||
|
options: [
|
||||||
|
{ label: "All Results", value: "" },
|
||||||
|
{ label: "Success (0)", value: "0" },
|
||||||
|
{ label: "Failed (!= 0)", value: "err" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => {
|
||||||
|
if (val === "0")
|
||||||
|
return item.exit_code === 0;
|
||||||
|
if (val === "err")
|
||||||
|
return item.exit_code !== 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function loadTechDebt() {
|
function loadTechDebt() {
|
||||||
@@ -923,6 +1334,50 @@ function loadTechDebt() {
|
|||||||
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">\uD83C\uDFF7️ ${escapeHtml(item.namespace || "global")}</span>
|
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">\uD83C\uDFF7️ ${escapeHtml(item.namespace || "global")}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Tech Debts",
|
||||||
|
cardBorderColor: "#e67e22",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.id,
|
||||||
|
item.description,
|
||||||
|
item.ideal_solution,
|
||||||
|
item.workaround,
|
||||||
|
item.file_path,
|
||||||
|
item.repo_name,
|
||||||
|
item.severity
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
options: [
|
||||||
|
{ label: "All Statuses", value: "" },
|
||||||
|
{ label: "Open", value: "open" },
|
||||||
|
{ label: "Resolved", value: "resolved" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => {
|
||||||
|
if (val === "resolved")
|
||||||
|
return !!item.is_resolved;
|
||||||
|
if (val === "open")
|
||||||
|
return !item.is_resolved;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "severity",
|
||||||
|
label: "Severity",
|
||||||
|
options: [
|
||||||
|
{ label: "All Severities", value: "" },
|
||||||
|
{ label: "Critical", value: "critical" },
|
||||||
|
{ label: "High", value: "high" },
|
||||||
|
{ label: "Medium", value: "medium" },
|
||||||
|
{ label: "Low", value: "low" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => String(item.severity || "").toLowerCase() === val.toLowerCase()
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
loadGenericList("/api/error_fixes", "errorfixes-container", (item) => `
|
loadGenericList("/api/error_fixes", "errorfixes-container", (item) => `
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
|
||||||
@@ -945,7 +1400,34 @@ function loadTechDebt() {
|
|||||||
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">Commit: ${item.git_commit ? escapeHtml(item.git_commit.substring(0, 8)) : "None"}</span>
|
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">Commit: ${item.git_commit ? escapeHtml(item.git_commit.substring(0, 8)) : "None"}</span>
|
||||||
${item.git_branch ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83C\uDF3F ${escapeHtml(item.git_branch)}</span>` : ""}
|
${item.git_branch ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83C\uDF3F ${escapeHtml(item.git_branch)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`);
|
`, {
|
||||||
|
itemNoun: "Error Fixes",
|
||||||
|
cardBorderColor: "#2ecc71",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.signature,
|
||||||
|
item.solution,
|
||||||
|
item.toolchain,
|
||||||
|
item.error_category,
|
||||||
|
item.repo_name,
|
||||||
|
item.stack_trace
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "category",
|
||||||
|
label: "Category",
|
||||||
|
options: [
|
||||||
|
{ label: "All Categories", value: "" },
|
||||||
|
{ label: "Compilation", value: "compilation" },
|
||||||
|
{ label: "Runtime", value: "runtime" },
|
||||||
|
{ label: "Test", value: "test" },
|
||||||
|
{ label: "Configuration", value: "configuration" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => String(item.error_category || "").toLowerCase() === val.toLowerCase()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function loadADRs() {
|
function loadADRs() {
|
||||||
loadGenericList("/api/adrs", "adrs-container", (item) => {
|
loadGenericList("/api/adrs", "adrs-container", (item) => {
|
||||||
@@ -980,6 +1462,41 @@ function loadADRs() {
|
|||||||
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${escapeHtml(item.consequence || "")}</div>
|
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${escapeHtml(item.consequence || "")}</div>
|
||||||
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${escapeHtml(item.supersedes)}</div>` : ""}
|
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${escapeHtml(item.supersedes)}</div>` : ""}
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "ADRs",
|
||||||
|
cardBorderColor: "#3498db",
|
||||||
|
defaultSort: "desc",
|
||||||
|
sortExtractor: (item) => {
|
||||||
|
const match = String(item.id || "").match(/\d+/);
|
||||||
|
if (match)
|
||||||
|
return parseInt(match[0], 10);
|
||||||
|
return item.timestamp || 0;
|
||||||
|
},
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.id,
|
||||||
|
item.title,
|
||||||
|
item.context,
|
||||||
|
item.decision,
|
||||||
|
item.consequence,
|
||||||
|
item.status,
|
||||||
|
item.git_branch,
|
||||||
|
item.task_id
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
options: [
|
||||||
|
{ label: "All Statuses", value: "" },
|
||||||
|
{ label: "Accepted", value: "accepted" },
|
||||||
|
{ label: "Implemented", value: "implemented" },
|
||||||
|
{ label: "Proposed", value: "proposed" },
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
|
{ label: "Superseded", value: "superseded" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => String(item.status || "").toLowerCase() === val.toLowerCase()
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function loadMemos() {
|
function loadMemos() {
|
||||||
@@ -1016,6 +1533,18 @@ function loadMemos() {
|
|||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Memos",
|
||||||
|
cardBorderColor: "#9b59b6",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.author,
|
||||||
|
item.content,
|
||||||
|
item.summary,
|
||||||
|
item.namespace,
|
||||||
|
item.git_branch
|
||||||
|
]
|
||||||
});
|
});
|
||||||
loadGenericList("/api/session_summaries", "summaries-container", (item) => `
|
loadGenericList("/api/session_summaries", "summaries-container", (item) => `
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
@@ -1026,7 +1555,18 @@ function loadMemos() {
|
|||||||
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4; margin-top:8px;">${escapeHtml(item.summary || "")}</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.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>` : ""}
|
${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>` : ""}
|
||||||
`);
|
`, {
|
||||||
|
itemNoun: "Summaries",
|
||||||
|
cardBorderColor: "#34495e",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.session_id,
|
||||||
|
item.summary,
|
||||||
|
item.namespace,
|
||||||
|
item.git_branch
|
||||||
|
]
|
||||||
|
});
|
||||||
loadGenericList("/api/milestones", "milestones-container", (item) => {
|
loadGenericList("/api/milestones", "milestones-container", (item) => {
|
||||||
const statusLower = (item.status || "pending").toLowerCase();
|
const statusLower = (item.status || "pending").toLowerCase();
|
||||||
let statusBg = "rgba(52, 152, 219, 0.15)";
|
let statusBg = "rgba(52, 152, 219, 0.15)";
|
||||||
@@ -1072,6 +1612,30 @@ function loadMemos() {
|
|||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Milestones",
|
||||||
|
cardBorderColor: "#f39c12",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp || item.target_date,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.title,
|
||||||
|
item.name,
|
||||||
|
item.description,
|
||||||
|
item.status
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
options: [
|
||||||
|
{ label: "All Statuses", value: "" },
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "In Progress", value: "in_progress" },
|
||||||
|
{ label: "Completed", value: "completed" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => String(item.status || "pending").toLowerCase() === val.toLowerCase()
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function loadSnippets() {
|
function loadSnippets() {
|
||||||
@@ -1095,6 +1659,42 @@ function loadSnippets() {
|
|||||||
${tags.map((t) => `<span style="background:var(--card-bg); padding:1px 6px; border-radius:3px; font-size:0.75em; border:1px solid var(--border-color); color:var(--text-secondary);">#${escapeHtml(t)}</span>`).join(" ")}
|
${tags.map((t) => `<span style="background:var(--card-bg); padding:1px 6px; border-radius:3px; font-size:0.75em; border:1px solid var(--border-color); color:var(--text-secondary);">#${escapeHtml(t)}</span>`).join(" ")}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Snippets",
|
||||||
|
cardBorderColor: "#27ae60",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [
|
||||||
|
item.name,
|
||||||
|
item.description,
|
||||||
|
item.code,
|
||||||
|
item.language,
|
||||||
|
item.origin_file,
|
||||||
|
Array.isArray(item.tags) ? item.tags.join(" ") : ""
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
label: "Language",
|
||||||
|
options: [
|
||||||
|
{ label: "All Languages", value: "" },
|
||||||
|
{ label: "Rust", value: "rust" },
|
||||||
|
{ label: "TypeScript / JS", value: "typescript" },
|
||||||
|
{ label: "Python", value: "python" },
|
||||||
|
{ label: "SQL", value: "sql" },
|
||||||
|
{ label: "Shell / Bash", value: "bash" },
|
||||||
|
{ label: "Markdown / Text", value: "markdown" }
|
||||||
|
],
|
||||||
|
predicate: (item, val) => {
|
||||||
|
const lang = String(item.language || "").toLowerCase();
|
||||||
|
if (val === "typescript")
|
||||||
|
return lang === "typescript" || lang === "javascript" || lang === "ts" || lang === "js";
|
||||||
|
if (val === "bash")
|
||||||
|
return lang === "bash" || lang === "sh" || lang === "pwsh" || lang === "powershell";
|
||||||
|
return lang === val.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function loadAllExtras() {
|
function loadAllExtras() {
|
||||||
@@ -1177,6 +1777,15 @@ document.addEventListener("keydown", function(e) {
|
|||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
searchInput.select();
|
searchInput.select();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") {
|
||||||
|
const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
|
||||||
|
if (activeSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
activeSearch.focus();
|
||||||
|
activeSearch.select();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function refreshActiveTab() {
|
function refreshActiveTab() {
|
||||||
@@ -1225,6 +1834,8 @@ Object.assign(window, {
|
|||||||
completeTask,
|
completeTask,
|
||||||
buildTaskTreeHTML,
|
buildTaskTreeHTML,
|
||||||
loadTasks,
|
loadTasks,
|
||||||
|
handleTaskFilter,
|
||||||
|
handleTaskStatusFilter,
|
||||||
highlightText,
|
highlightText,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
@@ -1234,6 +1845,13 @@ Object.assign(window, {
|
|||||||
handleIncomingActivity,
|
handleIncomingActivity,
|
||||||
setupWS,
|
setupWS,
|
||||||
loadGenericList,
|
loadGenericList,
|
||||||
|
onListSearchInput,
|
||||||
|
onListClearSearch,
|
||||||
|
onListFilterChange,
|
||||||
|
onListToggleSort,
|
||||||
|
onListPageSizeChange,
|
||||||
|
onListPageChange,
|
||||||
|
onListResetFilters,
|
||||||
loadTerminal,
|
loadTerminal,
|
||||||
loadLedger,
|
loadLedger,
|
||||||
loadTechDebt,
|
loadTechDebt,
|
||||||
|
|||||||
+904
-63
File diff suppressed because it is too large.
Load diff
+15
-7
@@ -152,7 +152,6 @@ fn fallback_embed(text: &str) -> Vec<f32> {
|
|||||||
return vec;
|
return vec;
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
for word in words {
|
for word in words {
|
||||||
let clean: String = word
|
let clean: String = word
|
||||||
.chars()
|
.chars()
|
||||||
@@ -163,18 +162,17 @@ fn fallback_embed(text: &str) -> Vec<f32> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
let hash_bytes = blake3::hash(clean.as_bytes());
|
||||||
clean.hash(&mut hasher);
|
let h = u64::from_le_bytes(hash_bytes.as_bytes()[0..8].try_into().unwrap());
|
||||||
let h = hasher.finish();
|
|
||||||
let idx = (h as usize) % DIM;
|
let idx = (h as usize) % DIM;
|
||||||
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
|
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
|
||||||
vec[idx] += sign;
|
vec[idx] += sign;
|
||||||
|
|
||||||
let chars: Vec<char> = clean.chars().collect();
|
let chars: Vec<char> = clean.chars().collect();
|
||||||
for window in chars.windows(3) {
|
for window in chars.windows(3) {
|
||||||
let mut h2 = std::collections::hash_map::DefaultHasher::new();
|
let window_str: String = window.iter().collect();
|
||||||
window.hash(&mut h2);
|
let h2_bytes = blake3::hash(window_str.as_bytes());
|
||||||
let hv = h2.finish();
|
let hv = u64::from_le_bytes(h2_bytes.as_bytes()[0..8].try_into().unwrap());
|
||||||
let idx2 = (hv as usize) % DIM;
|
let idx2 = (hv as usize) % DIM;
|
||||||
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
|
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
|
||||||
vec[idx2] += s2;
|
vec[idx2] += s2;
|
||||||
@@ -378,4 +376,14 @@ mod tests {
|
|||||||
assert_eq!(embedding.len(), 384);
|
assert_eq!(embedding.len(), 384);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fallback_embed_deterministic_stability() {
|
||||||
|
let text = "The quick brown fox jumps over the lazy dog";
|
||||||
|
let emb1 = fallback_embed(text);
|
||||||
|
let emb2 = fallback_embed(text);
|
||||||
|
assert_eq!(emb1.len(), 384);
|
||||||
|
assert_eq!(emb1, emb2);
|
||||||
|
assert!((cosine_similarity(&emb1, &emb2) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+117
-6
@@ -287,8 +287,8 @@ impl McpTool for LogCodeChangeHandler {
|
|||||||
author: req.author,
|
author: req.author,
|
||||||
session_id: req.session_id,
|
session_id: req.session_id,
|
||||||
vcs_type: detected_vcs,
|
vcs_type: detected_vcs,
|
||||||
revision: effective_rev,
|
revision: effective_rev.clone(),
|
||||||
branch: effective_branch,
|
branch: effective_branch.clone(),
|
||||||
repository_root: req.repository_root,
|
repository_root: req.repository_root,
|
||||||
});
|
});
|
||||||
if ledger.len() > 500 {
|
if ledger.len() > 500 {
|
||||||
@@ -300,9 +300,42 @@ impl McpTool for LogCodeChangeHandler {
|
|||||||
&format!("Modified {}", req.file_path),
|
&format!("Modified {}", req.file_path),
|
||||||
Some(&description),
|
Some(&description),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let recon = crate::handlers::reconciliation::reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
&description,
|
||||||
|
Some(&req.file_path),
|
||||||
|
effective_rev.as_deref(),
|
||||||
|
effective_branch.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut recon_notes = Vec::new();
|
||||||
|
if !recon.implemented_adrs.is_empty() {
|
||||||
|
recon_notes.push(format!("Implemented ADRs: {}", recon.implemented_adrs.join(", ")));
|
||||||
|
}
|
||||||
|
if !recon.resolved_tech_debts.is_empty() {
|
||||||
|
recon_notes.push(format!("Resolved TechDebt: {}", recon.resolved_tech_debts.join(", ")));
|
||||||
|
}
|
||||||
|
if !recon.completed_tasks.is_empty() {
|
||||||
|
recon_notes.push(format!("Completed Tasks: {}", recon.completed_tasks.join(", ")));
|
||||||
|
}
|
||||||
|
if !recon.unblocked_tasks.is_empty() {
|
||||||
|
recon_notes.push(format!("Unblocked Tasks: {}", recon.unblocked_tasks.join(", ")));
|
||||||
|
}
|
||||||
|
if !recon.updated_milestones.is_empty() {
|
||||||
|
recon_notes.push(format!("Updated Milestones: {}", recon.updated_milestones.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let recon_suffix = if recon_notes.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" [{}]", recon_notes.join(" | "))
|
||||||
|
};
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Logged code change for {}: {}",
|
"Logged code change for {}: {}{}",
|
||||||
req.file_path, description
|
req.file_path, description, recon_suffix
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,7 +698,20 @@ impl McpTool for TechDebtHandler {
|
|||||||
effort_estimate: req.effort_estimate,
|
effort_estimate: req.effort_estimate,
|
||||||
});
|
});
|
||||||
if debts.len() > 300 {
|
if debts.len() > 300 {
|
||||||
debts.remove(0);
|
let severity_rank = |sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
|
||||||
|
"critical" => 4,
|
||||||
|
"high" => 3,
|
||||||
|
"medium" => 2,
|
||||||
|
"low" => 1,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
if let Some((idx_to_remove, _)) = debts.iter().enumerate().min_by_key(|(_, d)| {
|
||||||
|
let status_score = if d.is_resolved { 0 } else { 10 };
|
||||||
|
let sev_score = severity_rank(d.severity.as_deref());
|
||||||
|
(status_score + sev_score, d.created_at)
|
||||||
|
}) {
|
||||||
|
debts.remove(idx_to_remove);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok("Tech debt logged".to_string())
|
Ok("Tech debt logged".to_string())
|
||||||
@@ -1157,7 +1203,11 @@ impl McpTool for GetProjectHealthHandler {
|
|||||||
let active_milestones = state.project.milestones.read_with(|milestones| {
|
let active_milestones = state.project.milestones.read_with(|milestones| {
|
||||||
milestones
|
milestones
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.namespace == req.namespace && m.status != "done")
|
.filter(|m| {
|
||||||
|
m.namespace == req.namespace
|
||||||
|
&& !m.status.eq_ignore_ascii_case("done")
|
||||||
|
&& !m.status.eq_ignore_ascii_case("completed")
|
||||||
|
})
|
||||||
.count()
|
.count()
|
||||||
});
|
});
|
||||||
let report = serde_json::json!({
|
let report = serde_json::json!({
|
||||||
@@ -2827,4 +2877,65 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(pre_res.contains("active_tasks"));
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,9 @@ pub mod graph;
|
|||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod notes;
|
pub mod notes;
|
||||||
|
pub mod reconciliation;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod vision;
|
pub mod vision;
|
||||||
pub mod workspaces;
|
pub mod workspaces;
|
||||||
|
|
||||||
@@ -108,7 +108,14 @@ impl McpTool for AddSessionSummaryHandler {
|
|||||||
next_steps: req.next_steps.unwrap_or_default(),
|
next_steps: req.next_steps.unwrap_or_default(),
|
||||||
});
|
});
|
||||||
if summaries.len() > 200 {
|
if summaries.len() > 200 {
|
||||||
summaries.remove(0);
|
let now = crate::handlers::utils::now_secs();
|
||||||
|
if let Some((idx_to_remove, _)) = summaries.iter().enumerate().min_by_key(|(_, s)| {
|
||||||
|
let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false);
|
||||||
|
let status_score = if is_expired { 0 } else { 1 };
|
||||||
|
(status_score, s.timestamp)
|
||||||
|
}) {
|
||||||
|
summaries.remove(idx_to_remove);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok("Session summary added".to_string())
|
Ok("Session summary added".to_string())
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
use crate::models::{Adr, TaskEvent};
|
||||||
|
use crate::state::MemoryState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
|
||||||
|
pub struct GitCommitPayload {
|
||||||
|
pub commit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ReconciliationResult {
|
||||||
|
pub implemented_adrs: Vec<String>,
|
||||||
|
pub resolved_tech_debts: Vec<String>,
|
||||||
|
pub completed_tasks: Vec<String>,
|
||||||
|
pub unblocked_tasks: Vec<String>,
|
||||||
|
pub updated_milestones: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102"
|
||||||
|
pub fn extract_adrs(text: &str) -> Vec<String> {
|
||||||
|
let mut adrs = Vec::new();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let lower_chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < len {
|
||||||
|
// Look for "adr"
|
||||||
|
if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] {
|
||||||
|
let mut cursor = i + 3;
|
||||||
|
// Skip optional '-', ':', or whitespace
|
||||||
|
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
// Collect consecutive digits
|
||||||
|
let digit_start = cursor;
|
||||||
|
while cursor < len && chars[cursor].is_ascii_digit() {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if cursor > digit_start {
|
||||||
|
let num_str: String = chars[digit_start..cursor].iter().collect();
|
||||||
|
if let Ok(num) = num_str.parse::<u32>() {
|
||||||
|
let formatted = format!("ADR-{:04}", num);
|
||||||
|
if !adrs.contains(&formatted) {
|
||||||
|
adrs.push(formatted);
|
||||||
|
}
|
||||||
|
let raw_formatted = format!("ADR-{}", num_str);
|
||||||
|
if !adrs.contains(&raw_formatted) {
|
||||||
|
adrs.push(raw_formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = cursor;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts TechDebt identifiers such as "TD-001", "td-auth-cleanup", "TD: 12"
|
||||||
|
pub fn extract_tech_debts(text: &str) -> Vec<String> {
|
||||||
|
let mut debts = Vec::new();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let lower_chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < len {
|
||||||
|
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
|
||||||
|
let mut cursor = i + 2;
|
||||||
|
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
let start = cursor;
|
||||||
|
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if cursor > start {
|
||||||
|
let id_part: String = chars[start..cursor].iter().collect();
|
||||||
|
let full_id = format!("TD-{}", id_part);
|
||||||
|
if !debts.contains(&full_id) {
|
||||||
|
debts.push(full_id);
|
||||||
|
}
|
||||||
|
if !debts.contains(&id_part) {
|
||||||
|
debts.push(id_part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = cursor;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts Task identifiers such as "TASK-123", "task-01", or UUID strings
|
||||||
|
pub fn extract_tasks(text: &str) -> Vec<String> {
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let lower_chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < len {
|
||||||
|
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
|
||||||
|
let mut cursor = i + 4;
|
||||||
|
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
let start = cursor;
|
||||||
|
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if cursor > start {
|
||||||
|
let id_part: String = chars[start..cursor].iter().collect();
|
||||||
|
let full_id = format!("TASK-{}", id_part);
|
||||||
|
if !tasks.contains(&full_id) {
|
||||||
|
tasks.push(full_id);
|
||||||
|
}
|
||||||
|
if !tasks.contains(&id_part) {
|
||||||
|
tasks.push(id_part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = cursor;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adr_numeric_id(id: &str) -> Option<u32> {
|
||||||
|
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
|
digits.parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Automatically cascades unblocking to tasks whose dependencies have all completed.
|
||||||
|
pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
|
||||||
|
let mut unblocked = Vec::new();
|
||||||
|
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
|
||||||
|
|
||||||
|
state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
if t.status.eq_ignore_ascii_case("blocked") {
|
||||||
|
if t.dependencies.is_empty() {
|
||||||
|
t.status = "active".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
|
unblocked.push(t.id.clone());
|
||||||
|
} else {
|
||||||
|
let all_deps_completed = t.dependencies.iter().all(|dep_id| {
|
||||||
|
tasks_snapshot.iter().any(|other| {
|
||||||
|
(other.id == *dep_id || other.title == *dep_id)
|
||||||
|
&& !other.is_active()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if all_deps_completed {
|
||||||
|
t.status = "active".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
|
unblocked.push(t.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tid in &unblocked {
|
||||||
|
state.record_activity(
|
||||||
|
"task_update",
|
||||||
|
&format!("Auto-unblocked Task {} (all dependencies met)", tid),
|
||||||
|
Some("active"),
|
||||||
|
);
|
||||||
|
state.broadcast_task_event(TaskEvent {
|
||||||
|
task_id: tid.clone(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
action: Some("cascade_unblock".to_string()),
|
||||||
|
result: Some(serde_json::json!({ "status": "active" })),
|
||||||
|
error: None,
|
||||||
|
timestamp: crate::handlers::utils::now_secs(),
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unblocked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Automatically recalculates milestone progress and status based on task completion.
|
||||||
|
pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
|
||||||
|
let mut updated = Vec::new();
|
||||||
|
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
|
||||||
|
|
||||||
|
state.project.milestones.modify(|milestones| {
|
||||||
|
for m in milestones.iter_mut() {
|
||||||
|
if m.task_ids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let total = m.task_ids.len();
|
||||||
|
let completed = m
|
||||||
|
.task_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|tid| {
|
||||||
|
tasks_snapshot
|
||||||
|
.iter()
|
||||||
|
.any(|t| (t.id == **tid || t.title == **tid) && !t.is_active())
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if completed == total {
|
||||||
|
if !m.status.eq_ignore_ascii_case("completed") {
|
||||||
|
m.status = "completed".to_string();
|
||||||
|
if m.completed_at.is_none() {
|
||||||
|
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||||
|
}
|
||||||
|
updated.push(m.id.clone());
|
||||||
|
}
|
||||||
|
} else if completed > 0
|
||||||
|
|| tasks_snapshot.iter().any(|t| {
|
||||||
|
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
|
||||||
|
&& (t.status == "in_progress" || t.status == "active")
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if !m.status.eq_ignore_ascii_case("in_progress")
|
||||||
|
&& !m.status.eq_ignore_ascii_case("completed")
|
||||||
|
{
|
||||||
|
m.status = "in_progress".to_string();
|
||||||
|
updated.push(m.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for mid in &updated {
|
||||||
|
state.record_activity(
|
||||||
|
"milestone",
|
||||||
|
&format!("Milestone {} status updated via task progress", mid),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciles state deterministically from git commit messages or MCP `log_code_change` calls.
|
||||||
|
pub async fn reconcile_commit_or_code_change(
|
||||||
|
state: &Arc<MemoryState>,
|
||||||
|
text: &str,
|
||||||
|
file_path: Option<&str>,
|
||||||
|
git_commit: Option<&str>,
|
||||||
|
git_branch: Option<&str>,
|
||||||
|
) -> ReconciliationResult {
|
||||||
|
let mut result = ReconciliationResult::default();
|
||||||
|
let now = crate::handlers::utils::now_secs();
|
||||||
|
let text_lower = text.to_lowercase();
|
||||||
|
|
||||||
|
// 1. Reconcile ADRs
|
||||||
|
let adr_candidates = extract_adrs(text);
|
||||||
|
if !adr_candidates.is_empty() {
|
||||||
|
let mut adrs_to_index: Vec<Adr> = Vec::new();
|
||||||
|
state.code.adrs.modify(|adrs| {
|
||||||
|
for adr in adrs.iter_mut() {
|
||||||
|
let matches = adr_candidates.iter().any(|cand| {
|
||||||
|
if adr.id.eq_ignore_ascii_case(cand) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) {
|
||||||
|
if n1 == n2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
if matches && !adr.status.eq_ignore_ascii_case("implemented") {
|
||||||
|
adr.status = "implemented".to_string();
|
||||||
|
adr.resolved_at = Some(now);
|
||||||
|
if let Some(rev) = git_commit {
|
||||||
|
adr.git_commit = Some(rev.to_string());
|
||||||
|
}
|
||||||
|
if let Some(br) = git_branch {
|
||||||
|
adr.git_branch = Some(br.to_string());
|
||||||
|
}
|
||||||
|
result.implemented_adrs.push(adr.id.clone());
|
||||||
|
adrs_to_index.push(adr.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if !adrs_to_index.is_empty() {
|
||||||
|
let idx = state.get_search_index().await;
|
||||||
|
for a in &adrs_to_index {
|
||||||
|
drop(idx.index_adr(a));
|
||||||
|
state.record_activity(
|
||||||
|
"decision",
|
||||||
|
&format!("Auto-implemented ADR {}: {}", a.id, a.title),
|
||||||
|
Some("implemented"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reconcile Tech Debts
|
||||||
|
let td_candidates = extract_tech_debts(text);
|
||||||
|
let mentions_debt_resolution = text_lower.contains("fix")
|
||||||
|
|| text_lower.contains("resolve")
|
||||||
|
|| text_lower.contains("refactor")
|
||||||
|
|| text_lower.contains("clean")
|
||||||
|
|| text_lower.contains("address");
|
||||||
|
|
||||||
|
state.code.tech_debts.modify(|debts| {
|
||||||
|
for d in debts.iter_mut() {
|
||||||
|
if d.is_resolved {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let matches_id = td_candidates.iter().any(|cand| d.id.eq_ignore_ascii_case(cand));
|
||||||
|
let matches_file = file_path
|
||||||
|
.map(|fp| d.file_path.as_deref() == Some(fp))
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& mentions_debt_resolution
|
||||||
|
&& (text_lower.contains("debt") || text_lower.contains("td-"));
|
||||||
|
|
||||||
|
if matches_id || matches_file {
|
||||||
|
d.is_resolved = true;
|
||||||
|
if let Some(rev) = git_commit {
|
||||||
|
d.git_commit = Some(rev.to_string());
|
||||||
|
}
|
||||||
|
if let Some(br) = git_branch {
|
||||||
|
d.git_branch = Some(br.to_string());
|
||||||
|
}
|
||||||
|
result.resolved_tech_debts.push(d.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tdid in &result.resolved_tech_debts {
|
||||||
|
state.record_activity(
|
||||||
|
"tech_debt",
|
||||||
|
&format!("Auto-resolved TechDebt {} via commit", tdid),
|
||||||
|
Some("resolved"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reconcile Tasks
|
||||||
|
let task_candidates = extract_tasks(text);
|
||||||
|
let mentions_task_close = text_lower.contains("fix")
|
||||||
|
|| text_lower.contains("close")
|
||||||
|
|| text_lower.contains("complete")
|
||||||
|
|| text_lower.contains("resolve")
|
||||||
|
|| text_lower.contains("finish")
|
||||||
|
|| text_lower.contains("implement");
|
||||||
|
|
||||||
|
if mentions_task_close && !task_candidates.is_empty() {
|
||||||
|
state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
if !t.is_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let matches = task_candidates.iter().any(|cand| {
|
||||||
|
t.id.eq_ignore_ascii_case(cand)
|
||||||
|
|| cand.trim_start_matches("TASK-").eq_ignore_ascii_case(&t.id)
|
||||||
|
});
|
||||||
|
if matches {
|
||||||
|
t.status = "completed".to_string();
|
||||||
|
t.updated_at = now;
|
||||||
|
result.completed_tasks.push(t.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tid in &result.completed_tasks {
|
||||||
|
state.record_activity(
|
||||||
|
"task_update",
|
||||||
|
&format!("Auto-completed Task {} via commit", tid),
|
||||||
|
Some("completed"),
|
||||||
|
);
|
||||||
|
state.broadcast_task_event(TaskEvent {
|
||||||
|
task_id: tid.clone(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
action: Some("commit_reconciliation".to_string()),
|
||||||
|
result: Some(serde_json::json!({ "status": "completed" })),
|
||||||
|
error: None,
|
||||||
|
timestamp: now,
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cascade Unblock & Recalculate Milestones if any tasks completed
|
||||||
|
if !result.completed_tasks.is_empty() {
|
||||||
|
result.unblocked_tasks = cascade_unblock_tasks(state);
|
||||||
|
result.updated_milestones = recalculate_milestones(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{Milestone, Task};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_adrs() {
|
||||||
|
let text = "feat(api): implement ADR-0102 and ADR-103 with pagination";
|
||||||
|
let adrs = extract_adrs(text);
|
||||||
|
assert!(adrs.contains(&"ADR-0102".to_string()));
|
||||||
|
assert!(adrs.contains(&"ADR-0103".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tech_debts() {
|
||||||
|
let text = "refactor: resolve TD-cleanup_old_buffers and TD-002";
|
||||||
|
let debts = extract_tech_debts(text);
|
||||||
|
assert!(debts.contains(&"TD-cleanup_old_buffers".to_string()));
|
||||||
|
assert!(debts.contains(&"TD-002".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tasks() {
|
||||||
|
let text = "fix(core): close task-551 and TASK-abc";
|
||||||
|
let tasks = extract_tasks(text);
|
||||||
|
assert!(tasks.contains(&"TASK-551".to_string()));
|
||||||
|
assert!(tasks.contains(&"TASK-abc".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reconcile_adr_implementation() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
state.code.adrs.modify(|adrs| {
|
||||||
|
adrs.push(Adr {
|
||||||
|
id: "ADR-0102".to_string(),
|
||||||
|
title: "Dynamic Neovim Projection Buffer".to_string(),
|
||||||
|
status: "accepted".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
"feat: implement ADR-102 projection buffer",
|
||||||
|
None,
|
||||||
|
Some("commit123"),
|
||||||
|
Some("master"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result.implemented_adrs, vec!["ADR-0102"]);
|
||||||
|
|
||||||
|
let adr = state.code.adrs.read_with(|adrs| adrs[0].clone());
|
||||||
|
assert_eq!(adr.status, "implemented");
|
||||||
|
assert_eq!(adr.git_commit.as_deref(), Some("commit123"));
|
||||||
|
assert_eq!(adr.git_branch.as_deref(), Some("master"));
|
||||||
|
assert!(adr.resolved_at.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cascade_unblock_and_milestone_recalculation() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
let task1 = Task {
|
||||||
|
id: "task-1".to_string(),
|
||||||
|
title: "Task 1".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let task2 = Task {
|
||||||
|
id: "task-2".to_string(),
|
||||||
|
title: "Task 2".to_string(),
|
||||||
|
status: "blocked".to_string(),
|
||||||
|
dependencies: vec!["task-1".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.project.tasks.modify(|t| {
|
||||||
|
t.push(task1);
|
||||||
|
t.push(task2);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ms = Milestone {
|
||||||
|
id: "ms-1".to_string(),
|
||||||
|
title: "Sprint 1".to_string(),
|
||||||
|
status: "pending".to_string(),
|
||||||
|
task_ids: vec!["task-1".to_string(), "task-2".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.project.milestones.modify(|m| m.push(ms));
|
||||||
|
|
||||||
|
// Complete task-1 via commit message
|
||||||
|
let result = reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
"fix: close task-1 core functionality",
|
||||||
|
None,
|
||||||
|
Some("c1"),
|
||||||
|
Some("master"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result.completed_tasks, vec!["task-1"]);
|
||||||
|
assert_eq!(result.unblocked_tasks, vec!["task-2"]);
|
||||||
|
assert_eq!(result.updated_milestones, vec!["ms-1"]);
|
||||||
|
|
||||||
|
// Task 2 should now be active
|
||||||
|
let t2 = state.project.tasks.read_with(|t| t[1].clone());
|
||||||
|
assert_eq!(t2.status, "active");
|
||||||
|
|
||||||
|
// Milestone should now be in_progress
|
||||||
|
let m = state.project.milestones.read_with(|m| m[0].clone());
|
||||||
|
assert_eq!(m.status, "in_progress");
|
||||||
|
|
||||||
|
// Now complete task-2
|
||||||
|
let result2 = reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
"fix: close task-2 follow-up",
|
||||||
|
None,
|
||||||
|
Some("c2"),
|
||||||
|
Some("master"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result2.completed_tasks, vec!["task-2"]);
|
||||||
|
let m2 = state.project.milestones.read_with(|m| m[0].clone());
|
||||||
|
assert_eq!(m2.status, "completed");
|
||||||
|
assert!(m2.completed_at.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,10 @@ impl McpTool for TasksHandler {
|
|||||||
session_id: None,
|
session_id: None,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
if target_status == "completed" {
|
||||||
|
crate::handlers::reconciliation::cascade_unblock_tasks(&state);
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&state);
|
||||||
|
}
|
||||||
Ok("Task status updated.".to_string())
|
Ok("Task status updated.".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(crate::error::AppError::Internal(
|
Err(crate::error::AppError::Internal(
|
||||||
@@ -452,11 +456,13 @@ impl McpTool for MilestonesHandler {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| s.parse::<u64>().ok()),
|
.and_then(|s| s.parse::<u64>().ok()),
|
||||||
description: req.description,
|
description: req.description,
|
||||||
|
task_ids: req.task_ids.unwrap_or_default(),
|
||||||
deliverables: req.deliverables.unwrap_or_default(),
|
deliverables: req.deliverables.unwrap_or_default(),
|
||||||
repo_name: req.repo_name,
|
repo_name: req.repo_name,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&state);
|
||||||
Ok("Milestone added".to_string())
|
Ok("Milestone added".to_string())
|
||||||
}
|
}
|
||||||
MilestoneAction::Update => {
|
MilestoneAction::Update => {
|
||||||
@@ -471,12 +477,24 @@ impl McpTool for MilestonesHandler {
|
|||||||
for m in ms.iter_mut() {
|
for m in ms.iter_mut() {
|
||||||
if m.id == id {
|
if m.id == id {
|
||||||
m.status = status.clone();
|
m.status = status.clone();
|
||||||
|
if status.eq_ignore_ascii_case("completed") || status.eq_ignore_ascii_case("done") {
|
||||||
|
if m.completed_at.is_none() {
|
||||||
|
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(task_ids) = &req.task_ids {
|
||||||
|
m.task_ids = task_ids.clone();
|
||||||
|
if let Some(deliverables) = &req.deliverables {
|
||||||
|
m.deliverables = deliverables.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if found {
|
if found {
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&state);
|
||||||
Ok("Milestone updated".to_string())
|
Ok("Milestone updated".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(crate::error::AppError::Internal(
|
Err(crate::error::AppError::Internal(
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
pub fn get_native_clipboard_text() -> Option<String> {
|
pub fn get_native_clipboard_text() -> Option<String> {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
if let Ok(mut clipboard) = arboard::Clipboard::new()
|
||||||
if let Ok(text) = clipboard.get_text() {
|
&& let Ok(text) = clipboard.get_text()
|
||||||
if !text.trim().is_empty() {
|
&& !text.trim().is_empty()
|
||||||
|
{
|
||||||
return Some(text);
|
return Some(text);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -71,17 +70,16 @@ pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
|||||||
return Some(img);
|
return Some(img);
|
||||||
}
|
}
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
if let Ok(mut clipboard) = arboard::Clipboard::new()
|
||||||
if let Ok(image_data) = clipboard.get_image() {
|
&& let Ok(image_data) = clipboard.get_image()
|
||||||
if let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
|
&& let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
|
||||||
image_data.width as u32,
|
image_data.width as u32,
|
||||||
image_data.height as u32,
|
image_data.height as u32,
|
||||||
image_data.bytes.into_owned(),
|
image_data.bytes.into_owned(),
|
||||||
) {
|
)
|
||||||
|
{
|
||||||
return Some(image::DynamicImage::ImageRgba8(img));
|
return Some(image::DynamicImage::ImageRgba8(img));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|||||||
+37
-2
@@ -102,10 +102,37 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
|
let mut expired_tasks = Vec::new();
|
||||||
state.project.tasks.modify(|tasks| {
|
state.project.tasks.modify(|tasks| {
|
||||||
tasks.retain(|t| t.expires_at.is_none_or(|exp| exp > now));
|
for t in tasks.iter_mut() {
|
||||||
|
if let Some(exp) = t.expires_at {
|
||||||
|
if exp <= now && t.is_active() {
|
||||||
|
t.status = "expired".to_string();
|
||||||
|
t.updated_at = now;
|
||||||
|
expired_tasks.push(t.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for tid in expired_tasks {
|
||||||
|
state.record_activity(
|
||||||
|
"task_expired",
|
||||||
|
&format!("Task {} expired due to TTL", tid),
|
||||||
|
Some("expired"),
|
||||||
|
);
|
||||||
|
state.broadcast_task_event(crate::models::TaskEvent {
|
||||||
|
task_id: tid,
|
||||||
|
status: "expired".to_string(),
|
||||||
|
action: Some("ttl_expire".to_string()),
|
||||||
|
result: Some(serde_json::json!({ "status": "expired" })),
|
||||||
|
error: None,
|
||||||
|
timestamp: now,
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
state.telemetry.handoff_memos.modify(|memos| {
|
state.telemetry.handoff_memos.modify(|memos| {
|
||||||
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
||||||
});
|
});
|
||||||
@@ -304,9 +331,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!({
|
let ws_msg = serde_json::json!({
|
||||||
"type": "nvim_telemetry",
|
"type": "nvim_telemetry",
|
||||||
"data": payload
|
"data": payload,
|
||||||
|
"tech_debts": tech_debts,
|
||||||
|
"adrs": adrs
|
||||||
});
|
});
|
||||||
let msg_str = ws_msg.to_string();
|
let msg_str = ws_msg.to_string();
|
||||||
|
|
||||||
|
|||||||
@@ -739,6 +739,9 @@ pub struct MilestonesTool {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Optional deliverables or task checklist.
|
/// Optional deliverables or task checklist.
|
||||||
pub deliverables: Option<Vec<String>>,
|
pub deliverables: Option<Vec<String>>,
|
||||||
|
/// Optional list of task IDs associated with this milestone.
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_ids: Option<Vec<String>>,
|
||||||
/// Optional repository name.
|
/// Optional repository name.
|
||||||
pub repo_name: Option<String>,
|
pub repo_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -56,11 +56,11 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
|
|||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
if should_review(&path) {
|
if should_review(&path) {
|
||||||
// 250ms debouncing window per file path
|
// 250ms debouncing window per file path
|
||||||
if let Some(last) = last_processed.get(&path) {
|
if let Some(last) = last_processed.get(&path)
|
||||||
if now.duration_since(*last) < std::time::Duration::from_millis(250) {
|
&& now.duration_since(*last) < std::time::Duration::from_millis(250)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
last_processed.insert(path.clone(), now);
|
last_processed.insert(path.clone(), now);
|
||||||
|
|
||||||
info!("Proactive Daemon Hooks: File modified: {:?}", path);
|
info!("Proactive Daemon Hooks: File modified: {:?}", path);
|
||||||
@@ -83,13 +83,9 @@ fn should_review(path: &Path) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ext) = path.extension() {
|
path.extension()
|
||||||
let ext_str = ext.to_string_lossy();
|
.and_then(|ext| ext.to_str())
|
||||||
if ext_str == "rs" || ext_str == "md" || ext_str == "toml" || ext_str == "lua" {
|
.is_some_and(|ext| matches!(ext, "rs" | "md" | "toml" | "lua"))
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async fn test_stub_connection_refused() {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to execute stub");
|
.expect("Failed to execute stub");
|
||||||
|
|
||||||
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
let res = tokio::time::timeout(Duration::from_secs(15), child.wait()).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -71,7 +71,7 @@ async fn test_stub_handles_eof_cleanly() {
|
|||||||
} // stdin dropped here
|
} // stdin dropped here
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
let res = tokio::time::timeout(Duration::from_secs(15), child.wait()).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert!(res.is_ok(), "Stub hung after EOF! Took {elapsed:?}");
|
assert!(res.is_ok(), "Stub hung after EOF! Took {elapsed:?}");
|
||||||
@@ -102,7 +102,7 @@ async fn test_stub_sse_fallback_failure() {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to execute stub");
|
.expect("Failed to execute stub");
|
||||||
|
|
||||||
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
let res = tokio::time::timeout(Duration::from_secs(15), child.wait()).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in new issue
Block a user