Compare commits
2
Commits
64857f9d5e
...
8bc8c97504
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc8c97504 | ||
|
|
f4dcf3acbf |
No files matched your search
@@ -9,3 +9,4 @@ llvm-cov.zip
|
|||||||
temp.json
|
temp.json
|
||||||
*.txt
|
*.txt
|
||||||
summary.md
|
summary.md
|
||||||
|
__pycache__/
|
||||||
Generated
+3
@@ -2661,6 +2661,7 @@ dependencies = [
|
|||||||
"tree-sitter-rust",
|
"tree-sitter-rust",
|
||||||
"tree-sitter-typescript",
|
"tree-sitter-typescript",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"winres",
|
"winres",
|
||||||
]
|
]
|
||||||
@@ -2670,6 +2671,7 @@ name = "mcp-memory-stub"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs 7.0.0",
|
"dirs 7.0.0",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2988,6 +2990,7 @@ name = "nvim-core"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dirs 7.0.0",
|
"dirs 7.0.0",
|
||||||
"mcp-stdio",
|
"mcp-stdio",
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ Before making expensive active tool calls, read available MCP resources:
|
|||||||
- **Hypotheses & Root Cause Analysis**: When diagnosing complex bugs or race conditions, call `hypotheses` (action: "log" / "query") to record test evidence and maintain reasoning trails across sessions.
|
- **Hypotheses & Root Cause Analysis**: When diagnosing complex bugs or race conditions, call `hypotheses` (action: "log" / "query") to record test evidence and maintain reasoning trails across sessions.
|
||||||
- **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status.
|
- **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status.
|
||||||
- **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context.
|
- **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context.
|
||||||
|
- **Clipboard & Multimodal Screenshots**:
|
||||||
|
- **User Image & Visual Queries**: When the user says "look at image in clipboard", "see screenshot", "check the clipboard", or refers to an error screenshot, ALWAYS call `clipboard(action: "image")`.
|
||||||
|
- **Decoupled Screenshot History**: Even if the user got sidetracked and copied text/URLs afterwards, `clipboard(action: "image")` reliably retrieves the last captured screenshot from the event-driven cache with exact metadata (`file_path`, `file_path_wsl`, `age`, `dimensions`, `ocr_text`).
|
||||||
|
- **WSL Ubuntu Compatibility**: For WSL sessions, use the returned `file_path_wsl` (e.g. `/mnt/c/...`) directly with `view_file`.
|
||||||
|
- **Text-Only Models & Fast Context**: Use the returned deterministic `ocr_text` to immediately extract error traces, URLs, and code snippets without waiting for multimodal vision tokens.
|
||||||
|
- **Copying Formatted Tables / Data**: `clipboard(action: "read")` automatically strips Win32 `CF_HTML` headers and converts HTML tables directly into clean GFM Markdown.
|
||||||
- **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave").
|
- **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave").
|
||||||
|
|
||||||
## 3. Delegation
|
## 3. Delegation
|
||||||
|
|||||||
+30
-11
@@ -1,20 +1,39 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let git_hash = Command::new("git")
|
let mut git_hash = Command::new("git")
|
||||||
.args(&["rev-parse", "--short", "HEAD"])
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
let git_date = Command::new("git")
|
|
||||||
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
|
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
|
git_hash = git_hash.trim().to_string();
|
||||||
println!("cargo:rustc-env=APP_VERSION={}", version);
|
|
||||||
|
let is_dirty = Command::new("git")
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|out| !out.stdout.is_empty());
|
||||||
|
|
||||||
|
if is_dirty {
|
||||||
|
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||||
|
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let git_date = Command::new("git")
|
||||||
|
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||||
|
|
||||||
|
let version = format!("v{git_date} ({git_hash})");
|
||||||
|
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||||
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=../.git/index");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
}
|
}
|
||||||
+85
-4
@@ -1,5 +1,9 @@
|
|||||||
-- gemini-integration.lua
|
-- gemini-integration.lua
|
||||||
-- Registers the active Neovim instance for the Antigravity MCP Server
|
-- Registers the active Neovim instance for the Antigravity MCP Server
|
||||||
|
|
||||||
|
local last_active_task = nil
|
||||||
|
local last_active_gate = nil
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, {
|
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, {
|
||||||
callback = function(ev)
|
callback = function(ev)
|
||||||
-- Only register if Neovim has an active UI attached (ignore headless/embedded instances)
|
-- Only register if Neovim has an active UI attached (ignore headless/embedded instances)
|
||||||
@@ -18,7 +22,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 2. V2 Telemetry Push (WebSockets & Cross-OS Sync via Rust Server)
|
-- 2. V2 Telemetry Push (HTTP with Knowledge Projection + UDP Fast Mirror)
|
||||||
local file = vim.api.nvim_buf_get_name(0)
|
local file = vim.api.nvim_buf_get_name(0)
|
||||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
local row, col = cursor[1], cursor[2]
|
local row, col = cursor[1], cursor[2]
|
||||||
@@ -26,6 +30,7 @@ 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
|
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",
|
||||||
@@ -38,11 +43,16 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
|||||||
local raw = table.concat(data, "")
|
local raw = table.concat(data, "")
|
||||||
if raw ~= "" then
|
if raw ~= "" then
|
||||||
local ok, resp = pcall(vim.fn.json_decode, raw)
|
local ok, resp = pcall(vim.fn.json_decode, raw)
|
||||||
if ok and resp and (resp.tech_debts or resp.adrs) then
|
if ok and resp then
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
if _G.gemini and _G.gemini.project_knowledge then
|
if (resp.tech_debts or resp.adrs) and _G.gemini and _G.gemini.project_knowledge then
|
||||||
_G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf)
|
_G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf)
|
||||||
end
|
end
|
||||||
|
if _G.gemini and _G.gemini.update_statusline then
|
||||||
|
last_active_task = resp.active_task
|
||||||
|
last_active_gate = resp.active_gate
|
||||||
|
_G.gemini.update_statusline(resp.active_task, resp.active_gate)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -50,7 +60,6 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
|||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- 3. UDP Mirroring (Native fast sync)
|
|
||||||
local uv = vim.uv or vim.loop
|
local uv = vim.uv or vim.loop
|
||||||
local client = uv.new_udp()
|
local client = uv.new_udp()
|
||||||
if client then
|
if client then
|
||||||
@@ -64,6 +73,78 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
|||||||
desc = "Register active Neovim instance for Gemini CLI",
|
desc = "Register active Neovim instance for Gemini CLI",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- 3. Push-driven Server-Sent Events (SSE) subscriber (Zero Polling)
|
||||||
|
local sse_job_id = nil
|
||||||
|
|
||||||
|
local function start_sse_subscriber()
|
||||||
|
if sse_job_id then
|
||||||
|
pcall(vim.fn.jobstop, sse_job_id)
|
||||||
|
sse_job_id = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
sse_job_id = vim.fn.jobstart({
|
||||||
|
"curl", "-s", "-N", "http://127.0.0.1:3000/api/events/stream"
|
||||||
|
}, {
|
||||||
|
on_stdout = function(_, data)
|
||||||
|
if not data then return end
|
||||||
|
for _, line in ipairs(data) do
|
||||||
|
if line:match("^data:%s*") then
|
||||||
|
local raw_json = line:gsub("^data:%s*", "")
|
||||||
|
local ok, ev = pcall(vim.fn.json_decode, raw_json)
|
||||||
|
if ok and ev and ev.payload then
|
||||||
|
local topic = ev.topic
|
||||||
|
local payload = ev.payload
|
||||||
|
local changed = false
|
||||||
|
|
||||||
|
if topic == "task:event" then
|
||||||
|
if payload.active_task ~= nil then
|
||||||
|
last_active_task = payload.active_task
|
||||||
|
changed = true
|
||||||
|
elseif payload.status == "in_progress" or payload.status == "active" then
|
||||||
|
last_active_task = payload.title or payload.task_id
|
||||||
|
changed = true
|
||||||
|
elseif payload.status == "completed" or payload.status == "expired" or payload.status == "failed" then
|
||||||
|
if last_active_task == (payload.title or payload.task_id) then
|
||||||
|
last_active_task = nil
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif topic == "gate:event" then
|
||||||
|
if payload.active_gate ~= nil then
|
||||||
|
last_active_gate = payload.active_gate
|
||||||
|
changed = true
|
||||||
|
elseif payload.status then
|
||||||
|
last_active_gate = payload.status
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if changed and _G.gemini and _G.gemini.update_statusline then
|
||||||
|
vim.schedule(function()
|
||||||
|
_G.gemini.update_statusline(last_active_task, last_active_gate)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
on_exit = function()
|
||||||
|
sse_job_id = nil
|
||||||
|
-- Reconnect after server restart without busy looping
|
||||||
|
vim.defer_fn(function()
|
||||||
|
if #vim.api.nvim_list_uis() > 0 then
|
||||||
|
start_sse_subscriber()
|
||||||
|
end
|
||||||
|
end, 5000)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initialize SSE push listener when UI is active
|
||||||
|
if #vim.api.nvim_list_uis() > 0 then
|
||||||
|
start_sse_subscriber()
|
||||||
|
end
|
||||||
|
|
||||||
-- Load UI & Keymaps
|
-- Load UI & Keymaps
|
||||||
local is_win = vim.fn.has("win32") == 1
|
local is_win = vim.fn.has("win32") == 1
|
||||||
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
|
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
|
||||||
|
|||||||
@@ -348,4 +348,48 @@ function M.send_to_terminal(command)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Antigravity Statusline Integration (Push-driven / Zero-polling)
|
||||||
|
-- ============================================================================
|
||||||
|
local statusline_cache = {
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.update_statusline(active_task, active_gate)
|
||||||
|
local parts = {}
|
||||||
|
|
||||||
|
if active_task and active_task ~= vim.NIL and active_task ~= "" then
|
||||||
|
local title = tostring(active_task)
|
||||||
|
if #title > 24 then
|
||||||
|
title = title:sub(1, 22) .. ".."
|
||||||
|
end
|
||||||
|
table.insert(parts, "📋 " .. title)
|
||||||
|
end
|
||||||
|
|
||||||
|
if active_gate and active_gate ~= vim.NIL and active_gate ~= "" then
|
||||||
|
local status = tostring(active_gate):lower()
|
||||||
|
if status == "authorized" then
|
||||||
|
table.insert(parts, "🟢 PUSH:OK")
|
||||||
|
elseif status == "blocked" then
|
||||||
|
table.insert(parts, "🔴 PUSH:BLOCKED")
|
||||||
|
else
|
||||||
|
table.insert(parts, "🟡 PUSH:WAIT")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #parts > 0 then
|
||||||
|
statusline_cache.text = " [AGY: " .. table.concat(parts, " | ") .. "] "
|
||||||
|
else
|
||||||
|
statusline_cache.text = ""
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.schedule(function()
|
||||||
|
pcall(vim.cmd, "redrawstatus")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.statusline()
|
||||||
|
return statusline_cache.text
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
@@ -28,7 +28,7 @@ deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
|
|||||||
# Stop the background server gracefully and verify port release
|
# Stop the background server gracefully and verify port release
|
||||||
stop port="3000":
|
stop port="3000":
|
||||||
@Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan
|
@Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan
|
||||||
-& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $stopped = $false; for ($i = 1; $i -le 10; $i++) { $conn = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; $proc = Get-Process mcp-memory-server -ErrorAction SilentlyContinue; if (-not $conn -and -not $proc) { $stopped = $true; Write-Host '✅ Server process and port {{port}} fully terminated.' -ForegroundColor Green; break }; Start-Sleep -Seconds 1 }; if (-not $stopped) { Write-Host '⚠️ Server process did not exit within 10s. Force killing process...' -ForegroundColor Yellow; Get-Process mcp-memory-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } }
|
-& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $procs = @(Get-Process mcp-memory-server -ErrorAction SilentlyContinue); if ($procs.Count -gt 0) { foreach ($p in $procs) { $exited = $p.WaitForExit(10000); if (-not $exited) { Write-Host "⚠️ Process $($p.Id) did not exit within 10s. Force killing..." -ForegroundColor Yellow; $p | Stop-Process -Force -ErrorAction SilentlyContinue } } }; $conns = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; if ($conns) { $conns | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } }
|
||||||
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
|
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
|
||||||
|
|
||||||
# Start the server in the background and verify /health
|
# Start the server in the background and verify /health
|
||||||
@@ -125,12 +125,12 @@ deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
|
|||||||
# Deploy WSL-native stub binary (skips if identical checksum)
|
# Deploy WSL-native stub binary (skips if identical checksum)
|
||||||
deploy-stub:
|
deploy-stub:
|
||||||
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
||||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||||
|
|
||||||
# Deploy WSL-native Neovim plugin binary (skips if identical checksum)
|
# Deploy WSL-native Neovim plugin binary (skips if identical checksum)
|
||||||
deploy-nvim:
|
deploy-nvim:
|
||||||
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
|
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
|
||||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||||
|
|
||||||
# Deploy global Agent rules to WSL config directory (skips identical files)
|
# Deploy global Agent rules to WSL config directory (skips identical files)
|
||||||
deploy-rules-wsl:
|
deploy-rules-wsl:
|
||||||
@@ -191,8 +191,8 @@ 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 (post-commit reconciliation and pre-push gatekeeper)
|
||||||
install-git-hooks:
|
install-git-hooks:
|
||||||
@Write-Host "Installing git post-commit reconciliation hook..." -ForegroundColor Cyan
|
@Write-Host "Installing git post-commit and pre-push hooks..." -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 }
|
-& { $postCommitContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; [System.IO.File]::WriteAllText(".git\hooks\post-commit", $postCommitContent); $prePushContent = "#!/bin/sh`nif command -v python3 >/dev/null 2>&1; then`n exec python3 scripts/git-pre-push.py `"`$@`"`nelse`n exec python scripts/git-pre-push.py `"`$@`"`nfi`n"; [System.IO.File]::WriteAllText(".git\hooks\pre-push", $prePushContent); Write-Host "✅ Git post-commit and pre-push hooks installed." -ForegroundColor Green }
|
||||||
|
|
||||||
@@ -17,3 +17,6 @@ mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
|||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
dashmap = "6.2.1"
|
dashmap = "6.2.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
chrono = "0.4.45"
|
||||||
|
|
||||||
+20
-3
@@ -1,22 +1,39 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let git_hash = Command::new("git")
|
let mut git_hash = Command::new("git")
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
git_hash = git_hash.trim().to_string();
|
||||||
|
|
||||||
|
let is_dirty = Command::new("git")
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|out| !out.stdout.is_empty());
|
||||||
|
|
||||||
|
if is_dirty {
|
||||||
|
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||||
|
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||||
|
}
|
||||||
|
|
||||||
let git_date = Command::new("git")
|
let git_date = Command::new("git")
|
||||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||||
|
|
||||||
let version = format!("{} ({})", git_date.trim(), git_hash.trim());
|
let version = format!("v{git_date} ({git_hash})");
|
||||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
println!("cargo:rerun-if-changed=../.git/index");
|
println!("cargo:rerun-if-changed=../.git/index");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
}
|
}
|
||||||
+97
-6
@@ -319,6 +319,7 @@ static PENDING_REQUESTS: LazyLock<
|
|||||||
pub struct NvimState {
|
pub struct NvimState {
|
||||||
pub cursor: String,
|
pub cursor: String,
|
||||||
pub active_buffer_id: u64,
|
pub active_buffer_id: u64,
|
||||||
|
pub active_buffer_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> =
|
static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> =
|
||||||
@@ -341,8 +342,27 @@ fn handle_nvim_notification(params: &[rmpv::Value]) {
|
|||||||
state.cursor = format!("Line: {}, Column: {}", row, col);
|
state.cursor = format!("Line: {}, Column: {}", row, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"TextChanged" => {
|
"TextChanged" | "BufEnter" => {
|
||||||
tracing::debug!("Shadow buffer text changed event received");
|
if params.len() > 1 {
|
||||||
|
let buf_id = match ¶ms[1] {
|
||||||
|
rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let buf_name = if params.len() > 2 {
|
||||||
|
match ¶ms[2] {
|
||||||
|
rmpv::Value::String(s) => s.as_str().unwrap_or("").to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
state.active_buffer_id = buf_id;
|
||||||
|
if !buf_name.is_empty() {
|
||||||
|
state.active_buffer_name = buf_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::debug!("Neovim buffer changed/entered event received");
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -591,7 +611,9 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
|||||||
vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, {
|
vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, {
|
||||||
group = "MCP_Tracking",
|
group = "MCP_Tracking",
|
||||||
callback = function()
|
callback = function()
|
||||||
pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", vim.api.nvim_get_current_buf())
|
local b = vim.api.nvim_get_current_buf()
|
||||||
|
local n = vim.api.nvim_buf_get_name(b)
|
||||||
|
pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", b, n)
|
||||||
end
|
end
|
||||||
})
|
})
|
||||||
"#;
|
"#;
|
||||||
@@ -1686,7 +1708,7 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
|||||||
bufnr = 0
|
bufnr = 0
|
||||||
end
|
end
|
||||||
_G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines)
|
_G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines)
|
||||||
return "Preview added"
|
return { status = "Preview added", diff_id = diff_id }
|
||||||
else
|
else
|
||||||
error("gemini UI not loaded")
|
error("gemini UI not loaded")
|
||||||
end
|
end
|
||||||
@@ -1708,6 +1730,61 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
|||||||
send_error(id, -32602, "Missing arguments: requires file_path, start_line, end_line, and preview_content").await;
|
send_error(id, -32602, "Missing arguments: requires file_path, start_line, end_line, and preview_content").await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"prompt" | "prompt_user" => {
|
||||||
|
let prompt_text = args
|
||||||
|
.get("prompt")
|
||||||
|
.or_else(|| args.get("prompt_text"))
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
if let Some(prompt) = prompt_text {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let request_id = args
|
||||||
|
.get("request_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let options_arr = match args.get("options").and_then(|v| v.as_array()) {
|
||||||
|
Some(arr) => {
|
||||||
|
let rmp_arr: Vec<rmpv::Value> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_str())
|
||||||
|
.map(|s| rmpv::Value::String(s.into()))
|
||||||
|
.collect();
|
||||||
|
rmpv::Value::Array(rmp_arr)
|
||||||
|
}
|
||||||
|
None => rmpv::Value::Nil,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lua_code = r#"
|
||||||
|
local prompt_text, options, request_id = ...
|
||||||
|
if _G.gemini and _G.gemini.prompt_user then
|
||||||
|
_G.gemini.prompt_user(prompt_text, options, request_id)
|
||||||
|
return { status = "Prompt displayed", request_id = request_id }
|
||||||
|
else
|
||||||
|
error("gemini UI not loaded")
|
||||||
|
end
|
||||||
|
"#;
|
||||||
|
handle_lua_result!(
|
||||||
|
id,
|
||||||
|
execute_nvim_lua_with_args(
|
||||||
|
lua_code,
|
||||||
|
vec![
|
||||||
|
rmpv::Value::String(prompt.into()),
|
||||||
|
options_arr,
|
||||||
|
rmpv::Value::String(request_id.into()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
"extmark" => {
|
"extmark" => {
|
||||||
if let (Some(line), Some(text)) = (
|
if let (Some(line), Some(text)) = (
|
||||||
args.get("line").and_then(serde_json::Value::as_i64),
|
args.get("line").and_then(serde_json::Value::as_i64),
|
||||||
@@ -1996,8 +2073,22 @@ mod tests {
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
handle_nvim_notification(¶ms);
|
handle_nvim_notification(¶ms);
|
||||||
let state = NVIM_STATE.lock().unwrap();
|
{
|
||||||
assert_eq!(state.cursor, "Line: 15, Column: 4");
|
let state = NVIM_STATE.lock().unwrap();
|
||||||
|
assert_eq!(state.cursor, "Line: 15, Column: 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_params = vec![
|
||||||
|
rmpv::Value::String("TextChanged".into()),
|
||||||
|
rmpv::Value::Integer(157.into()),
|
||||||
|
rmpv::Value::String("server/src/dashboard.ts".into()),
|
||||||
|
];
|
||||||
|
handle_nvim_notification(&text_params);
|
||||||
|
{
|
||||||
|
let state = NVIM_STATE.lock().unwrap();
|
||||||
|
assert_eq!(state.active_buffer_id, 157);
|
||||||
|
assert_eq!(state.active_buffer_name, "server/src/dashboard.ts");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pre-Push Gatekeeper for Git and MCP Memory.
|
||||||
|
Enforces workspace quality gates:
|
||||||
|
1. Allows tag pushes and branch deletions without verification.
|
||||||
|
2. Bypasses tests for docs/config-only modifications (.md, .txt, .png, justfile, etc.).
|
||||||
|
3. Queries mcp-memory gate API (/gate/verify?action=push&target=<branch>).
|
||||||
|
4. If no pre-authorized gate is found, runs project tests locally to verify 100% pass before allowing push.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
DOC_EXTENSIONS = {
|
||||||
|
".md", ".txt", ".png", ".jpg", ".jpeg", ".pdf", ".svg",
|
||||||
|
".drawio", ".gif", ".ico", ".csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
DOC_FILENAMES = {
|
||||||
|
"justfile", "jenkinsfile", "dockerfile", "license",
|
||||||
|
"changelog", ".gitignore", ".gitattributes"
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_doc_or_config(filepath: str) -> bool:
|
||||||
|
base = os.path.basename(filepath).lower()
|
||||||
|
if base in DOC_FILENAMES:
|
||||||
|
return True
|
||||||
|
ext = os.path.splitext(base)[1].lower()
|
||||||
|
return ext in DOC_EXTENSIONS
|
||||||
|
|
||||||
|
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 check_mcp_gate(port: str, branch: str):
|
||||||
|
"""
|
||||||
|
Returns (status_code, reason)
|
||||||
|
status_code: 200 (Authorized), 403 (Blocked), 404 (Not found), 0 (Server unreachable)
|
||||||
|
"""
|
||||||
|
url = f"http://127.0.0.1:{port}/gate/verify?action=push&target={branch}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
||||||
|
return resp.status, "Authorized"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = e.read().decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return e.code, body
|
||||||
|
except Exception:
|
||||||
|
return 0, "Server unreachable"
|
||||||
|
|
||||||
|
def set_mcp_gate(port: str, branch: str, authorize: bool, reason: str):
|
||||||
|
url = f"http://127.0.0.1:{port}/gate/set"
|
||||||
|
payload = {
|
||||||
|
"action": "push",
|
||||||
|
"target": branch,
|
||||||
|
"authorize": authorize,
|
||||||
|
"reason": reason
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.5):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def post_mcp_event(port: str, topic: str, payload: dict):
|
||||||
|
url = f"http://127.0.0.1:{port}/api/events/post"
|
||||||
|
try:
|
||||||
|
data = json.dumps({"topic": topic, "payload": payload}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.0):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait_for_mcp_decision(port: str, topic: str, timeout_secs: int = 2):
|
||||||
|
"""Waits for an operator or auditor decision on the event bus."""
|
||||||
|
url = f"http://127.0.0.1:{port}/events/wait?topic={topic}&timeout={timeout_secs}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_secs + 0.5) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
payload = data.get("payload", {})
|
||||||
|
status = str(payload.get("status", "")).lower()
|
||||||
|
reason = payload.get("reason", "Decision received via event bus")
|
||||||
|
if status in ("authorized", "approved", "ok", "true"):
|
||||||
|
return True, reason
|
||||||
|
elif status in ("blocked", "denied", "rejected", "false"):
|
||||||
|
return False, reason
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
def run_local_tests() -> bool:
|
||||||
|
# 1. Rust workspace
|
||||||
|
if os.path.exists("Cargo.toml"):
|
||||||
|
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running cargo test...")
|
||||||
|
cmd = ["cargo", "test", "--workspace"]
|
||||||
|
res = subprocess.run(cmd)
|
||||||
|
return res.returncode == 0
|
||||||
|
# 2. Python workspace (uv / pytest)
|
||||||
|
elif os.path.exists("pyproject.toml") or os.path.exists("setup.py"):
|
||||||
|
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running python tests...")
|
||||||
|
cmd = ["uv", "run", "pytest"] if os.system("uv --version >nul 2>&1") == 0 else ["pytest"]
|
||||||
|
res = subprocess.run(cmd)
|
||||||
|
return res.returncode == 0
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Read stdin passed by git pre-push: <local ref> <local sha> <remote ref> <remote sha>
|
||||||
|
lines = sys.stdin.read().splitlines()
|
||||||
|
if not lines:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
port = os.environ.get("MCP_PORT", "3000")
|
||||||
|
for line in lines:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
local_ref, local_sha, remote_ref, remote_sha = parts[:4]
|
||||||
|
|
||||||
|
# 1. Skip deletions
|
||||||
|
if local_sha == "0000000000000000000000000000000000000000" or local_ref == "(delete)":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Skip tags
|
||||||
|
if local_ref.startswith("refs/tags/"):
|
||||||
|
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m Tag push detected ({local_ref}). Quality gate bypassed.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract branch name
|
||||||
|
branch = local_ref.replace("refs/heads/", "")
|
||||||
|
|
||||||
|
# 3. Check for documentation / config-only changes
|
||||||
|
zero_sha = "0000000000000000000000000000000000000000"
|
||||||
|
diff_range = local_sha if remote_sha == zero_sha else f"{remote_sha}..{local_sha}"
|
||||||
|
changed_files_raw = run_git(["diff", "--name-only", diff_range])
|
||||||
|
changed_files = [f.strip() for f in changed_files_raw.splitlines() if f.strip()]
|
||||||
|
|
||||||
|
if changed_files and all(is_doc_or_config(f) for f in changed_files):
|
||||||
|
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m All modified files are docs/config-only. Quality gate bypassed.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4. Query MCP Memory gate status
|
||||||
|
status, reason = check_mcp_gate(port, branch)
|
||||||
|
if status == 200:
|
||||||
|
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push AUTHORIZED by MCP Memory gate for branch '{branch}'.")
|
||||||
|
continue
|
||||||
|
elif status == 403:
|
||||||
|
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by MCP Memory gate for branch '{branch}'!")
|
||||||
|
if reason:
|
||||||
|
print(f" Reason: {reason}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 5. No gate found (404 or server offline) -> Request interactive approval or verify unit tests
|
||||||
|
if status != 0:
|
||||||
|
post_mcp_event(port, "gate:approval_requested", {
|
||||||
|
"branch": branch,
|
||||||
|
"local_sha": local_sha,
|
||||||
|
"remote_sha": remote_sha,
|
||||||
|
"changed_files_count": len(changed_files)
|
||||||
|
})
|
||||||
|
# Fast check: Did dashboard / operator / auditor approve or reject via event bus?
|
||||||
|
decision, dec_reason = wait_for_mcp_decision(port, f"gate:decision:{branch}", timeout_secs=1)
|
||||||
|
if decision is True:
|
||||||
|
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push INTERACTIVELY AUTHORIZED via event bus: {dec_reason}")
|
||||||
|
set_mcp_gate(port, branch, True, dec_reason)
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
|
||||||
|
continue
|
||||||
|
elif decision is False:
|
||||||
|
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by operator: {dec_reason}")
|
||||||
|
set_mcp_gate(port, branch, False, dec_reason)
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate record for '{branch}'. Verifying unit tests...")
|
||||||
|
if run_local_tests():
|
||||||
|
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Unit tests passed 100%. Push authorized.")
|
||||||
|
if status != 0:
|
||||||
|
set_mcp_gate(port, branch, True, "Unit tests passed locally via pre-push gatekeeper")
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
|
||||||
|
else:
|
||||||
|
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED! Unit tests failed locally. Fix failing tests before pushing.")
|
||||||
|
if status != 0:
|
||||||
|
set_mcp_gate(port, branch, False, "Unit tests failed during pre-push gatekeeper check")
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Native shell pre-push hook using UDP Rendezvous
|
||||||
|
|
||||||
|
PORT=${MCP_UDP_PORT1:-3001}
|
||||||
|
|
||||||
|
while read local_ref local_sha remote_ref remote_sha; do
|
||||||
|
if [ "$local_sha" = "0000000000000000000000000000000000000000" ] || [ "$local_ref" = "(delete)" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$local_ref" == refs/tags/* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
BRANCH=${local_ref#refs/heads/}
|
||||||
|
|
||||||
|
echo -e "\033[36m[Pre-Push Gatekeeper]\033[0m Checking gate for branch '$BRANCH'..."
|
||||||
|
|
||||||
|
# Check if gate is already authorized or blocked
|
||||||
|
if mcp-memory-server gate verify --action push --target "$BRANCH" --consume 2>/dev/null; then
|
||||||
|
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED by MCP Memory gate for branch '$BRANCH'."
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
EXIT_CODE=$?
|
||||||
|
if [ $EXIT_CODE -eq 1 ]; then
|
||||||
|
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push BLOCKED by MCP Memory gate for branch '$BRANCH'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If exit code is 2 (Not Found), we wait via UDP Rendezvous
|
||||||
|
echo -e "\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate for '$BRANCH'. Waiting for approval via UDP rendezvous..."
|
||||||
|
|
||||||
|
# Payload for UDP
|
||||||
|
PAYLOAD="{\"type\":\"gate_wait\",\"action\":\"push\",\"target\":\"$BRANCH\"}"
|
||||||
|
|
||||||
|
if udp-send --wait "$PORT" "$PAYLOAD"; then
|
||||||
|
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED via UDP rendezvous for branch '$BRANCH'."
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push DENIED or timed out via UDP rendezvous for branch '$BRANCH'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
+15
-1
@@ -48,6 +48,7 @@ ignore = "0.4.33"
|
|||||||
notify = "7"
|
notify = "7"
|
||||||
blake3 = "1.8"
|
blake3 = "1.8"
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
@@ -58,4 +59,17 @@ tempfile = "3.27.0"
|
|||||||
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.59.0", features = ["Win32_System_DataExchange", "Win32_System_Memory"] }
|
windows-sys = { version = "0.59.0", features = [
|
||||||
|
"Win32_System_DataExchange",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Com",
|
||||||
|
] }
|
||||||
|
windows = { version = "0.62.2", features = [
|
||||||
|
"Foundation",
|
||||||
|
"Foundation_Collections",
|
||||||
|
"Media_Ocr",
|
||||||
|
"Graphics_Imaging",
|
||||||
|
"Storage_Streams",
|
||||||
|
] }
|
||||||
+14
-3
@@ -16,15 +16,26 @@ fn main() {
|
|||||||
.is_ok_and(|out| !out.stdout.is_empty());
|
.is_ok_and(|out| !out.stdout.is_empty());
|
||||||
|
|
||||||
if is_dirty {
|
if is_dirty {
|
||||||
git_hash.push_str("-dirty");
|
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||||
|
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let build_date = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
let git_date = Command::new("git")
|
||||||
|
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||||
|
|
||||||
let version = format!("{build_date} ({git_hash})");
|
let version = format!("v{git_date} ({git_hash})");
|
||||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
println!("cargo:rerun-if-changed=../.git/index");
|
println!("cargo:rerun-if-changed=../.git/index");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
|
||||||
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
|
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
|
||||||
let mut res = winres::WindowsResource::new();
|
let mut res = winres::WindowsResource::new();
|
||||||
|
|||||||
+66
-19
@@ -14,33 +14,80 @@ pub async fn wait_for_event_handler(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let topic = params.get("topic").cloned();
|
let topic = params.get("topic").cloned();
|
||||||
let session_id = params.get("session_id").cloned();
|
let session_id = params.get("session_id").cloned();
|
||||||
|
let timeout_duration = params
|
||||||
|
.get("timeout_ms")
|
||||||
|
.and_then(|ms| ms.parse::<u64>().ok())
|
||||||
|
.map(tokio::time::Duration::from_millis)
|
||||||
|
.or_else(|| {
|
||||||
|
params
|
||||||
|
.get("timeout")
|
||||||
|
.and_then(|secs| secs.parse::<u64>().ok())
|
||||||
|
.map(tokio::time::Duration::from_secs)
|
||||||
|
});
|
||||||
|
|
||||||
let mut rx = state.handler.state.event_bus_tx.subscribe();
|
let mut rx = state.handler.state.event_bus_tx.subscribe();
|
||||||
|
|
||||||
loop {
|
let wait_future = async {
|
||||||
match rx.recv().await {
|
loop {
|
||||||
Ok(event) => {
|
match rx.recv().await {
|
||||||
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
|
Ok(event) => {
|
||||||
let session_matches = session_id
|
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
|
||||||
.as_ref()
|
let session_matches = session_id
|
||||||
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
.as_ref()
|
||||||
|
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
||||||
|
|
||||||
if topic_matches && session_matches {
|
if topic_matches && session_matches {
|
||||||
return axum::Json(event);
|
return Ok(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
return Err("Event bus closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
}
|
||||||
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped);
|
};
|
||||||
continue;
|
|
||||||
}
|
match timeout_duration {
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
Some(dur) => match tokio::time::timeout(dur, wait_future).await {
|
||||||
return axum::Json(GenericEvent {
|
Ok(Ok(event)) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
|
||||||
|
Ok(Err(err)) => (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(GenericEvent {
|
||||||
topic: "error".to_string(),
|
topic: "error".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
payload: serde_json::json!({"error": "Event bus closed"}),
|
payload: serde_json::json!({ "error": err }),
|
||||||
});
|
}),
|
||||||
}
|
)
|
||||||
}
|
.into_response(),
|
||||||
|
Err(_) => (
|
||||||
|
axum::http::StatusCode::REQUEST_TIMEOUT,
|
||||||
|
axum::Json(GenericEvent {
|
||||||
|
topic: "timeout".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"error": "timed out waiting for event",
|
||||||
|
"topic": topic
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => match wait_future.await {
|
||||||
|
Ok(event) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(GenericEvent {
|
||||||
|
topic: "error".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "error": err }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -104,9 +104,30 @@ pub async fn gate_set_handler(
|
|||||||
};
|
};
|
||||||
app_state.handler.state.env.gates.modify(|gates| {
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||||
gates.push(record);
|
gates.push(record.clone());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let summary = format!("Push gate {} set to {}", record.target, record.status);
|
||||||
|
app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.record_activity("gate", &summary, record.reason.as_deref());
|
||||||
|
let _ = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.event_bus_tx
|
||||||
|
.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"action": record.action,
|
||||||
|
"target": record.target,
|
||||||
|
"status": record.status,
|
||||||
|
"reason": record.reason,
|
||||||
|
"active_gate": record.status,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
Ok((axum::http::StatusCode::OK, "Gate state updated."))
|
Ok((axum::http::StatusCode::OK, "Gate state updated."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+257
-18
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
@@ -123,20 +123,8 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Received shutdown request via /shutdown endpoint. Creating session checkpoint and initiating graceful shutdown."
|
"Received shutdown request via /shutdown endpoint. Initiating graceful shutdown."
|
||||||
);
|
);
|
||||||
let mem_state = state.handler.state.clone();
|
|
||||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
|
||||||
let memo = crate::models::HandoffMemo {
|
|
||||||
id: format!("chk_shutdown_{}", now),
|
|
||||||
author: "ShutdownHook".to_string(),
|
|
||||||
content: format!("Automatic session checkpoint triggered on graceful server shutdown at epoch {}", now),
|
|
||||||
expires_at: None,
|
|
||||||
namespace: "global".to_string(),
|
|
||||||
timestamp: now,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
mem_state.telemetry.handoff_memos.modify(|m| m.push(memo));
|
|
||||||
|
|
||||||
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
|
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
@@ -154,8 +142,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
"app_version": env!("APP_VERSION"),
|
"app_version": env!("APP_VERSION"),
|
||||||
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
|
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
|
||||||
"target_os": std::env::consts::OS,
|
"target_os": std::env::consts::OS,
|
||||||
"target_arch": std::env::consts::ARCH,
|
"target_arch": std::env::consts::ARCH
|
||||||
"shutdown_checkpoint": format!("chk_shutdown_{}", now)
|
|
||||||
}
|
}
|
||||||
})).into_response()
|
})).into_response()
|
||||||
},
|
},
|
||||||
@@ -309,6 +296,30 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tech_debts/{id}/resolve",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.code.tech_debts.modify(|debts| {
|
||||||
|
for d in debts.iter_mut() {
|
||||||
|
if d.id == id {
|
||||||
|
d.is_resolved = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("tech_debt", &format!("Resolved tech debt: {}", id), Some("resolved"));
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", id) })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Tech debt not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/adrs",
|
"/api/adrs",
|
||||||
get({
|
get({
|
||||||
@@ -398,6 +409,139 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/env_fingerprints",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.env_fingerprints.read_with(|items| {
|
||||||
|
let values: Vec<_> = items.values().cloned().collect();
|
||||||
|
serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/env_requirements",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.env_requirements.read_with(|items| {
|
||||||
|
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/environments",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.environments.read_with(|items| {
|
||||||
|
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.gates.read_with(|items| {
|
||||||
|
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates/{id}/authorize",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == id {
|
||||||
|
g.status = "authorized".to_string();
|
||||||
|
g.reason = Some("Manually authorized via dashboard".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("gate", &format!("Authorized push gate: {}", id), Some("authorized"));
|
||||||
|
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": id, "status": "authorized", "active_gate": "authorized" }),
|
||||||
|
});
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates/{id}/block",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == id {
|
||||||
|
g.status = "blocked".to_string();
|
||||||
|
g.reason = Some("Manually blocked via dashboard".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("gate", &format!("Blocked push gate: {}", id), Some("blocked"));
|
||||||
|
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": id, "status": "blocked", "active_gate": "blocked" }),
|
||||||
|
});
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates/{id}",
|
||||||
|
delete({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.env.gates.modify(|gates| {
|
||||||
|
let initial_len = gates.len();
|
||||||
|
gates.retain(|g| g.id != id);
|
||||||
|
found = gates.len() < initial_len;
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("gate", &format!("Deleted push gate: {}", id), Some("deleted"));
|
||||||
|
let active_gate = state_clone.env.gates.read_with(|gates| gates.iter().find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate")).map(|g| g.status.clone()));
|
||||||
|
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent { topic: "gate:event".to_string(), session_id: None, payload: serde_json::json!({ "id": id, "status": "deleted", "active_gate": active_gate }) });
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/stats",
|
"/api/stats",
|
||||||
get({
|
get({
|
||||||
@@ -967,7 +1111,8 @@ mod tests {
|
|||||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let summaries: Vec<crate::models::SessionSummary> = serde_json::from_slice(&body_bytes).unwrap();
|
let summaries: Vec<crate::models::SessionSummary> =
|
||||||
|
serde_json::from_slice(&body_bytes).unwrap();
|
||||||
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");
|
||||||
}
|
}
|
||||||
@@ -1002,9 +1147,103 @@ mod tests {
|
|||||||
let response = app.oneshot(request).await.unwrap();
|
let response = app.oneshot(request).await.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let adr = app_state.handler.state.code.adrs.read_with(|adrs| adrs[0].clone());
|
let adr = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.code
|
||||||
|
.adrs
|
||||||
|
.read_with(|adrs| adrs[0].clone());
|
||||||
assert_eq!(adr.status, "implemented");
|
assert_eq!(adr.status, "implemented");
|
||||||
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
|
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
|
||||||
assert!(adr.resolved_at.is_some());
|
assert!(adr.resolved_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_environment_endpoints() {
|
||||||
|
let (app, app_state, _dir) = setup_app().await;
|
||||||
|
|
||||||
|
app_state.handler.state.env.environments.modify(|envs| {
|
||||||
|
envs.push(crate::models::EnvironmentDetail {
|
||||||
|
namespace: "default".to_string(),
|
||||||
|
name: "staging-k8s".to_string(),
|
||||||
|
url: "https://staging.internal.net".to_string(),
|
||||||
|
description: "Staging cluster".to_string(),
|
||||||
|
requires_vpn: true,
|
||||||
|
updated_at: 12345,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/environments")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let envs: Vec<crate::models::EnvironmentDetail> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(envs.len(), 1);
|
||||||
|
assert_eq!(envs[0].name, "staging-k8s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interactive_endpoints() {
|
||||||
|
let (app, app_state, _dir) = setup_app().await;
|
||||||
|
|
||||||
|
// 1. Tech Debt resolve
|
||||||
|
app_state.handler.state.code.tech_debts.modify(|debts| {
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "TD-001".to_string(),
|
||||||
|
description: "Fix memory leak".to_string(),
|
||||||
|
is_resolved: false,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/tech_debts/TD-001/resolve")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let debt = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.code
|
||||||
|
.tech_debts
|
||||||
|
.read_with(|d| d[0].clone());
|
||||||
|
assert!(debt.is_resolved);
|
||||||
|
|
||||||
|
// 2. Gate authorize
|
||||||
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
|
gates.push(crate::models::GateRecord {
|
||||||
|
id: "gate-123".to_string(),
|
||||||
|
action: "push".to_string(),
|
||||||
|
target: "master".to_string(),
|
||||||
|
status: "blocked".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let app2 = create_router(app_state.clone());
|
||||||
|
let req2 = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/gates/gate-123/authorize")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp2 = app2.oneshot(req2).await.unwrap();
|
||||||
|
assert_eq!(resp2.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let gate = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.env
|
||||||
|
.gates
|
||||||
|
.read_with(|g| g[0].clone());
|
||||||
|
assert_eq!(gate.status, "authorized");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +187,36 @@ pub async fn nvim_telemetry_handler(
|
|||||||
let _ = state.handler.state.event_bus_tx.send(event);
|
let _ = state.handler.state.event_bus_tx.send(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||||
|
let payload_val = serde_json::json!(&payload);
|
||||||
|
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:{}", payload.event),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(ref req_id) = payload.request_id {
|
||||||
|
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref diff_id) = payload.diff_id {
|
||||||
|
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref f) = payload.file {
|
if let Some(ref f) = payload.file {
|
||||||
state.handler.state.record_activity(
|
state.handler.state.record_activity(
|
||||||
"editor",
|
"editor",
|
||||||
@@ -195,10 +225,28 @@ pub async fn nvim_telemetry_handler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Extract active task & push gate for zero-polling statusline
|
||||||
|
let active_task = state.handler.state.project.tasks.read_with(|tasks| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.is_active())
|
||||||
|
.or_else(|| tasks.iter().find(|t| t.status == "pending" && t.parent_id.is_none()))
|
||||||
|
.map(|t| t.title.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
let active_gate = state.handler.state.env.gates.read_with(|gates| {
|
||||||
|
gates
|
||||||
|
.iter()
|
||||||
|
.find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate"))
|
||||||
|
.map(|g| g.status.clone())
|
||||||
|
});
|
||||||
|
|
||||||
axum::Json(serde_json::json!({
|
axum::Json(serde_json::json!({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"tech_debts": tech_debts,
|
"tech_debts": tech_debts,
|
||||||
"adrs": adrs
|
"adrs": adrs,
|
||||||
|
"active_task": active_task,
|
||||||
|
"active_gate": active_gate
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub struct SessionCleanup {
|
|||||||
pub state: Arc<AppState>,
|
pub state: Arc<AppState>,
|
||||||
pub send_task: tokio::task::JoinHandle<()>,
|
pub send_task: tokio::task::JoinHandle<()>,
|
||||||
pub recv_task: tokio::task::JoinHandle<()>,
|
pub recv_task: tokio::task::JoinHandle<()>,
|
||||||
|
pub event_task: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SessionCleanup {
|
impl Drop for SessionCleanup {
|
||||||
@@ -41,6 +42,7 @@ impl Drop for SessionCleanup {
|
|||||||
.remove(&self.session_id);
|
.remove(&self.session_id);
|
||||||
self.send_task.abort();
|
self.send_task.abort();
|
||||||
self.recv_task.abort();
|
self.recv_task.abort();
|
||||||
|
self.event_task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +149,42 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let event_tx = tx.clone();
|
||||||
|
let mut event_bus_rx = state.handler.state.event_bus_tx.subscribe();
|
||||||
|
let event_task = tokio::spawn(async move {
|
||||||
|
while let Ok(event) = event_bus_rx.recv().await {
|
||||||
|
if event.topic == "resource:updated" {
|
||||||
|
if let Some(uri) = event.payload.get("uri").and_then(|u| u.as_str()) {
|
||||||
|
let notif = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/resources/updated",
|
||||||
|
"params": {
|
||||||
|
"uri": uri
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if event_tx.send(notif.to_string()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if event.topic == "task:event" {
|
||||||
|
let notif = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/task/updated",
|
||||||
|
"params": event.payload
|
||||||
|
});
|
||||||
|
if event_tx.send(notif.to_string()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mut cleanup = SessionCleanup {
|
let mut cleanup = SessionCleanup {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
state: Arc::clone(&state),
|
state: Arc::clone(&state),
|
||||||
send_task,
|
send_task,
|
||||||
recv_task,
|
recv_task,
|
||||||
|
event_task,
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -194,6 +227,7 @@ mod tests {
|
|||||||
|
|
||||||
let send_task = tokio::spawn(async {});
|
let send_task = tokio::spawn(async {});
|
||||||
let recv_task = tokio::spawn(async {});
|
let recv_task = tokio::spawn(async {});
|
||||||
|
let event_task = tokio::spawn(async {});
|
||||||
|
|
||||||
{
|
{
|
||||||
let _cleanup = SessionCleanup {
|
let _cleanup = SessionCleanup {
|
||||||
@@ -201,6 +235,7 @@ mod tests {
|
|||||||
state: app_state.clone(),
|
state: app_state.clone(),
|
||||||
send_task,
|
send_task,
|
||||||
recv_task,
|
recv_task,
|
||||||
|
event_task,
|
||||||
};
|
};
|
||||||
} // Drop happens here
|
} // Drop happens here
|
||||||
|
|
||||||
|
|||||||
@@ -642,6 +642,141 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Command Palette Modal */
|
||||||
|
#cmd-palette-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 12vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#cmd-palette-modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 680px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: paletteFadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes paletteFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.palette-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.palette-search-icon {
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.palette-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 1.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.palette-kbd-badge {
|
||||||
|
font-size: 0.72em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.palette-results {
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.palette-item {
|
||||||
|
padding: 10px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.palette-item:hover, .palette-item.selected {
|
||||||
|
background: rgba(52, 152, 219, 0.12);
|
||||||
|
}
|
||||||
|
.palette-item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item-icon {
|
||||||
|
font-size: 1em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.palette-item-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.palette-item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item-sub {
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item-badge {
|
||||||
|
font-size: 0.72em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.palette-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -651,12 +786,35 @@
|
|||||||
<span id="app-version" style="font-size:0.8em; color:var(--text-secondary); margin-left:12px; font-family:monospace;"></span>
|
<span id="app-version" style="font-size:0.8em; color:var(--text-secondary); margin-left:12px; font-family:monospace;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button class="theme-toggle" onclick="openCommandPalette()" title="Command Palette (Ctrl+K)" style="background:var(--card-bg); border:1px solid var(--border-color); color:var(--text-primary); border-radius:6px; padding:5px 10px; font-size:13px; display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||||
|
<span>🔍</span>
|
||||||
|
<span>Command Palette</span>
|
||||||
|
<span class="enterprise-kbd">Ctrl+K</span>
|
||||||
|
</button>
|
||||||
<button class="theme-toggle" onclick="testClipboard()" title="Test Clipboard Vision Tool" style="background:#3498db; color:white; border-radius:4px; padding:4px 8px; font-size:14px; display:flex; align-items:center; gap:5px;">📋 Inspect Clipboard</button>
|
<button class="theme-toggle" onclick="testClipboard()" title="Test Clipboard Vision Tool" style="background:#3498db; color:white; border-radius:4px; padding:4px 8px; font-size:14px; display:flex; align-items:center; gap:5px;">📋 Inspect Clipboard</button>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
|
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
|
||||||
<div style="font-weight: 500;"><span class="status-dot"></span>System Online</div>
|
<div style="font-weight: 500;"><span class="status-dot"></span>System Online</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Palette Modal -->
|
||||||
|
<div id="cmd-palette-backdrop" onclick="onPaletteBackdropClick(event)">
|
||||||
|
<div id="cmd-palette-modal">
|
||||||
|
<div class="palette-header">
|
||||||
|
<span class="palette-search-icon">🔍</span>
|
||||||
|
<input id="palette-input" class="palette-input" type="text" placeholder="Type a command or search tasks, debts, ADRs, environments..." autocomplete="off" oninput="onPaletteSearch(this.value)" onkeydown="onPaletteKeyDown(event)">
|
||||||
|
<span class="palette-kbd-badge">ESC</span>
|
||||||
|
</div>
|
||||||
|
<div id="palette-results" class="palette-results">
|
||||||
|
<!-- Injected dynamically -->
|
||||||
|
</div>
|
||||||
|
<div class="palette-footer">
|
||||||
|
<div><span>↑↓</span> to navigate • <span>↵</span> to jump • <span>ESC</span> to close</div>
|
||||||
|
<div id="palette-count" style="font-family: monospace;">0 items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Clipboard Modal -->
|
<!-- Clipboard Modal -->
|
||||||
<div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;">
|
<div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;">
|
||||||
<div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
|
<div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
|
||||||
@@ -681,6 +839,7 @@
|
|||||||
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
|
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
|
||||||
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
|
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
|
||||||
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
|
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
|
||||||
|
<button class="tab-button" onclick="switchTab('env-tab', this)">Environment</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="graph-tab" class="tab-content">
|
<div id="graph-tab" class="tab-content">
|
||||||
@@ -785,6 +944,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="env-tab" class="tab-content">
|
||||||
|
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
||||||
|
<h2>Target Environments & Infrastructure</h2>
|
||||||
|
<div id="environments-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Tool & System Fingerprints</h2>
|
||||||
|
<div id="fingerprints-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Environment Requirements & Secrets</h2>
|
||||||
|
<div id="requirements-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Quality & Authorization Gates</h2>
|
||||||
|
<div id="gates-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="activity-tab" class="tab-content active">
|
<div id="activity-tab" class="tab-content active">
|
||||||
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
|
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
|
||||||
<div id="activity-feed" style="flex:1;"></div>
|
<div id="activity-feed" style="flex:1;"></div>
|
||||||
|
|||||||
+507
-13
@@ -42,6 +42,9 @@ function switchTab(tabId, btn) {
|
|||||||
case "ledger-tab":
|
case "ledger-tab":
|
||||||
loadLedger();
|
loadLedger();
|
||||||
break;
|
break;
|
||||||
|
case "env-tab":
|
||||||
|
loadEnvironment();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
@@ -291,10 +294,20 @@ async function loadGraph() {
|
|||||||
}
|
}
|
||||||
async function completeTask(id) {
|
async function completeTask(id) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("tasks/complete", { id });
|
||||||
|
} else {
|
||||||
|
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||||
|
}
|
||||||
loadTasks();
|
loadTasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to complete task", e);
|
console.error("Failed to complete task via WS, falling back to HTTP", e);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||||
|
loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to complete task via HTTP fallback", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function buildTaskTreeHTML(tasks, parentId, depth = 0) {
|
function buildTaskTreeHTML(tasks, parentId, depth = 0) {
|
||||||
@@ -683,6 +696,34 @@ async function loadActivityHistory() {
|
|||||||
}
|
}
|
||||||
var wsConnected = false;
|
var wsConnected = false;
|
||||||
var activeSSE = null;
|
var activeSSE = null;
|
||||||
|
var activeWS = null;
|
||||||
|
var wsRpcId = 1;
|
||||||
|
var pendingWsRequests = new Map;
|
||||||
|
function sendWsRpc(method, params = {}, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!activeWS || activeWS.readyState !== WebSocket.OPEN) {
|
||||||
|
return reject(new Error("WebSocket not open"));
|
||||||
|
}
|
||||||
|
const id = ++wsRpcId;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingWsRequests.delete(id);
|
||||||
|
reject(new Error(`WebSocket RPC timeout for ${method}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
pendingWsRequests.set(id, { resolve, reject, timer });
|
||||||
|
try {
|
||||||
|
activeWS.send(JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingWsRequests.delete(id);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
var tabRefreshDebounceTimers = {};
|
var tabRefreshDebounceTimers = {};
|
||||||
function requestDomainRefresh(domain) {
|
function requestDomainRefresh(domain) {
|
||||||
const tabMap = {
|
const tabMap = {
|
||||||
@@ -693,7 +734,9 @@ function requestDomainRefresh(domain) {
|
|||||||
snippets: "snippets-tab",
|
snippets: "snippets-tab",
|
||||||
terminal: "terminal-tab",
|
terminal: "terminal-tab",
|
||||||
ledger: "ledger-tab",
|
ledger: "ledger-tab",
|
||||||
memos: "memos-tab"
|
memos: "memos-tab",
|
||||||
|
env: "env-tab",
|
||||||
|
gates: "env-tab"
|
||||||
};
|
};
|
||||||
const targetTab = tabMap[domain];
|
const targetTab = tabMap[domain];
|
||||||
if (!targetTab || targetTab !== currentTabId) {
|
if (!targetTab || targetTab !== currentTabId) {
|
||||||
@@ -729,6 +772,10 @@ function requestDomainRefresh(domain) {
|
|||||||
case "memos":
|
case "memos":
|
||||||
loadMemos();
|
loadMemos();
|
||||||
break;
|
break;
|
||||||
|
case "env":
|
||||||
|
case "gates":
|
||||||
|
loadEnvironment();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -750,6 +797,9 @@ function handleIncomingActivity(payload) {
|
|||||||
requestDomainRefresh("graph");
|
requestDomainRefresh("graph");
|
||||||
} else if (method === "notifications/task/completed" || category.startsWith("TASK")) {
|
} else if (method === "notifications/task/completed" || category.startsWith("TASK")) {
|
||||||
requestDomainRefresh("task");
|
requestDomainRefresh("task");
|
||||||
|
} else if (category === "GATE" || category === "QUALITY_GATE" || category === "ENVIRONMENT" || category === "ENV") {
|
||||||
|
requestDomainRefresh("gates");
|
||||||
|
requestDomainRefresh("env");
|
||||||
} else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") {
|
} else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") {
|
||||||
requestDomainRefresh("techdebt");
|
requestDomainRefresh("techdebt");
|
||||||
} else if (category === "SNIPPET") {
|
} else if (category === "SNIPPET") {
|
||||||
@@ -790,6 +840,7 @@ function setupWS() {
|
|||||||
}
|
}
|
||||||
ws.onopen = function() {
|
ws.onopen = function() {
|
||||||
wsConnected = true;
|
wsConnected = true;
|
||||||
|
activeWS = ws;
|
||||||
if (activeSSE) {
|
if (activeSSE) {
|
||||||
activeSSE.close();
|
activeSSE.close();
|
||||||
activeSSE = null;
|
activeSSE = null;
|
||||||
@@ -798,7 +849,18 @@ function setupWS() {
|
|||||||
ws.onmessage = function(event) {
|
ws.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === "activity" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") {
|
if (data.id !== undefined && pendingWsRequests.has(data.id)) {
|
||||||
|
const handler = pendingWsRequests.get(data.id);
|
||||||
|
pendingWsRequests.delete(data.id);
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
if (data.error) {
|
||||||
|
handler.reject(new Error(data.error.message || JSON.stringify(data.error)));
|
||||||
|
} else {
|
||||||
|
handler.resolve(data.result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "activity" || data.type === "gate" || data.type === "task" || data.type === "tech_debt" || data.type === "nvim_telemetry" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") {
|
||||||
const payload = data.params || data.data || data;
|
const payload = data.params || data.data || data;
|
||||||
handleIncomingActivity(payload);
|
handleIncomingActivity(payload);
|
||||||
}
|
}
|
||||||
@@ -808,12 +870,24 @@ function setupWS() {
|
|||||||
};
|
};
|
||||||
ws.onclose = function() {
|
ws.onclose = function() {
|
||||||
wsConnected = false;
|
wsConnected = false;
|
||||||
|
activeWS = null;
|
||||||
|
pendingWsRequests.forEach((handler) => {
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
handler.reject(new Error("WebSocket disconnected"));
|
||||||
|
});
|
||||||
|
pendingWsRequests.clear();
|
||||||
console.log("WebSocket closed, falling back to SSE and retrying WS in 5s...");
|
console.log("WebSocket closed, falling back to SSE and retrying WS in 5s...");
|
||||||
setupSSE();
|
setupSSE();
|
||||||
setTimeout(setupWS, 5000);
|
setTimeout(setupWS, 5000);
|
||||||
};
|
};
|
||||||
ws.onerror = function() {
|
ws.onerror = function() {
|
||||||
wsConnected = false;
|
wsConnected = false;
|
||||||
|
activeWS = null;
|
||||||
|
pendingWsRequests.forEach((handler) => {
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
handler.reject(new Error("WebSocket error"));
|
||||||
|
});
|
||||||
|
pendingWsRequests.clear();
|
||||||
setupSSE();
|
setupSSE();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1328,7 +1402,10 @@ function loadTechDebt() {
|
|||||||
<span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span>
|
<span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span>
|
||||||
${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""}
|
${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div>${statusBadge}</div>
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<div>${statusBadge}</div>
|
||||||
|
${!item.is_resolved ? `<button class="complete-btn" style="position:static;" onclick="resolveTechDebt('${escapeHtml(item.id)}')" title="Resolve Tech Debt">✓</button>` : ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">\uD83D\uDCC4 ${escapeHtml(fileLoc)}</div>` : ""}
|
${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">\uD83D\uDCC4 ${escapeHtml(fileLoc)}</div>` : ""}
|
||||||
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div>
|
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div>
|
||||||
@@ -1704,6 +1781,121 @@ function loadSnippets() {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function loadEnvironment() {
|
||||||
|
loadGenericList("/api/environments", "environments-container", (item) => {
|
||||||
|
const isVpn = item.requires_vpn;
|
||||||
|
const envType = (item.env_type || "dev").toLowerCase();
|
||||||
|
let typeColor = "#3498db";
|
||||||
|
if (envType === "prod")
|
||||||
|
typeColor = "#e74c3c";
|
||||||
|
else if (envType === "staging")
|
||||||
|
typeColor = "#f39c12";
|
||||||
|
else if (envType === "qa")
|
||||||
|
typeColor = "#9b59b6";
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.name || "Environment")}</strong>
|
||||||
|
<span style="background:${typeColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(envType)}</span>
|
||||||
|
${isVpn ? '<span style="background:#e67e22; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">\uD83D\uDD12 VPN Required</span>' : ""}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
|
||||||
|
</div>
|
||||||
|
${item.url ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em;"><a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color); text-decoration:none;">\uD83C\uDF10 ${escapeHtml(item.url)}</a></div>` : ""}
|
||||||
|
${item.description ? `<div style="margin-top:4px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
${item.ssh_host ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">SSH: ${escapeHtml(item.ssh_host)}</span>` : ""}
|
||||||
|
${item.healthcheck_endpoint ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Health: ${escapeHtml(item.healthcheck_endpoint)}</span>` : ""}
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Environments",
|
||||||
|
cardBorderColor: "#3498db",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.updated_at,
|
||||||
|
searchFields: (item) => [item.name, item.url, item.description, item.env_type, item.ssh_host, item.namespace]
|
||||||
|
});
|
||||||
|
loadGenericList("/api/env_fingerprints", "fingerprints-container", (item) => {
|
||||||
|
const toolVersions = item.tool_versions || {};
|
||||||
|
const tools = Object.entries(toolVersions).map(([k, v]) => `<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; font-size:0.8em; font-family:monospace; border:1px solid var(--border-color);"><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</span>`).join(" ");
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:1.05em; color:var(--text-primary);">\uD83D\uDCBB ${escapeHtml(item.os || "Unknown OS")} (${escapeHtml(item.shell || "shell")})</strong>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
|
||||||
|
</div>
|
||||||
|
${tools ? `<div style="margin-top:10px; display:flex; gap:6px; flex-wrap:wrap; align-items:center;">${tools}</div>` : '<div style="margin-top:6px; font-size:0.85em; color:var(--text-secondary);">No tool versions registered</div>'}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; color:var(--text-secondary);">
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); margin-right:8px;">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Fingerprints",
|
||||||
|
cardBorderColor: "#1abc9c",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.updated_at,
|
||||||
|
searchFields: (item) => [item.namespace, item.os, item.shell, item.repo_name, JSON.stringify(item.tool_versions || {})]
|
||||||
|
});
|
||||||
|
loadGenericList("/api/env_requirements", "requirements-container", (item) => {
|
||||||
|
const isSecret = item.is_secret;
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<span style="font-family:monospace; font-size:1.05em; font-weight:bold; color:var(--text-primary);">${escapeHtml(item.key)}</span>
|
||||||
|
${isSecret ? '<span style="background:#e74c3c; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">\uD83D\uDD12 Secret</span>' : '<span style="background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); font-size:0.75em; padding:2px 6px; border-radius:4px; margin-left:6px;">Public</span>'}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
|
||||||
|
</div>
|
||||||
|
${item.description ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
${item.default_value ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Default: ${escapeHtml(item.default_value)}</span>` : ""}
|
||||||
|
${item.validation_regex ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Regex: ${escapeHtml(item.validation_regex)}</span>` : ""}
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Requirements",
|
||||||
|
cardBorderColor: "#e67e22",
|
||||||
|
defaultSort: "desc",
|
||||||
|
searchFields: (item) => [item.key, item.description, item.default_value, item.namespace, item.repo_name]
|
||||||
|
});
|
||||||
|
loadGenericList("/api/gates", "gates-container", (item) => {
|
||||||
|
const status = (item.status || "pending").toLowerCase();
|
||||||
|
let statusColor = "var(--accent-color)";
|
||||||
|
if (status === "authorized")
|
||||||
|
statusColor = "var(--success-color)";
|
||||||
|
else if (status === "blocked")
|
||||||
|
statusColor = "var(--error-color)";
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.action)} → ${escapeHtml(item.target)}</strong>
|
||||||
|
<span style="background:${statusColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(status)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "global")}</span>
|
||||||
|
${status !== "authorized" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(46,204,113,0.15); color:var(--success-color); border:1px solid var(--success-color); cursor:pointer;" onclick="authorizeGate('${escapeHtml(item.id)}')" title="Authorize Gate">✓ Authorize</button>` : ""}
|
||||||
|
${status !== "blocked" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(231,76,60,0.15); color:var(--error-color); border:1px solid var(--error-color); cursor:pointer;" onclick="blockGate('${escapeHtml(item.id)}')" title="Block Gate">\uD83D\uDEAB Block</button>` : ""}
|
||||||
|
<button class="tab-button" style="padding:2px 6px; font-size:0.75em; background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); cursor:pointer;" onclick="deleteGate('${escapeHtml(item.id)}')" title="Delete Gate">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${item.reason ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.reason)}</div>` : ""}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
${item.gate_type ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Type: ${escapeHtml(item.gate_type)}</span>` : ""}
|
||||||
|
${item.enforcer ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Enforcer: ${escapeHtml(item.enforcer)}</span>` : ""}
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}, {
|
||||||
|
itemNoun: "Gates",
|
||||||
|
cardBorderColor: "#e74c3c",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item) => item.timestamp,
|
||||||
|
searchFields: (item) => [item.action, item.target, item.status, item.reason, item.enforcer]
|
||||||
|
});
|
||||||
|
}
|
||||||
function loadAllExtras() {
|
function loadAllExtras() {
|
||||||
loadTerminal();
|
loadTerminal();
|
||||||
loadTechDebt();
|
loadTechDebt();
|
||||||
@@ -1772,21 +1964,308 @@ function setupSSE() {
|
|||||||
console.error("SSE initialization error", e);
|
console.error("SSE initialization error", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function resolveTechDebt(id) {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("tech_debts/resolve", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to resolve tech debt", await res.text());
|
||||||
|
}
|
||||||
|
loadTechDebt();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error resolving tech debt via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to resolve tech debt", await res.text());
|
||||||
|
loadTechDebt();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error resolving tech debt", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function authorizeGate(id) {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("gates/authorize", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to authorize gate", await res.text());
|
||||||
|
}
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error authorizing gate via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to authorize gate", await res.text());
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error authorizing gate", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function blockGate(id) {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("gates/block", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to block gate", await res.text());
|
||||||
|
}
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error blocking gate via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to block gate", await res.text());
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error blocking gate", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteGate(id) {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("gates/delete", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to delete gate", await res.text());
|
||||||
|
}
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting gate via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok)
|
||||||
|
console.error("Failed to delete gate", await res.text());
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error deleting gate", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var paletteItems = [];
|
||||||
|
var filteredPaletteItems = [];
|
||||||
|
var paletteSelectedIndex = 0;
|
||||||
|
var paletteDataLoaded = false;
|
||||||
|
async function preloadPaletteData() {
|
||||||
|
try {
|
||||||
|
const [tasksRes, debtsRes, adrsRes, envsRes, gatesRes] = await Promise.all([
|
||||||
|
fetch("/api/tasks", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/tech_debts", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/adrs", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/environments", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/gates", { cache: "no-store" }).then((r) => r.json()).catch(() => [])
|
||||||
|
]);
|
||||||
|
const items = [
|
||||||
|
{ id: "nav-activity", category: "nav", icon: "⚡", title: "Live Activity Feed", sub: "Inspect real-time agent signals & SSE", badge: "VIEW", tabId: "activity-tab" },
|
||||||
|
{ id: "nav-ledger", category: "nav", icon: "\uD83D\uDCD6", title: "Code Ledger", sub: "View logged code changes & commits", badge: "VIEW", tabId: "ledger-tab" },
|
||||||
|
{ id: "nav-graph", category: "nav", icon: "\uD83D\uDD78️", title: "Knowledge Graph Explorer", sub: "Explore nodes, relationships & dependencies", badge: "VIEW", tabId: "graph-tab" },
|
||||||
|
{ id: "nav-search", category: "nav", icon: "\uD83D\uDD0D", title: "Omni-Search", sub: "Full-text cross-entity search", badge: "VIEW", tabId: "search-tab" },
|
||||||
|
{ id: "nav-tasks", category: "nav", icon: "\uD83D\uDCCB", title: "Task Board", sub: "HTN / DAG task hierarchies", badge: "VIEW", tabId: "task-tab" },
|
||||||
|
{ id: "nav-techdebt", category: "nav", icon: "⚡", title: "Tech Debt & Error Fixes", sub: "Known tech debt and error fixes", badge: "VIEW", tabId: "techdebt-tab" },
|
||||||
|
{ id: "nav-adrs", category: "nav", icon: "\uD83D\uDCDC", title: "Architectural Decision Records", sub: "ADRs and design choices", badge: "VIEW", tabId: "adrs-tab" },
|
||||||
|
{ id: "nav-memos", category: "nav", icon: "\uD83D\uDCDD", title: "Handoff Memos", sub: "Agent handoffs & session summaries", badge: "VIEW", tabId: "memos-tab" },
|
||||||
|
{ id: "nav-snippets", category: "nav", icon: "\uD83D\uDCCE", title: "Code Snippets", sub: "Indexed snippets & patterns", badge: "VIEW", tabId: "snippets-tab" },
|
||||||
|
{ id: "nav-terminal", category: "nav", icon: "\uD83D\uDCBB", title: "Terminal History", sub: "Command execution log & exit codes", badge: "VIEW", tabId: "terminal-tab" },
|
||||||
|
{ id: "nav-env", category: "nav", icon: "\uD83C\uDF10", title: "Environment & Quality Gates", sub: "Deploy targets, fingerprints, requirements & gates", badge: "VIEW", tabId: "env-tab" }
|
||||||
|
];
|
||||||
|
if (Array.isArray(tasksRes)) {
|
||||||
|
tasksRes.forEach((t) => {
|
||||||
|
items.push({
|
||||||
|
id: t.id,
|
||||||
|
category: "task",
|
||||||
|
icon: t.status === "completed" ? "✓" : "\uD83D\uDCCB",
|
||||||
|
title: t.title || t.id,
|
||||||
|
sub: t.description || `Task (${t.status || "pending"})`,
|
||||||
|
badge: t.status || "TASK",
|
||||||
|
tabId: "task-tab"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(debtsRes)) {
|
||||||
|
debtsRes.forEach((d) => {
|
||||||
|
items.push({
|
||||||
|
id: d.id,
|
||||||
|
category: "techdebt",
|
||||||
|
icon: "⚡",
|
||||||
|
title: `${d.id}: ${d.description || ""}`.trim(),
|
||||||
|
sub: d.file_path ? `${d.file_path} - Severity: ${d.severity || "medium"}` : `Severity: ${d.severity || "medium"}`,
|
||||||
|
badge: d.is_resolved ? "RESOLVED" : d.severity || "DEBT",
|
||||||
|
tabId: "techdebt-tab"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(adrsRes)) {
|
||||||
|
adrsRes.forEach((a) => {
|
||||||
|
items.push({
|
||||||
|
id: a.id,
|
||||||
|
category: "adr",
|
||||||
|
icon: "\uD83D\uDCDC",
|
||||||
|
title: `${a.id}: ${a.title || ""}`.trim(),
|
||||||
|
sub: a.decision || `Status: ${a.status || "accepted"}`,
|
||||||
|
badge: a.status || "ADR",
|
||||||
|
tabId: "adrs-tab"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(envsRes)) {
|
||||||
|
envsRes.forEach((e) => {
|
||||||
|
items.push({
|
||||||
|
id: e.name || e.url,
|
||||||
|
category: "env",
|
||||||
|
icon: "\uD83C\uDF10",
|
||||||
|
title: e.name || "Target Environment",
|
||||||
|
sub: `${e.url || ""} ${e.description ? "• " + e.description : ""}`.trim(),
|
||||||
|
badge: e.env_type || "ENV",
|
||||||
|
tabId: "env-tab"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(gatesRes)) {
|
||||||
|
gatesRes.forEach((g) => {
|
||||||
|
items.push({
|
||||||
|
id: g.id,
|
||||||
|
category: "gate",
|
||||||
|
icon: g.status === "authorized" ? "\uD83D\uDFE2" : g.status === "blocked" ? "\uD83D\uDD34" : "\uD83D\uDFE1",
|
||||||
|
title: `${g.action} → ${g.target}`,
|
||||||
|
sub: g.reason || `Enforcer: ${g.enforcer || "gate"}`,
|
||||||
|
badge: g.status || "GATE",
|
||||||
|
tabId: "env-tab"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
paletteItems = items;
|
||||||
|
paletteDataLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to preload palette data", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function openCommandPalette() {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
const input = document.getElementById("palette-input");
|
||||||
|
if (!backdrop || !input)
|
||||||
|
return;
|
||||||
|
backdrop.style.display = "flex";
|
||||||
|
input.value = "";
|
||||||
|
paletteSelectedIndex = 0;
|
||||||
|
preloadPaletteData().then(() => {
|
||||||
|
onPaletteSearch("");
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function closeCommandPalette() {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
if (backdrop)
|
||||||
|
backdrop.style.display = "none";
|
||||||
|
}
|
||||||
|
function onPaletteBackdropClick(e) {
|
||||||
|
if (e.target === document.getElementById("cmd-palette-backdrop")) {
|
||||||
|
closeCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPaletteSearch(query) {
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
filteredPaletteItems = paletteItems.slice(0, 30);
|
||||||
|
} else {
|
||||||
|
filteredPaletteItems = paletteItems.filter((item) => item.title.toLowerCase().includes(q) || item.sub.toLowerCase().includes(q) || item.badge.toLowerCase().includes(q) || item.category.toLowerCase().includes(q)).slice(0, 40);
|
||||||
|
}
|
||||||
|
paletteSelectedIndex = 0;
|
||||||
|
renderPaletteResults();
|
||||||
|
}
|
||||||
|
function renderPaletteResults() {
|
||||||
|
const container = document.getElementById("palette-results");
|
||||||
|
const countEl = document.getElementById("palette-count");
|
||||||
|
if (!container)
|
||||||
|
return;
|
||||||
|
if (countEl)
|
||||||
|
countEl.innerText = `${filteredPaletteItems.length} results`;
|
||||||
|
if (filteredPaletteItems.length === 0) {
|
||||||
|
container.innerHTML = '<li class="enterprise-empty-state" style="margin: 10px 18px;">No matching items found.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = filteredPaletteItems.map((item, idx) => {
|
||||||
|
const isSel = idx === paletteSelectedIndex;
|
||||||
|
return `
|
||||||
|
<li class="palette-item ${isSel ? "selected" : ""}" data-index="${idx}" onclick="selectPaletteItem(${idx})">
|
||||||
|
<div class="palette-item-left">
|
||||||
|
<span class="palette-item-icon">${escapeHtml(item.icon)}</span>
|
||||||
|
<div class="palette-item-text">
|
||||||
|
<span class="palette-item-title">${escapeHtml(item.title)}</span>
|
||||||
|
<span class="palette-item-sub">${escapeHtml(item.sub)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="palette-item-badge">${escapeHtml(item.badge)}</span>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
const selectedEl = container.querySelector(`.palette-item[data-index="${paletteSelectedIndex}"]`);
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selectPaletteItem(idx) {
|
||||||
|
const item = filteredPaletteItems[idx];
|
||||||
|
if (!item)
|
||||||
|
return;
|
||||||
|
closeCommandPalette();
|
||||||
|
const tabBtn = document.querySelector(`button[onclick*="${item.tabId}"]`);
|
||||||
|
if (tabBtn) {
|
||||||
|
switchTab(item.tabId, tabBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPaletteKeyDown(e) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredPaletteItems.length > 0) {
|
||||||
|
paletteSelectedIndex = (paletteSelectedIndex + 1) % filteredPaletteItems.length;
|
||||||
|
renderPaletteResults();
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredPaletteItems.length > 0) {
|
||||||
|
paletteSelectedIndex = (paletteSelectedIndex - 1 + filteredPaletteItems.length) % filteredPaletteItems.length;
|
||||||
|
renderPaletteResults();
|
||||||
|
}
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectPaletteItem(paletteSelectedIndex);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
document.addEventListener("keydown", function(e) {
|
document.addEventListener("keydown", function(e) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const searchTabBtn = document.querySelector('button[onclick*="search-tab"]');
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
if (searchTabBtn) {
|
if (backdrop && backdrop.style.display === "flex") {
|
||||||
switchTab("search-tab", searchTabBtn);
|
closeCommandPalette();
|
||||||
}
|
} else {
|
||||||
const searchInput = document.getElementById("search-box");
|
openCommandPalette();
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus();
|
|
||||||
searchInput.select();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
if (backdrop && backdrop.style.display === "flex") {
|
||||||
|
closeCommandPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") {
|
if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
if (backdrop && backdrop.style.display === "flex")
|
||||||
|
return;
|
||||||
const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
|
const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
|
||||||
if (activeSearch) {
|
if (activeSearch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1821,6 +2300,9 @@ function refreshActiveTab() {
|
|||||||
case "ledger-tab":
|
case "ledger-tab":
|
||||||
loadLedger();
|
loadLedger();
|
||||||
break;
|
break;
|
||||||
|
case "env-tab":
|
||||||
|
loadEnvironment();
|
||||||
|
break;
|
||||||
case "activity-tab":
|
case "activity-tab":
|
||||||
loadActivityHistory();
|
loadActivityHistory();
|
||||||
break;
|
break;
|
||||||
@@ -1851,6 +2333,7 @@ Object.assign(window, {
|
|||||||
requestDomainRefresh,
|
requestDomainRefresh,
|
||||||
handleIncomingActivity,
|
handleIncomingActivity,
|
||||||
setupWS,
|
setupWS,
|
||||||
|
sendWsRpc,
|
||||||
loadGenericList,
|
loadGenericList,
|
||||||
onListSearchInput,
|
onListSearchInput,
|
||||||
onListClearSearch,
|
onListClearSearch,
|
||||||
@@ -1866,6 +2349,17 @@ Object.assign(window, {
|
|||||||
loadMemos,
|
loadMemos,
|
||||||
loadSnippets,
|
loadSnippets,
|
||||||
loadAllExtras,
|
loadAllExtras,
|
||||||
|
loadEnvironment,
|
||||||
|
resolveTechDebt,
|
||||||
|
authorizeGate,
|
||||||
|
blockGate,
|
||||||
|
deleteGate,
|
||||||
|
openCommandPalette,
|
||||||
|
closeCommandPalette,
|
||||||
|
onPaletteBackdropClick,
|
||||||
|
onPaletteSearch,
|
||||||
|
onPaletteKeyDown,
|
||||||
|
selectPaletteItem,
|
||||||
testClipboard,
|
testClipboard,
|
||||||
loadVersion,
|
loadVersion,
|
||||||
setupSSE,
|
setupSSE,
|
||||||
|
|||||||
+579
-15
@@ -150,6 +150,9 @@ function switchTab(tabId: string, btn?: HTMLElement | null): void {
|
|||||||
case "ledger-tab":
|
case "ledger-tab":
|
||||||
loadLedger();
|
loadLedger();
|
||||||
break;
|
break;
|
||||||
|
case "env-tab":
|
||||||
|
loadEnvironment();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,10 +467,20 @@ async function loadGraph(): Promise<void> {
|
|||||||
// --- Task Tree (HTN/DAG) ---
|
// --- Task Tree (HTN/DAG) ---
|
||||||
async function completeTask(id: string): Promise<void> {
|
async function completeTask(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("tasks/complete", { id });
|
||||||
|
} else {
|
||||||
|
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||||
|
}
|
||||||
loadTasks();
|
loadTasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to complete task", e);
|
console.error("Failed to complete task via WS, falling back to HTTP", e);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||||
|
loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to complete task via HTTP fallback", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,6 +940,36 @@ async function loadActivityHistory(): Promise<void> {
|
|||||||
|
|
||||||
let wsConnected = false;
|
let wsConnected = false;
|
||||||
let activeSSE: EventSource | null = null;
|
let activeSSE: EventSource | null = null;
|
||||||
|
let activeWS: WebSocket | null = null;
|
||||||
|
let wsRpcId = 1;
|
||||||
|
const pendingWsRequests = new Map<number | string, { resolve: (val: any) => void; reject: (err: any) => void; timer: any }>();
|
||||||
|
|
||||||
|
function sendWsRpc(method: string, params: any = {}, timeoutMs = 5000): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!activeWS || activeWS.readyState !== WebSocket.OPEN) {
|
||||||
|
return reject(new Error("WebSocket not open"));
|
||||||
|
}
|
||||||
|
const id = ++wsRpcId;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingWsRequests.delete(id);
|
||||||
|
reject(new Error(`WebSocket RPC timeout for ${method}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pendingWsRequests.set(id, { resolve, reject, timer });
|
||||||
|
try {
|
||||||
|
activeWS.send(JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingWsRequests.delete(id);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
const tabRefreshDebounceTimers: Record<string, any> = {};
|
const tabRefreshDebounceTimers: Record<string, any> = {};
|
||||||
|
|
||||||
function requestDomainRefresh(domain: string): void {
|
function requestDomainRefresh(domain: string): void {
|
||||||
@@ -939,6 +982,8 @@ function requestDomainRefresh(domain: string): void {
|
|||||||
terminal: "terminal-tab",
|
terminal: "terminal-tab",
|
||||||
ledger: "ledger-tab",
|
ledger: "ledger-tab",
|
||||||
memos: "memos-tab",
|
memos: "memos-tab",
|
||||||
|
env: "env-tab",
|
||||||
|
gates: "env-tab",
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetTab = tabMap[domain];
|
const targetTab = tabMap[domain];
|
||||||
@@ -977,6 +1022,10 @@ function requestDomainRefresh(domain: string): void {
|
|||||||
case "memos":
|
case "memos":
|
||||||
loadMemos();
|
loadMemos();
|
||||||
break;
|
break;
|
||||||
|
case "env":
|
||||||
|
case "gates":
|
||||||
|
loadEnvironment();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -1007,6 +1056,14 @@ function handleIncomingActivity(payload: any): void {
|
|||||||
category.startsWith("TASK")
|
category.startsWith("TASK")
|
||||||
) {
|
) {
|
||||||
requestDomainRefresh("task");
|
requestDomainRefresh("task");
|
||||||
|
} else if (
|
||||||
|
category === "GATE" ||
|
||||||
|
category === "QUALITY_GATE" ||
|
||||||
|
category === "ENVIRONMENT" ||
|
||||||
|
category === "ENV"
|
||||||
|
) {
|
||||||
|
requestDomainRefresh("gates");
|
||||||
|
requestDomainRefresh("env");
|
||||||
} else if (
|
} else if (
|
||||||
category === "TECH_DEBT" ||
|
category === "TECH_DEBT" ||
|
||||||
category === "ERROR_FIX" ||
|
category === "ERROR_FIX" ||
|
||||||
@@ -1056,6 +1113,7 @@ function setupWS(): void {
|
|||||||
|
|
||||||
ws.onopen = function () {
|
ws.onopen = function () {
|
||||||
wsConnected = true;
|
wsConnected = true;
|
||||||
|
activeWS = ws;
|
||||||
if (activeSSE) {
|
if (activeSSE) {
|
||||||
activeSSE.close();
|
activeSSE.close();
|
||||||
activeSSE = null;
|
activeSSE = null;
|
||||||
@@ -1065,8 +1123,23 @@ function setupWS(): void {
|
|||||||
ws.onmessage = function (event: MessageEvent) {
|
ws.onmessage = function (event: MessageEvent) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.id !== undefined && pendingWsRequests.has(data.id)) {
|
||||||
|
const handler = pendingWsRequests.get(data.id)!;
|
||||||
|
pendingWsRequests.delete(data.id);
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
if (data.error) {
|
||||||
|
handler.reject(new Error(data.error.message || JSON.stringify(data.error)));
|
||||||
|
} else {
|
||||||
|
handler.resolve(data.result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
data.type === "activity" ||
|
data.type === "activity" ||
|
||||||
|
data.type === "gate" ||
|
||||||
|
data.type === "task" ||
|
||||||
|
data.type === "tech_debt" ||
|
||||||
|
data.type === "nvim_telemetry" ||
|
||||||
data.method === "notifications/activity" ||
|
data.method === "notifications/activity" ||
|
||||||
data.method === "notifications/task/completed" ||
|
data.method === "notifications/task/completed" ||
|
||||||
data.method === "notifications/resources/updated"
|
data.method === "notifications/resources/updated"
|
||||||
@@ -1081,6 +1154,12 @@ function setupWS(): void {
|
|||||||
|
|
||||||
ws.onclose = function () {
|
ws.onclose = function () {
|
||||||
wsConnected = false;
|
wsConnected = false;
|
||||||
|
activeWS = null;
|
||||||
|
pendingWsRequests.forEach((handler) => {
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
handler.reject(new Error("WebSocket disconnected"));
|
||||||
|
});
|
||||||
|
pendingWsRequests.clear();
|
||||||
console.log(
|
console.log(
|
||||||
"WebSocket closed, falling back to SSE and retrying WS in 5s...",
|
"WebSocket closed, falling back to SSE and retrying WS in 5s...",
|
||||||
);
|
);
|
||||||
@@ -1090,6 +1169,12 @@ function setupWS(): void {
|
|||||||
|
|
||||||
ws.onerror = function () {
|
ws.onerror = function () {
|
||||||
wsConnected = false;
|
wsConnected = false;
|
||||||
|
activeWS = null;
|
||||||
|
pendingWsRequests.forEach((handler) => {
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
handler.reject(new Error("WebSocket error"));
|
||||||
|
});
|
||||||
|
pendingWsRequests.clear();
|
||||||
setupSSE();
|
setupSSE();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1777,7 +1862,10 @@ function loadTechDebt(): void {
|
|||||||
<span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span>
|
<span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span>
|
||||||
${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""}
|
${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div>${statusBadge}</div>
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<div>${statusBadge}</div>
|
||||||
|
${!item.is_resolved ? `<button class="complete-btn" style="position:static;" onclick="resolveTechDebt('${escapeHtml(item.id)}')" title="Resolve Tech Debt">✓</button>` : ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">📄 ${escapeHtml(fileLoc)}</div>` : ""}
|
${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">📄 ${escapeHtml(fileLoc)}</div>` : ""}
|
||||||
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div>
|
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div>
|
||||||
@@ -2270,6 +2358,149 @@ function loadSnippets(): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function loadEnvironment(): void {
|
||||||
|
// 1. Target Environments & Infrastructure
|
||||||
|
loadGenericList(
|
||||||
|
"/api/environments",
|
||||||
|
"environments-container",
|
||||||
|
(item) => {
|
||||||
|
const isVpn = item.requires_vpn;
|
||||||
|
const envType = (item.env_type || "dev").toLowerCase();
|
||||||
|
let typeColor = "#3498db";
|
||||||
|
if (envType === "prod") typeColor = "#e74c3c";
|
||||||
|
else if (envType === "staging") typeColor = "#f39c12";
|
||||||
|
else if (envType === "qa") typeColor = "#9b59b6";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.name || "Environment")}</strong>
|
||||||
|
<span style="background:${typeColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(envType)}</span>
|
||||||
|
${isVpn ? '<span style="background:#e67e22; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">🔒 VPN Required</span>' : ""}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
|
||||||
|
</div>
|
||||||
|
${item.url ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em;"><a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color); text-decoration:none;">🌐 ${escapeHtml(item.url)}</a></div>` : ""}
|
||||||
|
${item.description ? `<div style="margin-top:4px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
${item.ssh_host ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">SSH: ${escapeHtml(item.ssh_host)}</span>` : ""}
|
||||||
|
${item.healthcheck_endpoint ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Health: ${escapeHtml(item.healthcheck_endpoint)}</span>` : ""}
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemNoun: "Environments",
|
||||||
|
cardBorderColor: "#3498db",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item: any) => item.updated_at,
|
||||||
|
searchFields: (item: any) => [item.name, item.url, item.description, item.env_type, item.ssh_host, item.namespace],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Tool Fingerprints
|
||||||
|
loadGenericList(
|
||||||
|
"/api/env_fingerprints",
|
||||||
|
"fingerprints-container",
|
||||||
|
(item) => {
|
||||||
|
const toolVersions = item.tool_versions || {};
|
||||||
|
const tools = Object.entries(toolVersions).map(
|
||||||
|
([k, v]) => `<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; font-size:0.8em; font-family:monospace; border:1px solid var(--border-color);"><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</span>`
|
||||||
|
).join(" ");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:1.05em; color:var(--text-primary);">💻 ${escapeHtml(item.os || "Unknown OS")} (${escapeHtml(item.shell || "shell")})</strong>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
|
||||||
|
</div>
|
||||||
|
${tools ? `<div style="margin-top:10px; display:flex; gap:6px; flex-wrap:wrap; align-items:center;">${tools}</div>` : '<div style="margin-top:6px; font-size:0.85em; color:var(--text-secondary);">No tool versions registered</div>'}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; color:var(--text-secondary);">
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); margin-right:8px;">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemNoun: "Fingerprints",
|
||||||
|
cardBorderColor: "#1abc9c",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item: any) => item.updated_at,
|
||||||
|
searchFields: (item: any) => [item.namespace, item.os, item.shell, item.repo_name, JSON.stringify(item.tool_versions || {})],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Environment Requirements
|
||||||
|
loadGenericList(
|
||||||
|
"/api/env_requirements",
|
||||||
|
"requirements-container",
|
||||||
|
(item) => {
|
||||||
|
const isSecret = item.is_secret;
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<span style="font-family:monospace; font-size:1.05em; font-weight:bold; color:var(--text-primary);">${escapeHtml(item.key)}</span>
|
||||||
|
${isSecret ? '<span style="background:#e74c3c; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">🔒 Secret</span>' : '<span style="background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); font-size:0.75em; padding:2px 6px; border-radius:4px; margin-left:6px;">Public</span>'}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
|
||||||
|
</div>
|
||||||
|
${item.description ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
${item.default_value ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Default: ${escapeHtml(item.default_value)}</span>` : ""}
|
||||||
|
${item.validation_regex ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Regex: ${escapeHtml(item.validation_regex)}</span>` : ""}
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemNoun: "Requirements",
|
||||||
|
cardBorderColor: "#e67e22",
|
||||||
|
defaultSort: "desc",
|
||||||
|
searchFields: (item: any) => [item.key, item.description, item.default_value, item.namespace, item.repo_name],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Quality & Authorization Gates
|
||||||
|
loadGenericList(
|
||||||
|
"/api/gates",
|
||||||
|
"gates-container",
|
||||||
|
(item) => {
|
||||||
|
const status = (item.status || "pending").toLowerCase();
|
||||||
|
let statusColor = "var(--accent-color)";
|
||||||
|
if (status === "authorized") statusColor = "var(--success-color)";
|
||||||
|
else if (status === "blocked") statusColor = "var(--error-color)";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.action)} → ${escapeHtml(item.target)}</strong>
|
||||||
|
<span style="background:${statusColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(status)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "global")}</span>
|
||||||
|
${status !== "authorized" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(46,204,113,0.15); color:var(--success-color); border:1px solid var(--success-color); cursor:pointer;" onclick="authorizeGate('${escapeHtml(item.id)}')" title="Authorize Gate">✓ Authorize</button>` : ""}
|
||||||
|
${status !== "blocked" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(231,76,60,0.15); color:var(--error-color); border:1px solid var(--error-color); cursor:pointer;" onclick="blockGate('${escapeHtml(item.id)}')" title="Block Gate">🚫 Block</button>` : ""}
|
||||||
|
<button class="tab-button" style="padding:2px 6px; font-size:0.75em; background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); cursor:pointer;" onclick="deleteGate('${escapeHtml(item.id)}')" title="Delete Gate">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${item.reason ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.reason)}</div>` : ""}
|
||||||
|
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
${item.gate_type ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Type: ${escapeHtml(item.gate_type)}</span>` : ""}
|
||||||
|
${item.enforcer ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Enforcer: ${escapeHtml(item.enforcer)}</span>` : ""}
|
||||||
|
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemNoun: "Gates",
|
||||||
|
cardBorderColor: "#e74c3c",
|
||||||
|
defaultSort: "desc",
|
||||||
|
timestampExtractor: (item: any) => item.timestamp,
|
||||||
|
searchFields: (item: any) => [item.action, item.target, item.status, item.reason, item.enforcer],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loadAllExtras(): void {
|
function loadAllExtras(): void {
|
||||||
loadTerminal();
|
loadTerminal();
|
||||||
loadTechDebt();
|
loadTechDebt();
|
||||||
@@ -2343,31 +2574,349 @@ function setupSSE(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Interactive Dashboard Controls ---
|
||||||
|
async function resolveTechDebt(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("tech_debts/resolve", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" });
|
||||||
|
if (!res.ok) console.error("Failed to resolve tech debt", await res.text());
|
||||||
|
}
|
||||||
|
loadTechDebt();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error resolving tech debt via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" });
|
||||||
|
if (!res.ok) console.error("Failed to resolve tech debt", await res.text());
|
||||||
|
loadTechDebt();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error resolving tech debt", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeGate(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("gates/authorize", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" });
|
||||||
|
if (!res.ok) console.error("Failed to authorize gate", await res.text());
|
||||||
|
}
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error authorizing gate via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" });
|
||||||
|
if (!res.ok) console.error("Failed to authorize gate", await res.text());
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error authorizing gate", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blockGate(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("gates/block", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" });
|
||||||
|
if (!res.ok) console.error("Failed to block gate", await res.text());
|
||||||
|
}
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error blocking gate via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" });
|
||||||
|
if (!res.ok) console.error("Failed to block gate", await res.text());
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error blocking gate", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGate(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||||
|
await sendWsRpc("gates/delete", { id });
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) console.error("Failed to delete gate", await res.text());
|
||||||
|
}
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting gate via WS, trying HTTP fallback", err);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) console.error("Failed to delete gate", await res.text());
|
||||||
|
loadEnvironment();
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Error deleting gate", fallbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Command Palette (Ctrl+K) ---
|
||||||
|
interface PaletteItem {
|
||||||
|
id: string;
|
||||||
|
category: "task" | "techdebt" | "adr" | "env" | "gate" | "nav";
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
sub: string;
|
||||||
|
badge: string;
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paletteItems: PaletteItem[] = [];
|
||||||
|
let filteredPaletteItems: PaletteItem[] = [];
|
||||||
|
let paletteSelectedIndex = 0;
|
||||||
|
let paletteDataLoaded = false;
|
||||||
|
|
||||||
|
async function preloadPaletteData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [tasksRes, debtsRes, adrsRes, envsRes, gatesRes] = await Promise.all([
|
||||||
|
fetch("/api/tasks", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/tech_debts", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/adrs", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/environments", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
fetch("/api/gates", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items: PaletteItem[] = [
|
||||||
|
{ id: "nav-activity", category: "nav", icon: "⚡", title: "Live Activity Feed", sub: "Inspect real-time agent signals & SSE", badge: "VIEW", tabId: "activity-tab" },
|
||||||
|
{ id: "nav-ledger", category: "nav", icon: "📖", title: "Code Ledger", sub: "View logged code changes & commits", badge: "VIEW", tabId: "ledger-tab" },
|
||||||
|
{ id: "nav-graph", category: "nav", icon: "🕸️", title: "Knowledge Graph Explorer", sub: "Explore nodes, relationships & dependencies", badge: "VIEW", tabId: "graph-tab" },
|
||||||
|
{ id: "nav-search", category: "nav", icon: "🔍", title: "Omni-Search", sub: "Full-text cross-entity search", badge: "VIEW", tabId: "search-tab" },
|
||||||
|
{ id: "nav-tasks", category: "nav", icon: "📋", title: "Task Board", sub: "HTN / DAG task hierarchies", badge: "VIEW", tabId: "task-tab" },
|
||||||
|
{ id: "nav-techdebt", category: "nav", icon: "⚡", title: "Tech Debt & Error Fixes", sub: "Known tech debt and error fixes", badge: "VIEW", tabId: "techdebt-tab" },
|
||||||
|
{ id: "nav-adrs", category: "nav", icon: "📜", title: "Architectural Decision Records", sub: "ADRs and design choices", badge: "VIEW", tabId: "adrs-tab" },
|
||||||
|
{ id: "nav-memos", category: "nav", icon: "📝", title: "Handoff Memos", sub: "Agent handoffs & session summaries", badge: "VIEW", tabId: "memos-tab" },
|
||||||
|
{ id: "nav-snippets", category: "nav", icon: "📎", title: "Code Snippets", sub: "Indexed snippets & patterns", badge: "VIEW", tabId: "snippets-tab" },
|
||||||
|
{ id: "nav-terminal", category: "nav", icon: "💻", title: "Terminal History", sub: "Command execution log & exit codes", badge: "VIEW", tabId: "terminal-tab" },
|
||||||
|
{ id: "nav-env", category: "nav", icon: "🌐", title: "Environment & Quality Gates", sub: "Deploy targets, fingerprints, requirements & gates", badge: "VIEW", tabId: "env-tab" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (Array.isArray(tasksRes)) {
|
||||||
|
tasksRes.forEach((t: any) => {
|
||||||
|
items.push({
|
||||||
|
id: t.id,
|
||||||
|
category: "task",
|
||||||
|
icon: t.status === "completed" ? "✓" : "📋",
|
||||||
|
title: t.title || t.id,
|
||||||
|
sub: t.description || `Task (${t.status || "pending"})`,
|
||||||
|
badge: t.status || "TASK",
|
||||||
|
tabId: "task-tab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(debtsRes)) {
|
||||||
|
debtsRes.forEach((d: any) => {
|
||||||
|
items.push({
|
||||||
|
id: d.id,
|
||||||
|
category: "techdebt",
|
||||||
|
icon: "⚡",
|
||||||
|
title: `${d.id}: ${d.description || ""}`.trim(),
|
||||||
|
sub: d.file_path ? `${d.file_path} - Severity: ${d.severity || "medium"}` : `Severity: ${d.severity || "medium"}`,
|
||||||
|
badge: d.is_resolved ? "RESOLVED" : (d.severity || "DEBT"),
|
||||||
|
tabId: "techdebt-tab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(adrsRes)) {
|
||||||
|
adrsRes.forEach((a: any) => {
|
||||||
|
items.push({
|
||||||
|
id: a.id,
|
||||||
|
category: "adr",
|
||||||
|
icon: "📜",
|
||||||
|
title: `${a.id}: ${a.title || ""}`.trim(),
|
||||||
|
sub: a.decision || `Status: ${a.status || "accepted"}`,
|
||||||
|
badge: a.status || "ADR",
|
||||||
|
tabId: "adrs-tab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(envsRes)) {
|
||||||
|
envsRes.forEach((e: any) => {
|
||||||
|
items.push({
|
||||||
|
id: e.name || e.url,
|
||||||
|
category: "env",
|
||||||
|
icon: "🌐",
|
||||||
|
title: e.name || "Target Environment",
|
||||||
|
sub: `${e.url || ""} ${e.description ? "• " + e.description : ""}`.trim(),
|
||||||
|
badge: e.env_type || "ENV",
|
||||||
|
tabId: "env-tab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(gatesRes)) {
|
||||||
|
gatesRes.forEach((g: any) => {
|
||||||
|
items.push({
|
||||||
|
id: g.id,
|
||||||
|
category: "gate",
|
||||||
|
icon: g.status === "authorized" ? "🟢" : (g.status === "blocked" ? "🔴" : "🟡"),
|
||||||
|
title: `${g.action} → ${g.target}`,
|
||||||
|
sub: g.reason || `Enforcer: ${g.enforcer || "gate"}`,
|
||||||
|
badge: g.status || "GATE",
|
||||||
|
tabId: "env-tab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
paletteItems = items;
|
||||||
|
paletteDataLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to preload palette data", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCommandPalette(): void {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
const input = document.getElementById("palette-input") as HTMLInputElement | null;
|
||||||
|
if (!backdrop || !input) return;
|
||||||
|
|
||||||
|
backdrop.style.display = "flex";
|
||||||
|
input.value = "";
|
||||||
|
paletteSelectedIndex = 0;
|
||||||
|
preloadPaletteData().then(() => {
|
||||||
|
onPaletteSearch("");
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCommandPalette(): void {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
if (backdrop) backdrop.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaletteBackdropClick(e: MouseEvent): void {
|
||||||
|
if (e.target === document.getElementById("cmd-palette-backdrop")) {
|
||||||
|
closeCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaletteSearch(query: string): void {
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
filteredPaletteItems = paletteItems.slice(0, 30);
|
||||||
|
} else {
|
||||||
|
filteredPaletteItems = paletteItems
|
||||||
|
.filter((item) =>
|
||||||
|
item.title.toLowerCase().includes(q) ||
|
||||||
|
item.sub.toLowerCase().includes(q) ||
|
||||||
|
item.badge.toLowerCase().includes(q) ||
|
||||||
|
item.category.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
paletteSelectedIndex = 0;
|
||||||
|
renderPaletteResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaletteResults(): void {
|
||||||
|
const container = document.getElementById("palette-results");
|
||||||
|
const countEl = document.getElementById("palette-count");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (countEl) countEl.innerText = `${filteredPaletteItems.length} results`;
|
||||||
|
|
||||||
|
if (filteredPaletteItems.length === 0) {
|
||||||
|
container.innerHTML = '<li class="enterprise-empty-state" style="margin: 10px 18px;">No matching items found.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filteredPaletteItems
|
||||||
|
.map((item, idx) => {
|
||||||
|
const isSel = idx === paletteSelectedIndex;
|
||||||
|
return `
|
||||||
|
<li class="palette-item ${isSel ? "selected" : ""}" data-index="${idx}" onclick="selectPaletteItem(${idx})">
|
||||||
|
<div class="palette-item-left">
|
||||||
|
<span class="palette-item-icon">${escapeHtml(item.icon)}</span>
|
||||||
|
<div class="palette-item-text">
|
||||||
|
<span class="palette-item-title">${escapeHtml(item.title)}</span>
|
||||||
|
<span class="palette-item-sub">${escapeHtml(item.sub)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="palette-item-badge">${escapeHtml(item.badge)}</span>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const selectedEl = container.querySelector(`.palette-item[data-index="${paletteSelectedIndex}"]`) as HTMLElement | null;
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPaletteItem(idx: number): void {
|
||||||
|
const item = filteredPaletteItems[idx];
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
closeCommandPalette();
|
||||||
|
const tabBtn = document.querySelector(`button[onclick*="${item.tabId}"]`) as HTMLElement | null;
|
||||||
|
if (tabBtn) {
|
||||||
|
switchTab(item.tabId, tabBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaletteKeyDown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredPaletteItems.length > 0) {
|
||||||
|
paletteSelectedIndex = (paletteSelectedIndex + 1) % filteredPaletteItems.length;
|
||||||
|
renderPaletteResults();
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredPaletteItems.length > 0) {
|
||||||
|
paletteSelectedIndex = (paletteSelectedIndex - 1 + filteredPaletteItems.length) % filteredPaletteItems.length;
|
||||||
|
renderPaletteResults();
|
||||||
|
}
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectPaletteItem(paletteSelectedIndex);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", function (e: KeyboardEvent) {
|
document.addEventListener("keydown", function (e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const searchTabBtn = document.querySelector(
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
'button[onclick*="search-tab"]',
|
if (backdrop && backdrop.style.display === "flex") {
|
||||||
) as HTMLElement | null;
|
closeCommandPalette();
|
||||||
if (searchTabBtn) {
|
} else {
|
||||||
switchTab("search-tab", searchTabBtn);
|
openCommandPalette();
|
||||||
}
|
|
||||||
const searchInput = document.getElementById(
|
|
||||||
"search-box",
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus();
|
|
||||||
searchInput.select();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
if (backdrop && backdrop.style.display === "flex") {
|
||||||
|
closeCommandPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pressing '/' focuses the current active list search input if not already typing
|
// Pressing '/' focuses the current active list search input if not already typing
|
||||||
if (
|
if (
|
||||||
e.key === "/" &&
|
e.key === "/" &&
|
||||||
document.activeElement?.tagName !== "INPUT" &&
|
document.activeElement?.tagName !== "INPUT" &&
|
||||||
document.activeElement?.tagName !== "TEXTAREA"
|
document.activeElement?.tagName !== "TEXTAREA"
|
||||||
) {
|
) {
|
||||||
|
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||||
|
if (backdrop && backdrop.style.display === "flex") return;
|
||||||
const activeSearch = document.querySelector(
|
const activeSearch = document.querySelector(
|
||||||
".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input",
|
".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input",
|
||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
@@ -2405,6 +2954,9 @@ function refreshActiveTab(): void {
|
|||||||
case "ledger-tab":
|
case "ledger-tab":
|
||||||
loadLedger();
|
loadLedger();
|
||||||
break;
|
break;
|
||||||
|
case "env-tab":
|
||||||
|
loadEnvironment();
|
||||||
|
break;
|
||||||
case "activity-tab":
|
case "activity-tab":
|
||||||
loadActivityHistory();
|
loadActivityHistory();
|
||||||
break;
|
break;
|
||||||
@@ -2437,6 +2989,7 @@ Object.assign(window, {
|
|||||||
requestDomainRefresh,
|
requestDomainRefresh,
|
||||||
handleIncomingActivity,
|
handleIncomingActivity,
|
||||||
setupWS,
|
setupWS,
|
||||||
|
sendWsRpc,
|
||||||
loadGenericList,
|
loadGenericList,
|
||||||
onListSearchInput,
|
onListSearchInput,
|
||||||
onListClearSearch,
|
onListClearSearch,
|
||||||
@@ -2452,6 +3005,17 @@ Object.assign(window, {
|
|||||||
loadMemos,
|
loadMemos,
|
||||||
loadSnippets,
|
loadSnippets,
|
||||||
loadAllExtras,
|
loadAllExtras,
|
||||||
|
loadEnvironment,
|
||||||
|
resolveTechDebt,
|
||||||
|
authorizeGate,
|
||||||
|
blockGate,
|
||||||
|
deleteGate,
|
||||||
|
openCommandPalette,
|
||||||
|
closeCommandPalette,
|
||||||
|
onPaletteBackdropClick,
|
||||||
|
onPaletteSearch,
|
||||||
|
onPaletteKeyDown,
|
||||||
|
selectPaletteItem,
|
||||||
testClipboard,
|
testClipboard,
|
||||||
loadVersion,
|
loadVersion,
|
||||||
setupSSE,
|
setupSSE,
|
||||||
|
|||||||
@@ -26,13 +26,21 @@ impl McpTool for EnvironmentHandler {
|
|||||||
match req.action {
|
match req.action {
|
||||||
EnvAction::UpdateFingerprint => {
|
EnvAction::UpdateFingerprint => {
|
||||||
let tool_versions = req.tool_versions.unwrap_or_default();
|
let tool_versions = req.tool_versions.unwrap_or_default();
|
||||||
|
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||||
|
let detected_shell = req.shell
|
||||||
|
.or_else(|| std::env::var("SHELL").ok())
|
||||||
|
.or_else(|| std::env::var("STARSHIP_SHELL").ok())
|
||||||
|
.or_else(|| if std::env::var("PSModulePath").is_ok() { Some("pwsh".to_string()) } else { None })
|
||||||
|
.or_else(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None })
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
state.env.env_fingerprints.modify(|fps| {
|
state.env.env_fingerprints.modify(|fps| {
|
||||||
fps.insert(
|
fps.insert(
|
||||||
ns.clone(),
|
ns.clone(),
|
||||||
crate::models::EnvFingerprint {
|
crate::models::EnvFingerprint {
|
||||||
namespace: ns.clone(),
|
namespace: ns.clone(),
|
||||||
os: std::env::consts::OS.to_string(),
|
os: detected_os,
|
||||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
|
shell: detected_shell,
|
||||||
tool_versions,
|
tool_versions,
|
||||||
updated_at: crate::handlers::utils::now_secs(),
|
updated_at: crate::handlers::utils::now_secs(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+801
-18
@@ -8,9 +8,229 @@ use serde_json::{Value, json};
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
||||||
static CLIPBOARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
static CLIPBOARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
pub fn to_wsl_path(win_path: &str) -> String {
|
||||||
|
let p = win_path.trim();
|
||||||
|
if let Some(rest) = p.strip_prefix("C:\\").or_else(|| p.strip_prefix("c:\\")) {
|
||||||
|
format!("/mnt/c/{}", rest.replace('\\', "/"))
|
||||||
|
} else if let Some(rest) = p.strip_prefix("D:\\").or_else(|| p.strip_prefix("d:\\")) {
|
||||||
|
format!("/mnt/d/{}", rest.replace('\\', "/"))
|
||||||
|
} else {
|
||||||
|
p.replace('\\', "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_html_fragment(raw: &str) -> &str {
|
||||||
|
if let Some(start_idx) = raw.find("<!--StartFragment-->") {
|
||||||
|
let after_start = start_idx + "<!--StartFragment-->".len();
|
||||||
|
if let Some(end_idx) = raw[after_start..].find("<!--EndFragment-->") {
|
||||||
|
return &raw[after_start..after_start + end_idx];
|
||||||
|
}
|
||||||
|
return &raw[after_start..];
|
||||||
|
}
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html_to_markdown(html: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut in_table = false;
|
||||||
|
let mut table_rows: Vec<Vec<String>> = Vec::new();
|
||||||
|
let mut current_row: Vec<String> = Vec::new();
|
||||||
|
let mut current_cell = String::new();
|
||||||
|
let mut in_cell = false;
|
||||||
|
|
||||||
|
let mut chars = html.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '<' {
|
||||||
|
let mut tag = String::new();
|
||||||
|
while let Some(&next_c) = chars.peek() {
|
||||||
|
chars.next();
|
||||||
|
if next_c == '>' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tag.push(next_c);
|
||||||
|
}
|
||||||
|
let tag_lower = tag.trim().to_lowercase();
|
||||||
|
let tag_name = tag_lower.split_whitespace().next().unwrap_or("");
|
||||||
|
|
||||||
|
match tag_name {
|
||||||
|
"table" => {
|
||||||
|
in_table = true;
|
||||||
|
table_rows.clear();
|
||||||
|
}
|
||||||
|
"/table" => {
|
||||||
|
if in_table {
|
||||||
|
in_table = false;
|
||||||
|
if !table_rows.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
|
||||||
|
for (row_idx, row) in table_rows.iter().enumerate() {
|
||||||
|
out.push('|');
|
||||||
|
for col_idx in 0..num_cols {
|
||||||
|
let cell_val = row.get(col_idx).map(|s| s.trim()).unwrap_or("");
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(cell_val);
|
||||||
|
out.push_str(" |");
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
if row_idx == 0 {
|
||||||
|
out.push('|');
|
||||||
|
for _ in 0..num_cols {
|
||||||
|
out.push_str(" --- |");
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tr" => {
|
||||||
|
if in_table {
|
||||||
|
current_row.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/tr" => {
|
||||||
|
if in_table {
|
||||||
|
if !current_row.is_empty() {
|
||||||
|
table_rows.push(current_row.clone());
|
||||||
|
}
|
||||||
|
current_row.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"th" | "td" => {
|
||||||
|
if in_table {
|
||||||
|
in_cell = true;
|
||||||
|
current_cell.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/th" | "/td" => {
|
||||||
|
if in_table && in_cell {
|
||||||
|
in_cell = false;
|
||||||
|
current_row.push(current_cell.trim().to_string());
|
||||||
|
current_cell.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"br" | "br/" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push(' ');
|
||||||
|
} else {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"p" | "div" => {
|
||||||
|
if !in_cell && !out.ends_with("\n\n") && !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/p" | "/div" => {
|
||||||
|
if !in_cell && !out.ends_with("\n\n") {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
|
||||||
|
if !in_cell {
|
||||||
|
if !out.ends_with('\n') && !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
let level = tag_name[1..].parse::<usize>().unwrap_or(1);
|
||||||
|
out.push_str(&"#".repeat(level));
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/h1" | "/h2" | "/h3" | "/h4" | "/h5" | "/h6" => {
|
||||||
|
if !in_cell {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"b" | "strong" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push_str("**");
|
||||||
|
} else {
|
||||||
|
out.push_str("**");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/b" | "/strong" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push_str("**");
|
||||||
|
} else {
|
||||||
|
out.push_str("**");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"i" | "em" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push('*');
|
||||||
|
} else {
|
||||||
|
out.push('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/i" | "/em" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push('*');
|
||||||
|
} else {
|
||||||
|
out.push('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"code" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push('`');
|
||||||
|
} else {
|
||||||
|
out.push('`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/code" => {
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push('`');
|
||||||
|
} else {
|
||||||
|
out.push('`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"li" => {
|
||||||
|
if !in_cell {
|
||||||
|
out.push_str("* ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/li" => {
|
||||||
|
if !in_cell {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if c == '&' {
|
||||||
|
let mut entity = String::new();
|
||||||
|
while let Some(&next_c) = chars.peek() {
|
||||||
|
chars.next();
|
||||||
|
if next_c == ';' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
entity.push(next_c);
|
||||||
|
}
|
||||||
|
let decoded = match entity.as_str() {
|
||||||
|
"nbsp" => " ",
|
||||||
|
"amp" => "&",
|
||||||
|
"lt" => "<",
|
||||||
|
"gt" => ">",
|
||||||
|
"quot" => "\"",
|
||||||
|
"apos" | "#39" => "'",
|
||||||
|
_ => " ",
|
||||||
|
};
|
||||||
|
if in_cell {
|
||||||
|
current_cell.push_str(decoded);
|
||||||
|
} else if !in_table {
|
||||||
|
out.push_str(decoded);
|
||||||
|
}
|
||||||
|
} else if in_cell {
|
||||||
|
current_cell.push(c);
|
||||||
|
} else if !in_table {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_native_clipboard_text() -> Option<String> {
|
pub fn get_native_clipboard_text() -> Option<String> {
|
||||||
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
@@ -25,6 +245,83 @@ pub fn get_native_clipboard_text() -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn get_windows_html_clipboard() -> Option<String> {
|
||||||
|
use windows_sys::Win32::System::DataExchange::{
|
||||||
|
CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
|
||||||
|
RegisterClipboardFormatW,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock};
|
||||||
|
let format_name: Vec<u16> = "HTML Format\0".encode_utf16().collect();
|
||||||
|
unsafe {
|
||||||
|
let format_id = RegisterClipboardFormatW(format_name.as_ptr());
|
||||||
|
if format_id == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for _ in 0..3 {
|
||||||
|
if OpenClipboard(std::ptr::null_mut()) != 0 {
|
||||||
|
let has_format = IsClipboardFormatAvailable(format_id) != 0;
|
||||||
|
let mut html_opt = None;
|
||||||
|
if has_format {
|
||||||
|
let handle = GetClipboardData(format_id);
|
||||||
|
if !handle.is_null() {
|
||||||
|
let size = GlobalSize(handle as _);
|
||||||
|
if size > 0 {
|
||||||
|
let ptr = GlobalLock(handle as _);
|
||||||
|
if !ptr.is_null() {
|
||||||
|
let slice = std::slice::from_raw_parts(ptr as *const u8, size);
|
||||||
|
if let Ok(s) = std::str::from_utf8(slice) {
|
||||||
|
html_opt = Some(s.to_string());
|
||||||
|
}
|
||||||
|
GlobalUnlock(handle as _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseClipboard();
|
||||||
|
if html_opt.is_some() {
|
||||||
|
return html_opt;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn perform_windows_ocr(img: &image::DynamicImage) -> Option<String> {
|
||||||
|
use windows::Graphics::Imaging::BitmapDecoder;
|
||||||
|
use windows::Media::Ocr::OcrEngine;
|
||||||
|
use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream};
|
||||||
|
|
||||||
|
let mut png_bytes = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut png_bytes, image::ImageFormat::Png).ok()?;
|
||||||
|
let bytes = png_bytes.into_inner();
|
||||||
|
|
||||||
|
let stream = InMemoryRandomAccessStream::new().ok()?;
|
||||||
|
let writer = DataWriter::CreateDataWriter(&stream).ok()?;
|
||||||
|
writer.WriteBytes(&bytes).ok()?;
|
||||||
|
writer.StoreAsync().ok()?.join().ok()?;
|
||||||
|
writer.FlushAsync().ok()?.join().ok()?;
|
||||||
|
stream.Seek(0).ok()?;
|
||||||
|
|
||||||
|
let decoder = BitmapDecoder::CreateAsync(&stream).ok()?.join().ok()?;
|
||||||
|
let software_bitmap = decoder.GetSoftwareBitmapAsync().ok()?.join().ok()?;
|
||||||
|
|
||||||
|
let engine = OcrEngine::TryCreateFromUserProfileLanguages().ok()?;
|
||||||
|
let result = engine.RecognizeAsync(&software_bitmap).ok()?.join().ok()?;
|
||||||
|
let text = result.Text().ok()?;
|
||||||
|
let s = text.to_string();
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
||||||
use windows_sys::Win32::System::DataExchange::{
|
use windows_sys::Win32::System::DataExchange::{
|
||||||
@@ -67,6 +364,7 @@ fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
||||||
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -89,6 +387,242 @@ pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
|
||||||
|
let now_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
// 1. Try to read image first
|
||||||
|
let img_opt = tokio::task::spawn_blocking(get_native_clipboard_image)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if let Some(mut img) = img_opt {
|
||||||
|
let max_dim = 1440;
|
||||||
|
if img.width() > max_dim || img.height() > max_dim {
|
||||||
|
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||||
|
}
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
let rgb_img = img.to_rgb8();
|
||||||
|
|
||||||
|
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88);
|
||||||
|
if encoder
|
||||||
|
.encode(&rgb_img, w, h, image::ExtendedColorType::Rgb8)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let bytes = jpeg_bytes.into_inner();
|
||||||
|
let size_bytes = bytes.len();
|
||||||
|
|
||||||
|
let cache_dir = dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".gemini/mcp_memory/clipboard");
|
||||||
|
let _ = std::fs::create_dir_all(&cache_dir);
|
||||||
|
let file_path = cache_dir.join("clipboard_latest_image.jpg");
|
||||||
|
let _ = std::fs::write(&file_path, &bytes);
|
||||||
|
|
||||||
|
let path_str = file_path.to_string_lossy().to_string();
|
||||||
|
let wsl_path = to_wsl_path(&path_str);
|
||||||
|
|
||||||
|
// Deterministic OCR
|
||||||
|
let img_clone = img.clone();
|
||||||
|
let ocr_text_opt = tokio::task::spawn_blocking(move || {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
perform_windows_ocr(&img_clone)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
// Index OCR text into Tantivy for omni_search
|
||||||
|
if let Some(ref text) = ocr_text_opt {
|
||||||
|
let doc_id = format!("clipboard_{}", now_ms);
|
||||||
|
let _ = state
|
||||||
|
.search_index
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.index_clipboard_image(&doc_id, &path_str, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached = crate::state::CachedClipboardImage {
|
||||||
|
file_path: path_str,
|
||||||
|
file_path_wsl: wsl_path,
|
||||||
|
captured_at_epoch_ms: now_ms,
|
||||||
|
age: "0s ago".to_string(),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
size_bytes,
|
||||||
|
ocr_text: ocr_text_opt,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
cache.last_image = Some(cached.clone());
|
||||||
|
cache
|
||||||
|
.history
|
||||||
|
.push_front(crate::state::ClipboardHistoryItem::Image(cached));
|
||||||
|
if cache.history.len() > 20 {
|
||||||
|
cache.history.pop_back();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try text / HTML
|
||||||
|
let text_opt = tokio::task::spawn_blocking(|| {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(raw_html) = get_windows_html_clipboard() {
|
||||||
|
let frag = extract_html_fragment(&raw_html);
|
||||||
|
let md = html_to_markdown(frag);
|
||||||
|
if !md.trim().is_empty() {
|
||||||
|
return Some(md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get_native_clipboard_text()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if let Some(text) = text_opt {
|
||||||
|
let text_trim = text.trim();
|
||||||
|
if text_trim.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let char_count = text_trim.chars().count();
|
||||||
|
let cached = crate::state::CachedClipboardText {
|
||||||
|
text: text_trim.to_string(),
|
||||||
|
captured_at_epoch_ms: now_ms,
|
||||||
|
age: "0s ago".to_string(),
|
||||||
|
char_count,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
if let Some(ref prev) = cache.last_text {
|
||||||
|
if prev.text == cached.text {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.last_text = Some(cached.clone());
|
||||||
|
cache
|
||||||
|
.history
|
||||||
|
.push_front(crate::state::ClipboardHistoryItem::Text(cached));
|
||||||
|
if cache.history.len() > 20 {
|
||||||
|
cache.history.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
static CLIPBOARD_NOTIFY_TX: std::sync::OnceLock<tokio::sync::mpsc::UnboundedSender<()>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn spawn_clipboard_listener(state: Arc<MemoryState>) {
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
|
||||||
|
let _ = CLIPBOARD_NOTIFY_TX.set(tx);
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("win32-clipboard-listener".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
use windows_sys::Win32::Foundation::*;
|
||||||
|
use windows_sys::Win32::System::DataExchange::*;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::*;
|
||||||
|
|
||||||
|
unsafe extern "system" fn wnd_proc(
|
||||||
|
hwnd: HWND,
|
||||||
|
msg: u32,
|
||||||
|
wparam: WPARAM,
|
||||||
|
lparam: LPARAM,
|
||||||
|
) -> LRESULT {
|
||||||
|
if msg == WM_CLIPBOARDUPDATE {
|
||||||
|
if let Some(tx) = CLIPBOARD_NOTIFY_TX.get() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let class_name: Vec<u16> =
|
||||||
|
"McpMemoryClipboardWatcherClass\0".encode_utf16().collect();
|
||||||
|
let wnd_class = WNDCLASSEXW {
|
||||||
|
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||||
|
style: 0,
|
||||||
|
lpfnWndProc: Some(wnd_proc),
|
||||||
|
cbClsExtra: 0,
|
||||||
|
cbWndExtra: 0,
|
||||||
|
hInstance: 0 as _,
|
||||||
|
hIcon: 0 as _,
|
||||||
|
hCursor: 0 as _,
|
||||||
|
hbrBackground: 0 as _,
|
||||||
|
lpszMenuName: std::ptr::null(),
|
||||||
|
lpszClassName: class_name.as_ptr(),
|
||||||
|
hIconSm: 0 as _,
|
||||||
|
};
|
||||||
|
RegisterClassExW(&wnd_class);
|
||||||
|
|
||||||
|
let hwnd = CreateWindowExW(
|
||||||
|
0,
|
||||||
|
class_name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
HWND_MESSAGE,
|
||||||
|
0 as _,
|
||||||
|
0 as _,
|
||||||
|
std::ptr::null(),
|
||||||
|
);
|
||||||
|
if hwnd == 0 as _ {
|
||||||
|
tracing::error!("Failed to create Win32 clipboard message window");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if AddClipboardFormatListener(hwnd) == 0 {
|
||||||
|
tracing::error!("Failed to AddClipboardFormatListener");
|
||||||
|
DestroyWindow(hwnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("Win32 clipboard listener registered successfully on HWND_MESSAGE.");
|
||||||
|
|
||||||
|
let mut msg: MSG = std::mem::zeroed();
|
||||||
|
while GetMessageW(&mut msg, 0 as _, 0, 0) > 0 {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveClipboardFormatListener(hwnd);
|
||||||
|
DestroyWindow(hwnd);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("Failed to spawn win32-clipboard-listener thread");
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(()) = rx.recv().await {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||||
|
while let Ok(_) = rx.try_recv() {}
|
||||||
|
|
||||||
|
process_clipboard_snapshot(Arc::clone(&state)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub fn spawn_clipboard_listener(_state: Arc<MemoryState>) {
|
||||||
|
tracing::info!("Clipboard listener: running in headless/Linux mode (no Win32 listener).");
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ClipboardHandler;
|
pub struct ClipboardHandler;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -100,13 +634,154 @@ impl McpTool for ClipboardHandler {
|
|||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<ClipboardTool>(
|
crate::mcp::tool_def::<ClipboardTool>(
|
||||||
"clipboard",
|
"clipboard",
|
||||||
"Consolidated OS clipboard management (read, write)",
|
"Smart OS clipboard management: Retrieve screenshots ('image') with overwrite immunity and verbatim OCR, normalized text ('text'), recent history ('history'), clear, read, or write.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
match req.action {
|
match req.action {
|
||||||
|
ClipboardAction::Image => {
|
||||||
|
let cached_opt = {
|
||||||
|
let cache = state.clipboard_cache.read().await;
|
||||||
|
cache.last_image.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = if let Some(mut img) = cached_opt {
|
||||||
|
img.age = crate::state::format_age(img.captured_at_epoch_ms);
|
||||||
|
Some(img)
|
||||||
|
} else {
|
||||||
|
process_clipboard_snapshot(Arc::clone(&state)).await;
|
||||||
|
let cache = state.clipboard_cache.read().await;
|
||||||
|
cache.last_image.clone().map(|mut img| {
|
||||||
|
img.age = crate::state::format_age(img.captured_at_epoch_ms);
|
||||||
|
img
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = if item.is_none() {
|
||||||
|
let cache_dir = dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".gemini/mcp_memory/clipboard");
|
||||||
|
let file_path = cache_dir.join("clipboard_latest_image.jpg");
|
||||||
|
if file_path.exists() {
|
||||||
|
let path_str = file_path.to_string_lossy().to_string();
|
||||||
|
let wsl_path = to_wsl_path(&path_str);
|
||||||
|
let meta = std::fs::metadata(&file_path).ok();
|
||||||
|
let size_bytes = meta.as_ref().map(|m| m.len() as usize).unwrap_or(0);
|
||||||
|
let epoch_ms = meta
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(crate::state::CachedClipboardImage {
|
||||||
|
file_path: path_str,
|
||||||
|
file_path_wsl: wsl_path,
|
||||||
|
captured_at_epoch_ms: epoch_ms,
|
||||||
|
age: crate::state::format_age(epoch_ms),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
size_bytes,
|
||||||
|
ocr_text: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
};
|
||||||
|
|
||||||
|
state.record_activity(
|
||||||
|
"clipboard",
|
||||||
|
"Retrieved screenshot image from clipboard cache",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(img) = item {
|
||||||
|
Ok(serde_json::to_string_pretty(&img)?)
|
||||||
|
} else {
|
||||||
|
Ok(json!({
|
||||||
|
"status": "no_image_in_clipboard",
|
||||||
|
"message": "No screenshot or image found in clipboard cache. Take a screenshot (Win+Shift+S on Windows) and call clipboard(action: \"image\") again."
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipboardAction::Text => {
|
||||||
|
let cached_opt = {
|
||||||
|
let cache = state.clipboard_cache.read().await;
|
||||||
|
cache.last_text.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = if let Some(mut txt) = cached_opt {
|
||||||
|
txt.age = crate::state::format_age(txt.captured_at_epoch_ms);
|
||||||
|
Some(txt)
|
||||||
|
} else {
|
||||||
|
process_clipboard_snapshot(Arc::clone(&state)).await;
|
||||||
|
let cache = state.clipboard_cache.read().await;
|
||||||
|
cache.last_text.clone().map(|mut txt| {
|
||||||
|
txt.age = crate::state::format_age(txt.captured_at_epoch_ms);
|
||||||
|
txt
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
state.record_activity("clipboard", "Retrieved text from clipboard cache", None);
|
||||||
|
if let Some(txt) = item {
|
||||||
|
Ok(serde_json::to_string_pretty(&txt)?)
|
||||||
|
} else {
|
||||||
|
Ok(json!({
|
||||||
|
"text": "",
|
||||||
|
"status": "empty",
|
||||||
|
"message": "Clipboard does not currently contain text."
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipboardAction::History => {
|
||||||
|
let history = {
|
||||||
|
let cache = state.clipboard_cache.read().await;
|
||||||
|
cache
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
crate::state::ClipboardHistoryItem::Image(img) => {
|
||||||
|
let mut img = img.clone();
|
||||||
|
img.age = crate::state::format_age(img.captured_at_epoch_ms);
|
||||||
|
crate::state::ClipboardHistoryItem::Image(img)
|
||||||
|
}
|
||||||
|
crate::state::ClipboardHistoryItem::Text(txt) => {
|
||||||
|
let mut txt = txt.clone();
|
||||||
|
txt.age = crate::state::format_age(txt.captured_at_epoch_ms);
|
||||||
|
crate::state::ClipboardHistoryItem::Text(txt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.record_activity("clipboard", "Viewed clipboard history ring buffer", None);
|
||||||
|
Ok(serde_json::to_string_pretty(&history)?)
|
||||||
|
}
|
||||||
|
ClipboardAction::Clear => {
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Ok(mut c) = Clipboard::new() {
|
||||||
|
let _ = c.clear();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
cache.last_image = None;
|
||||||
|
cache.last_text = None;
|
||||||
|
cache.history.clear();
|
||||||
|
|
||||||
|
state.record_activity("clipboard", "Cleared OS clipboard and memory cache", None);
|
||||||
|
Ok(json!({
|
||||||
|
"status": "cleared",
|
||||||
|
"message": "OS clipboard and memory cache successfully cleared."
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
ClipboardAction::Read => {
|
ClipboardAction::Read => {
|
||||||
let (mut out, b64_opt) =
|
let (mut out, b64_opt) =
|
||||||
tokio::task::spawn_blocking(move || -> crate::error::Result<(serde_json::Map<String, Value>, Option<String>)> {
|
tokio::task::spawn_blocking(move || -> crate::error::Result<(serde_json::Map<String, Value>, Option<String>)> {
|
||||||
@@ -119,14 +794,15 @@ impl McpTool for ClipboardHandler {
|
|||||||
|
|
||||||
if let Some(dynamic_img) = get_native_clipboard_image() {
|
if let Some(dynamic_img) = get_native_clipboard_image() {
|
||||||
let mut img = dynamic_img;
|
let mut img = dynamic_img;
|
||||||
let max_dim = 1024;
|
let max_dim = 1440;
|
||||||
if img.width() > max_dim || img.height() > max_dim {
|
if img.width() > max_dim || img.height() > max_dim {
|
||||||
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||||
}
|
}
|
||||||
let rgb_img = img.into_rgb8();
|
let rgb_img = img.into_rgb8();
|
||||||
|
|
||||||
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
|
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
|
||||||
if rgb_img.write_to(&mut jpeg_bytes, image::ImageFormat::Jpeg).is_ok() {
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88);
|
||||||
|
if encoder.encode(&rgb_img, rgb_img.width(), rgb_img.height(), image::ExtendedColorType::Rgb8).is_ok() {
|
||||||
let bytes = jpeg_bytes.into_inner();
|
let bytes = jpeg_bytes.into_inner();
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
@@ -136,18 +812,12 @@ impl McpTool for ClipboardHandler {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.join(".gemini/mcp_memory/clipboard");
|
.join(".gemini/mcp_memory/clipboard");
|
||||||
let _ = std::fs::create_dir_all(&cache_dir);
|
let _ = std::fs::create_dir_all(&cache_dir);
|
||||||
let file_path = cache_dir.join("clipboard_latest.jpg");
|
let file_path = cache_dir.join("clipboard_latest_image.jpg");
|
||||||
if std::fs::write(&file_path, &bytes).is_ok() {
|
if std::fs::write(&file_path, &bytes).is_ok() {
|
||||||
let path_str = file_path.to_string_lossy().to_string();
|
let path_str = file_path.to_string_lossy().to_string();
|
||||||
out.insert("image_path".into(), json!(path_str));
|
out.insert("image_path".into(), json!(path_str));
|
||||||
#[cfg(target_os = "windows")]
|
let wsl_path = to_wsl_path(&path_str);
|
||||||
if let Some(rest) = path_str.strip_prefix("C:\\") {
|
out.insert("image_path_wsl".into(), json!(wsl_path));
|
||||||
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
|
|
||||||
out.insert("image_path_wsl".into(), json!(wsl_path));
|
|
||||||
} else if let Some(rest) = path_str.strip_prefix("c:\\") {
|
|
||||||
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
|
|
||||||
out.insert("image_path_wsl".into(), json!(wsl_path));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +841,9 @@ impl McpTool for ClipboardHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.record_activity("clipboard", "Read contents from OS clipboard", None);
|
state.record_activity("clipboard", "Read contents from OS clipboard", None);
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object(out))?)
|
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object(
|
||||||
|
out,
|
||||||
|
))?)
|
||||||
}
|
}
|
||||||
ClipboardAction::Write => {
|
ClipboardAction::Write => {
|
||||||
let text_opt = req.text;
|
let text_opt = req.text;
|
||||||
@@ -258,7 +930,6 @@ impl McpTool for ClipboardHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -266,7 +937,6 @@ mod tests {
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_write_clipboard() {
|
async fn test_write_clipboard() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -351,6 +1021,119 @@ mod tests {
|
|||||||
"Regression detected: vision.rs must not invoke xclip!"
|
"Regression detected: vision.rs must not invoke xclip!"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wsl_path_translation() {
|
||||||
|
assert_eq!(
|
||||||
|
to_wsl_path(r"C:\Users\test\image.jpg"),
|
||||||
|
"/mnt/c/Users/test/image.jpg"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
to_wsl_path(r"c:\projects\code\doc.txt"),
|
||||||
|
"/mnt/c/projects/code/doc.txt"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
to_wsl_path(r"D:\Data\archive.tar"),
|
||||||
|
"/mnt/d/Data/archive.tar"
|
||||||
|
);
|
||||||
|
assert_eq!(to_wsl_path("/tmp/scratch.png"), "/tmp/scratch.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html_to_markdown_conversion() {
|
||||||
|
let html_snippet = "Version:0.9\r\nStartHTML:00000001\r\n<!--StartFragment--><table><tr><th>Header 1</th><th>Header 2</th></tr><tr><td>Row 1 Cell 1</td><td>Row 1 Cell 2</td></tr></table><!--EndFragment-->";
|
||||||
|
let fragment = extract_html_fragment(html_snippet);
|
||||||
|
let markdown = html_to_markdown(fragment);
|
||||||
|
assert!(markdown.contains("| Header 1 | Header 2 |"));
|
||||||
|
assert!(markdown.contains("| --- | --- |"));
|
||||||
|
assert!(markdown.contains("| Row 1 Cell 1 | Row 1 Cell 2 |"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interleaved_hotchpotch_clipboard_cache() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = ClipboardHandler;
|
||||||
|
|
||||||
|
// Step 1: Push Image A into cache
|
||||||
|
{
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
cache.push_image(crate::state::CachedClipboardImage {
|
||||||
|
file_path: "C:\\tmp\\image_a.jpg".into(),
|
||||||
|
file_path_wsl: "/mnt/c/tmp/image_a.jpg".into(),
|
||||||
|
captured_at_epoch_ms: 1000,
|
||||||
|
age: "0s ago".into(),
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
size_bytes: 1024,
|
||||||
|
ocr_text: Some("Error: Failed to connect to server".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Push Text 1 into cache
|
||||||
|
{
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
cache.push_text(crate::state::CachedClipboardText {
|
||||||
|
text: "curl http://localhost:8080".into(),
|
||||||
|
captured_at_epoch_ms: 2000,
|
||||||
|
age: "0s ago".into(),
|
||||||
|
char_count: 26,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Push Image B into cache
|
||||||
|
{
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
cache.push_image(crate::state::CachedClipboardImage {
|
||||||
|
file_path: "C:\\tmp\\image_b.jpg".into(),
|
||||||
|
file_path_wsl: "/mnt/c/tmp/image_b.jpg".into(),
|
||||||
|
captured_at_epoch_ms: 3000,
|
||||||
|
age: "0s ago".into(),
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
size_bytes: 2048,
|
||||||
|
ocr_text: Some("git commit -m 'feat: smart clipboard'".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Push Text 2 into cache (User got sidetracked and copied text!)
|
||||||
|
{
|
||||||
|
let mut cache = state.clipboard_cache.write().await;
|
||||||
|
cache.push_text(crate::state::CachedClipboardText {
|
||||||
|
text: "Some random stackoverflow URL".into(),
|
||||||
|
captured_at_epoch_ms: 4000,
|
||||||
|
age: "0s ago".into(),
|
||||||
|
char_count: 29,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Now verify: Action 'image' MUST still retrieve Image B, not be shadowed by Text 2!
|
||||||
|
let img_res_str = handler
|
||||||
|
.execute(json!({ "action": "image" }), Arc::clone(&state))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let img_val: Value = serde_json::from_str(&img_res_str).unwrap();
|
||||||
|
assert_eq!(img_val["file_path"], "C:\\tmp\\image_b.jpg");
|
||||||
|
assert_eq!(img_val["file_path_wsl"], "/mnt/c/tmp/image_b.jpg");
|
||||||
|
assert_eq!(img_val["width"], 1200);
|
||||||
|
assert_eq!(img_val["height"], 800);
|
||||||
|
assert_eq!(img_val["ocr_text"], "git commit -m 'feat: smart clipboard'");
|
||||||
|
assert!(img_val.get("age").is_some());
|
||||||
|
|
||||||
|
// Action 'text' MUST retrieve Text 2
|
||||||
|
let text_res_str = handler
|
||||||
|
.execute(json!({ "action": "text" }), Arc::clone(&state))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text_val: Value = serde_json::from_str(&text_res_str).unwrap();
|
||||||
|
assert_eq!(text_val["text"], "Some random stackoverflow URL");
|
||||||
|
|
||||||
|
// Action 'history' MUST show both items
|
||||||
|
let hist_res_str = handler
|
||||||
|
.execute(json!({ "action": "history" }), Arc::clone(&state))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let hist_val: Value = serde_json::from_str(&hist_res_str).unwrap();
|
||||||
|
let history = hist_val.as_array().unwrap();
|
||||||
|
assert!(history.len() >= 4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +131,13 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
|||||||
snippets.drain(0..overflow);
|
snippets.drain(0..overflow);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(100);
|
||||||
|
if state.code.snippets.read_with(|s| s.len()) > threshold {
|
||||||
|
state.condense_notify.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
for snippet in &snippets_to_index {
|
for snippet in &snippets_to_index {
|
||||||
let _ = idx.index_snippet(snippet).await;
|
let _ = idx.index_snippet(snippet).await;
|
||||||
|
|||||||
+155
-32
@@ -96,7 +96,62 @@ pub struct AppState {
|
|||||||
|
|
||||||
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
state.ttl_notify.notified().await;
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let mut next_expiry: Option<u64> = None;
|
||||||
|
|
||||||
|
state.project.tasks.read_with(|tasks| {
|
||||||
|
for t in tasks.iter() {
|
||||||
|
if let Some(exp) = t.expires_at {
|
||||||
|
if t.is_active() {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.telemetry.handoff_memos.read_with(|memos| {
|
||||||
|
for m in memos.iter() {
|
||||||
|
if let Some(exp) = m.expires_at {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.telemetry.session_summaries.read_with(|summaries| {
|
||||||
|
for s in summaries.iter() {
|
||||||
|
if let Some(exp) = s.expires_at {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.env.gates.read_with(|gates| {
|
||||||
|
for g in gates.iter() {
|
||||||
|
if let Some(exp) = g.expires_at {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let sleep_duration = match next_expiry {
|
||||||
|
Some(exp) if exp > now => {
|
||||||
|
let diff = exp - now;
|
||||||
|
std::time::Duration::from_secs(diff.min(60).max(1))
|
||||||
|
}
|
||||||
|
Some(_) => std::time::Duration::from_millis(50),
|
||||||
|
None => std::time::Duration::from_secs(60),
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.ttl_notify.notified() => {},
|
||||||
|
_ = tokio::time::sleep(sleep_duration) => {},
|
||||||
|
}
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -139,12 +194,18 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
|||||||
state.telemetry.session_summaries.modify(|summaries| {
|
state.telemetry.session_summaries.modify(|summaries| {
|
||||||
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
||||||
});
|
});
|
||||||
|
state.env.gates.modify(|gates| {
|
||||||
|
gates.retain(|g| g.expires_at.is_none_or(|exp| exp > now));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
state.index_commit_notify.notified().await;
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.index_commit_notify.notified() => {},
|
||||||
|
}
|
||||||
let idx = state.search_index.read().await.clone();
|
let idx = state.search_index.read().await.clone();
|
||||||
let _ = idx.commit().await;
|
let _ = idx.commit().await;
|
||||||
}
|
}
|
||||||
@@ -152,7 +213,10 @@ pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
|||||||
|
|
||||||
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
state.condense_notify.notified().await;
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.condense_notify.notified() => {},
|
||||||
|
}
|
||||||
|
|
||||||
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
.unwrap_or_else(|_| "100".to_string())
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
@@ -224,6 +288,7 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
||||||
tokio::spawn(condense_graph_worker(Arc::clone(&state)));
|
tokio::spawn(condense_graph_worker(Arc::clone(&state)));
|
||||||
crate::watcher::spawn_watcher(Arc::clone(&state));
|
crate::watcher::spawn_watcher(Arc::clone(&state));
|
||||||
|
crate::handlers::vision::spawn_clipboard_listener(Arc::clone(&state));
|
||||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
||||||
@@ -270,32 +335,66 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
|
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
|
||||||
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await {
|
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await {
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
let socket_rx = socket.clone();
|
||||||
|
|
||||||
|
let mut subscribers: HashMap<(String, String), std::net::SocketAddr> = HashMap::new();
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
|
let mut event_rx = udp_state.handler.state.event_bus_tx.subscribe();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
tokio::select! {
|
||||||
&& let Ok(payload) =
|
recv_res = socket_rx.recv_from(&mut buf) => {
|
||||||
serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
if let Ok((len, addr)) = recv_res {
|
||||||
{
|
if let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len]) {
|
||||||
udp_state
|
udp_state
|
||||||
.handler
|
.handler
|
||||||
.state
|
.state
|
||||||
.record_terminal_history(payload.clone());
|
.record_terminal_history(payload.clone());
|
||||||
|
|
||||||
let ws_msg = serde_json::json!({
|
let ws_msg = serde_json::json!({
|
||||||
"type": "terminal_telemetry",
|
"type": "terminal_telemetry",
|
||||||
"data": payload
|
"data": payload
|
||||||
});
|
});
|
||||||
let msg_str = ws_msg.to_string();
|
let msg_str = ws_msg.to_string();
|
||||||
|
|
||||||
let senders: Vec<_> = udp_state
|
let senders: Vec<_> = udp_state
|
||||||
.clients
|
.clients
|
||||||
.read()
|
.read()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.values()
|
.values()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
for tx in senders {
|
for tx in senders {
|
||||||
let _ = tx.try_send(msg_str.clone());
|
let _ = tx.try_send(msg_str.clone());
|
||||||
|
}
|
||||||
|
} else if let Ok(gate_wait) = serde_json::from_slice::<serde_json::Value>(&buf[..len]) {
|
||||||
|
if gate_wait.get("type").and_then(|t| t.as_str()) == Some("gate_wait") {
|
||||||
|
if let (Some(action), Some(target)) = (
|
||||||
|
gate_wait.get("action").and_then(|a| a.as_str()),
|
||||||
|
gate_wait.get("target").and_then(|t| t.as_str())
|
||||||
|
) {
|
||||||
|
subscribers.insert((action.to_string(), target.to_string()), addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(event) = event_rx.recv() => {
|
||||||
|
if event.topic == "gate:event" {
|
||||||
|
if let (Some(action), Some(target), Some(status)) = (
|
||||||
|
event.payload.get("action").and_then(|a| a.as_str()),
|
||||||
|
event.payload.get("target").and_then(|t| t.as_str()),
|
||||||
|
event.payload.get("status").and_then(|s| s.as_str()),
|
||||||
|
) {
|
||||||
|
if status == "authorized" || status == "blocked" {
|
||||||
|
if let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
|
||||||
|
let response = if status == "authorized" { b"APPROVED" } else { b"REJECTED" };
|
||||||
|
let _ = socket.send_to(response, addr).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,14 +471,38 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.event.starts_with("agent_") {
|
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||||
let topic = format!("nvim:ui:{}", payload.event);
|
let payload_val = serde_json::json!(&payload);
|
||||||
let event = crate::state::GenericEvent {
|
// 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted)
|
||||||
topic,
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:{}", payload.event),
|
||||||
session_id: Some(payload.session_id.clone()),
|
session_id: Some(payload.session_id.clone()),
|
||||||
payload: serde_json::json!(&payload),
|
payload: payload_val.clone(),
|
||||||
};
|
});
|
||||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
|
||||||
|
// 2. Correlated request_id topic (e.g. nvim:ui:agent_prompt_response:REQ_ID)
|
||||||
|
if let Some(ref req_id) = payload.request_id {
|
||||||
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Correlated diff_id topics
|
||||||
|
if let Some(ref diff_id) = payload.diff_id {
|
||||||
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
// General diff decision topic
|
||||||
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: payload_val.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -772,6 +772,159 @@ impl MemoryHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"tasks/complete" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let task_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if task_id.is_empty() {
|
||||||
|
return Some(crate::mcp::error(id, -32602, "Missing task id parameter"));
|
||||||
|
}
|
||||||
|
self.state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
if t.id == task_id {
|
||||||
|
t.status = "completed".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state);
|
||||||
|
let milestones = crate::handlers::reconciliation::recalculate_milestones(&self.state);
|
||||||
|
self.state.record_activity("task", &format!("Completed task via WebSocket RPC: {}", task_id), Some("completed"));
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "task:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "completed",
|
||||||
|
"unblocked_tasks": unblocked,
|
||||||
|
"updated_milestones": milestones,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({
|
||||||
|
"status": "success",
|
||||||
|
"unblocked_tasks": unblocked,
|
||||||
|
"updated_milestones": milestones
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
"tech_debts/resolve" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let debt_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if debt_id.is_empty() {
|
||||||
|
return Some(crate::mcp::error(id, -32602, "Missing debt id parameter"));
|
||||||
|
}
|
||||||
|
let mut found = false;
|
||||||
|
self.state.code.tech_debts.modify(|debts| {
|
||||||
|
for d in debts.iter_mut() {
|
||||||
|
if d.id == debt_id {
|
||||||
|
d.is_resolved = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
self.state.record_activity("tech_debt", &format!("Resolved tech debt via WebSocket RPC: {}", debt_id), Some("resolved"));
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "tech_debt:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"id": debt_id,
|
||||||
|
"status": "resolved"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) })))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Tech debt not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gates/authorize" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut found = false;
|
||||||
|
self.state.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == gate_id {
|
||||||
|
g.status = "authorized".to_string();
|
||||||
|
g.reason = Some("Manually authorized via WebSocket RPC".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
self.state.record_activity("gate", &format!("Authorized push gate via WebSocket: {}", gate_id), Some("authorized"));
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gates/block" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut found = false;
|
||||||
|
self.state.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == gate_id {
|
||||||
|
g.status = "blocked".to_string();
|
||||||
|
g.reason = Some("Manually blocked via WebSocket RPC".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
self.state.record_activity("gate", &format!("Blocked push gate via WebSocket: {}", gate_id), Some("blocked"));
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gates/delete" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut removed = false;
|
||||||
|
self.state.env.gates.modify(|gates| {
|
||||||
|
if let Some(pos) = gates.iter().position(|g| g.id == gate_id) {
|
||||||
|
gates.remove(pos);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if removed {
|
||||||
|
self.state.record_activity("gate", &format!("Deleted push gate via WebSocket: {}", gate_id), Some("deleted"));
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": gate_id, "status": "deleted" }),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"resources/subscribe" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
|
||||||
|
tracing::info!("Client subscribed to MCP resource: {}", uri);
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({})))
|
||||||
|
}
|
||||||
|
"resources/unsubscribe" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
|
||||||
|
tracing::info!("Client unsubscribed from MCP resource: {}", uri);
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({})))
|
||||||
|
}
|
||||||
m if m.starts_with("notifications/") => None,
|
m if m.starts_with("notifications/") => None,
|
||||||
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
|
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
|
||||||
_ => {
|
_ => {
|
||||||
|
|||||||
@@ -179,6 +179,34 @@ impl MemoryIndex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn index_clipboard_image(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
file_path: &str,
|
||||||
|
ocr_text: &str,
|
||||||
|
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
|
let writer = Arc::clone(&self.writer);
|
||||||
|
let id_field = self.id_field;
|
||||||
|
let id_val = id.to_string();
|
||||||
|
let needs_commit = Arc::clone(&self.needs_commit);
|
||||||
|
|
||||||
|
let doc = doc!(
|
||||||
|
self.id_field => id,
|
||||||
|
self.title_field => format!("Clipboard Screenshot: {}", file_path),
|
||||||
|
self.body_field => ocr_text,
|
||||||
|
self.type_field => "clipboard_image",
|
||||||
|
self.namespace_field => "global"
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let writer = writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val));
|
||||||
|
writer.add_document(doc)?;
|
||||||
|
needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
let writer = Arc::clone(&self.writer);
|
let writer = Arc::clone(&self.writer);
|
||||||
let id_field = self.id_field;
|
let id_field = self.id_field;
|
||||||
|
|||||||
+110
-1
@@ -42,6 +42,78 @@ pub struct TelemetryStores {
|
|||||||
pub agent_signals: Store<Vec<AgentSignal>>,
|
pub agent_signals: Store<Vec<AgentSignal>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct CachedClipboardImage {
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_path_wsl: String,
|
||||||
|
pub captured_at_epoch_ms: u64,
|
||||||
|
pub age: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub size_bytes: usize,
|
||||||
|
pub ocr_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct CachedClipboardText {
|
||||||
|
pub text: String,
|
||||||
|
pub captured_at_epoch_ms: u64,
|
||||||
|
pub age: String,
|
||||||
|
pub char_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ClipboardHistoryItem {
|
||||||
|
Image(CachedClipboardImage),
|
||||||
|
Text(CachedClipboardText),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ClipboardCacheState {
|
||||||
|
pub last_image: Option<CachedClipboardImage>,
|
||||||
|
pub last_text: Option<CachedClipboardText>,
|
||||||
|
pub history: std::collections::VecDeque<ClipboardHistoryItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardCacheState {
|
||||||
|
pub fn push_image(&mut self, image: CachedClipboardImage) {
|
||||||
|
self.last_image = Some(image.clone());
|
||||||
|
self.history.push_front(ClipboardHistoryItem::Image(image));
|
||||||
|
if self.history.len() > 20 {
|
||||||
|
self.history.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_text(&mut self, text: CachedClipboardText) {
|
||||||
|
if let Some(ref prev) = self.last_text {
|
||||||
|
if prev.text == text.text {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_text = Some(text.clone());
|
||||||
|
self.history.push_front(ClipboardHistoryItem::Text(text));
|
||||||
|
if self.history.len() > 20 {
|
||||||
|
self.history.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_age(epoch_ms: u64) -> String {
|
||||||
|
let now_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
let diff_secs = now_ms.saturating_sub(epoch_ms) / 1000;
|
||||||
|
if diff_secs < 60 {
|
||||||
|
format!("{}s ago", diff_secs)
|
||||||
|
} else if diff_secs < 3600 {
|
||||||
|
format!("{}m ago", diff_secs / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}h ago", diff_secs / 3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MemoryState {
|
pub struct MemoryState {
|
||||||
pub base_dir: PathBuf,
|
pub base_dir: PathBuf,
|
||||||
pub index_commit_notify: Arc<tokio::sync::Notify>,
|
pub index_commit_notify: Arc<tokio::sync::Notify>,
|
||||||
@@ -59,6 +131,7 @@ pub struct MemoryState {
|
|||||||
pub activity_tx: tokio::sync::broadcast::Sender<String>,
|
pub activity_tx: tokio::sync::broadcast::Sender<String>,
|
||||||
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
|
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
|
||||||
pub ollama: Arc<crate::ollama::OllamaClient>,
|
pub ollama: Arc<crate::ollama::OllamaClient>,
|
||||||
|
pub clipboard_cache: Arc<tokio::sync::RwLock<ClipboardCacheState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryState {
|
impl MemoryState {
|
||||||
@@ -133,6 +206,7 @@ impl MemoryState {
|
|||||||
|
|
||||||
activity_tx: tokio::sync::broadcast::channel(100).0,
|
activity_tx: tokio::sync::broadcast::channel(100).0,
|
||||||
event_bus_tx: tokio::sync::broadcast::channel(1000).0,
|
event_bus_tx: tokio::sync::broadcast::channel(1000).0,
|
||||||
|
clipboard_cache: Arc::new(tokio::sync::RwLock::new(ClipboardCacheState::default())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalize pre-existing graph entity and relation types
|
// Normalize pre-existing graph entity and relation types
|
||||||
@@ -162,6 +236,19 @@ impl MemoryState {
|
|||||||
|
|
||||||
pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
||||||
self.graph.modify(update_fn);
|
self.graph.modify(update_fn);
|
||||||
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(100);
|
||||||
|
let count = self.graph.read_with(|g| g.entities.len());
|
||||||
|
if count >= threshold {
|
||||||
|
self.condense_notify.notify_one();
|
||||||
|
}
|
||||||
|
let _ = self.event_bus_tx.send(GenericEvent {
|
||||||
|
topic: "resource:updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "uri": "memory://graph" }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_search_index(&self) -> MemoryIndex {
|
pub async fn get_search_index(&self) -> MemoryIndex {
|
||||||
@@ -283,7 +370,24 @@ impl MemoryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn broadcast_task_event(&self, event: TaskEvent) {
|
pub fn broadcast_task_event(&self, event: TaskEvent) {
|
||||||
let payload_val = serde_json::to_value(&event).unwrap_or_default();
|
let mut payload_val = serde_json::to_value(&event).unwrap_or_default();
|
||||||
|
if let Some(obj) = payload_val.as_object_mut() {
|
||||||
|
let active_task = self.project.tasks.read_with(|tasks| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.is_active())
|
||||||
|
.or_else(|| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.status == "pending" && t.parent_id.is_none())
|
||||||
|
})
|
||||||
|
.map(|t| t.title.clone())
|
||||||
|
});
|
||||||
|
obj.insert(
|
||||||
|
"active_task".to_string(),
|
||||||
|
serde_json::to_value(active_task).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let summary_str = format!("Task {} -> {}", event.task_id, event.status);
|
let summary_str = format!("Task {} -> {}", event.task_id, event.status);
|
||||||
let details_str = payload_val.to_string();
|
let details_str = payload_val.to_string();
|
||||||
@@ -315,6 +419,11 @@ impl MemoryState {
|
|||||||
payload: payload_val,
|
payload: payload_val,
|
||||||
};
|
};
|
||||||
let _ = self.event_bus_tx.send(generic_ev);
|
let _ = self.event_bus_tx.send(generic_ev);
|
||||||
|
let _ = self.event_bus_tx.send(GenericEvent {
|
||||||
|
topic: "resource:updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "uri": "memory://tasks/active" }),
|
||||||
|
});
|
||||||
|
|
||||||
let ws_notification = serde_json::json!({
|
let ws_notification = serde_json::json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
|
|||||||
+24
-5
@@ -984,24 +984,43 @@ pub struct EnvironmentTool {
|
|||||||
pub requires_vpn: Option<bool>,
|
pub requires_vpn: Option<bool>,
|
||||||
/// Optional namespace.
|
/// Optional namespace.
|
||||||
pub namespace: Option<String>,
|
pub namespace: Option<String>,
|
||||||
|
/// Optional operating system name (e.g. 'windows', 'linux', 'darwin').
|
||||||
|
pub os: Option<String>,
|
||||||
|
/// Optional shell name (e.g. 'pwsh', 'bash', 'zsh', 'cmd').
|
||||||
|
pub shell: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ClipboardAction {
|
pub enum ClipboardAction {
|
||||||
|
#[serde(alias = "image", alias = "IMAGE", alias = "Image", alias = "screenshot", alias = "SCREENSHOT")]
|
||||||
|
Image,
|
||||||
|
#[serde(alias = "text", alias = "TEXT", alias = "Text")]
|
||||||
|
Text,
|
||||||
|
#[serde(alias = "history", alias = "HISTORY", alias = "History")]
|
||||||
|
History,
|
||||||
|
#[serde(alias = "clear", alias = "CLEAR", alias = "Clear")]
|
||||||
|
Clear,
|
||||||
#[serde(alias = "read", alias = "READ", alias = "Read")]
|
#[serde(alias = "read", alias = "READ", alias = "Read")]
|
||||||
Read,
|
Read,
|
||||||
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
|
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
|
||||||
Write,
|
Write,
|
||||||
}
|
}
|
||||||
/// OS Clipboard management (read, write).
|
/// Smart OS Clipboard management with overwrite-immune screenshot caching and OCR.
|
||||||
/// Actions:
|
/// Actions:
|
||||||
/// - 'read': Read current clipboard contents.
|
/// - 'image': Get latest screenshot image path (Windows + WSL) and extracted verbatim OCR text.
|
||||||
|
/// Returns cached screenshot even if text was copied afterwards!
|
||||||
|
/// - 'text': Get latest clipboard text (or normalized Markdown if HTML was copied).
|
||||||
|
/// - 'history': View recent clipboard history ring buffer (images and text with timestamps).
|
||||||
|
/// - 'clear': Clear OS clipboard and memory cache.
|
||||||
|
/// - 'read': Read current clipboard contents (legacy alias).
|
||||||
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
|
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
|
||||||
/// Next steps on error: Provide text/html/files/image_path when calling 'write'.
|
///
|
||||||
|
/// Triggers: Call 'image' immediately when user says "look at image in clipboard", "see screenshot",
|
||||||
|
/// "look at clipboard", "what I copied", or shares terminal error captures.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct ClipboardTool {
|
pub struct ClipboardTool {
|
||||||
/// Action to perform: 'read' or 'write'.
|
/// Action to perform: 'image', 'text', 'history', 'clear', 'read', or 'write'.
|
||||||
pub action: ClipboardAction,
|
pub action: ClipboardAction,
|
||||||
/// Plain text content (for 'write').
|
/// Plain text content (for 'write').
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
|||||||
+22
-1
@@ -116,6 +116,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
|||||||
let file_str = path.to_string_lossy().to_string();
|
let file_str = path.to_string_lossy().to_string();
|
||||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
|
||||||
|
let chunks_count = chunks.len();
|
||||||
|
let file_str_clone = file_str.clone();
|
||||||
state.modify_graph(|g| {
|
state.modify_graph(|g| {
|
||||||
// Ensure File entity exists
|
// Ensure File entity exists
|
||||||
g.entities.entry(file_str.clone()).or_insert_with(|| {
|
g.entities.entry(file_str.clone()).or_insert_with(|| {
|
||||||
@@ -147,7 +149,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
|||||||
created_at: Some(now),
|
created_at: Some(now),
|
||||||
updated_at: Some(now),
|
updated_at: Some(now),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let rel = crate::models::Relation {
|
let rel = crate::models::Relation {
|
||||||
from: file_str.clone(),
|
from: file_str.clone(),
|
||||||
@@ -161,6 +164,24 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "ast:symbol_updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"file": file_str_clone,
|
||||||
|
"extension": ext,
|
||||||
|
"symbols_count": chunks_count,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "resource:updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"uri": "memory://graph"
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,8 +129,15 @@ fn test_dashboard_route_parity() {
|
|||||||
} else {
|
} else {
|
||||||
full_call
|
full_call
|
||||||
};
|
};
|
||||||
let normalized = clean.replace("${id}", "{id}");
|
let mut normalized = clean.to_string();
|
||||||
endpoint = normalized.to_string();
|
while let Some(start_var) = normalized.find("${") {
|
||||||
|
if let Some(end_var) = normalized[start_var..].find('}') {
|
||||||
|
normalized.replace_range(start_var..=start_var + end_var, "{id}");
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endpoint = normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ serde_json = "1.0.151"
|
|||||||
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||||
regex = "1.13.1"
|
regex = "1.13.1"
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
chrono = "0.4.45"
|
||||||
+20
-3
@@ -1,22 +1,39 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let git_hash = Command::new("git")
|
let mut git_hash = Command::new("git")
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
git_hash = git_hash.trim().to_string();
|
||||||
|
|
||||||
|
let is_dirty = Command::new("git")
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|out| !out.stdout.is_empty());
|
||||||
|
|
||||||
|
if is_dirty {
|
||||||
|
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||||
|
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||||
|
}
|
||||||
|
|
||||||
let git_date = Command::new("git")
|
let git_date = Command::new("git")
|
||||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||||
|
|
||||||
let version = format!("{} ({})", git_date.trim(), git_hash.trim());
|
let version = format!("v{git_date} ({git_hash})");
|
||||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
println!("cargo:rerun-if-changed=../.git/index");
|
println!("cargo:rerun-if-changed=../.git/index");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user