mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b18140d3 | ||
|
|
a81482d06a | ||
|
|
de5796bbe6 | ||
|
|
29d57857ff | ||
|
|
56616d0616 | ||
|
|
90803a4885 | ||
|
|
e2dd736e38 | ||
|
|
a9677881df | ||
|
|
620d538f23 | ||
|
|
a59b9b1622 | ||
|
|
00d8871f5e | ||
|
|
28b6582dac | ||
|
|
dc010fe57d | ||
|
|
7e1f6efe3c | ||
|
|
eab3b58e0f | ||
|
|
720a14cd65 | ||
|
|
f07875ee82 | ||
|
|
cfc95c9ce4 | ||
|
|
62da8e5315 | ||
|
|
d0200ff9ee | ||
|
|
c14dbc6877 | ||
|
|
9316c7ac8d | ||
|
|
73c42c9f55 | ||
|
|
c258c0c845 | ||
|
|
be536e56d5 | ||
|
|
6b575b92d6 | ||
|
|
c572408ab4 | ||
|
|
fae538c169 | ||
|
|
9e333a7de1 | ||
|
|
458f607b80 | ||
|
|
9c0083c24c | ||
|
|
a0fecab186 | ||
|
|
96e64a5796 | ||
|
|
ef92d710b8 | ||
|
|
f8702cb0c2 | ||
|
|
c52bab1e95 | ||
|
|
1eec436746 | ||
|
|
3ccb20df63 | ||
|
|
5fbba6bace | ||
|
|
de422c0de0 |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||||
|
"name": "claude-mnemonic",
|
||||||
|
"version": "0.6.38",
|
||||||
|
"description": "Persistent memory system for Claude Code - stores observations, session summaries, and user prompts with semantic search",
|
||||||
|
"owner": {
|
||||||
|
"name": "lukaszraczylo",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "claude-mnemonic",
|
||||||
|
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB",
|
||||||
|
"version": "0.6.38",
|
||||||
|
"author": {
|
||||||
|
"name": "lukaszraczylo"
|
||||||
|
},
|
||||||
|
"source": "./",
|
||||||
|
"category": "productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-mnemonic",
|
||||||
|
"version": "v0.11.57-dirty",
|
||||||
|
"description": "Persistent memory system for Claude Code with SQLite, FTS5, and vector search",
|
||||||
|
"author": {
|
||||||
|
"name": "lukaszraczylo",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"url": "https://github.com/lukaszraczylo"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||||
|
"repository": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||||
|
"license": "MIT",
|
||||||
|
"hooks": "./hooks/hooks.json",
|
||||||
|
"mcpServers": {
|
||||||
|
"claude-mnemonic": {
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-server",
|
||||||
|
"args": ["--project", "${CLAUDE_PROJECT}"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commands": ["./commands/restart.md"]
|
||||||
|
}
|
||||||
+4
-5
@@ -1,5 +1,7 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
bin/
|
bin/
|
||||||
|
/mcp
|
||||||
|
/worker
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
@@ -82,11 +84,8 @@ logs/
|
|||||||
# goreleaser
|
# goreleaser
|
||||||
dist/
|
dist/
|
||||||
docs/dist
|
docs/dist
|
||||||
.claude-plugin
|
|
||||||
|
|
||||||
# Auto-generated plugin configs (generated by scripts/generate-plugin-config.sh)
|
# Non-template plugin configs (keep only .tpl files in plugin/ dir)
|
||||||
.claude-plugin/
|
|
||||||
|
|
||||||
# Non-template plugin configs (keep only .tpl files)
|
|
||||||
plugin/.claude-plugin/plugin.json
|
plugin/.claude-plugin/plugin.json
|
||||||
plugin/.claude-plugin/marketplace.json
|
plugin/.claude-plugin/marketplace.json
|
||||||
|
user-prompt
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev
|
|||||||
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X github.com/lukaszraczylo/claude-mnemonic/pkg/hooks.Version=$(VERSION) -s -w" -buildvcs=false
|
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X github.com/lukaszraczylo/claude-mnemonic/pkg/hooks.Version=$(VERSION) -s -w" -buildvcs=false
|
||||||
BUILD_DIR := bin
|
BUILD_DIR := bin
|
||||||
PLUGIN_DIR := plugin
|
PLUGIN_DIR := plugin
|
||||||
|
STABLE_BIN := $(HOME)/.claude-mnemonic/bin
|
||||||
|
|
||||||
# Go settings
|
# Go settings
|
||||||
GOOS ?= $(shell go env GOOS)
|
GOOS ?= $(shell go env GOOS)
|
||||||
@@ -22,13 +23,12 @@ all: build
|
|||||||
setup-libs:
|
setup-libs:
|
||||||
@./scripts/download-onnx-libs.sh all
|
@./scripts/download-onnx-libs.sh all
|
||||||
|
|
||||||
# Update root plugin metadata with current version
|
# Update version in committed plugin metadata
|
||||||
update-version:
|
update-version:
|
||||||
@mkdir -p .claude-plugin
|
@if [ -f .claude-plugin/plugin.json ]; then \
|
||||||
@sed 's/{{ .Version }}/$(VERSION)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json.tpl > .claude-plugin/plugin.json
|
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' .claude-plugin/plugin.json > .claude-plugin/plugin.json.tmp && mv .claude-plugin/plugin.json.tmp .claude-plugin/plugin.json; \
|
||||||
@echo "Updated .claude-plugin/plugin.json to version $(VERSION)"
|
echo "Updated .claude-plugin/plugin.json to version $(VERSION)"; \
|
||||||
@# marketplace.json contains release-specific data (URLs, SHA256 hashes) that requires manual update per release.
|
fi
|
||||||
@# Only the top-level version field is updated here.
|
|
||||||
@if [ -f marketplace.json ]; then \
|
@if [ -f marketplace.json ]; then \
|
||||||
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' marketplace.json > marketplace.json.tmp && mv marketplace.json.tmp marketplace.json; \
|
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' marketplace.json > marketplace.json.tmp && mv marketplace.json.tmp marketplace.json; \
|
||||||
echo "Updated marketplace.json version fields to $(VERSION)"; \
|
echo "Updated marketplace.json version fields to $(VERSION)"; \
|
||||||
@@ -121,9 +121,11 @@ build-windows:
|
|||||||
stop-worker:
|
stop-worker:
|
||||||
@echo "Stopping worker..."
|
@echo "Stopping worker..."
|
||||||
@-pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
|
@-pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
|
||||||
|
@-pkill -TERM -f '\.claude-mnemonic/bin/worker' 2>/dev/null || true
|
||||||
@-pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
@-pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
||||||
@sleep 1
|
@sleep 1
|
||||||
@-pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
|
@-pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
|
||||||
|
@-pkill -9 -f '\.claude-mnemonic/bin/worker' 2>/dev/null || true
|
||||||
@-pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
@-pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
||||||
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
|
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
|
||||||
@sleep 1
|
@sleep 1
|
||||||
@@ -131,12 +133,7 @@ stop-worker:
|
|||||||
# Start worker in background
|
# Start worker in background
|
||||||
start-worker:
|
start-worker:
|
||||||
@echo "Starting worker..."
|
@echo "Starting worker..."
|
||||||
@# Prefer cache directory (where Claude Code looks), fall back to marketplaces
|
@nohup $(STABLE_BIN)/worker > /tmp/claude-mnemonic-worker.log 2>&1 &
|
||||||
@if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker" ]; then \
|
|
||||||
nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
|
|
||||||
else \
|
|
||||||
nohup $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
|
|
||||||
fi
|
|
||||||
@sleep 1
|
@sleep 1
|
||||||
@if curl -s http://localhost:37777/health > /dev/null 2>&1; then \
|
@if curl -s http://localhost:37777/health > /dev/null 2>&1; then \
|
||||||
echo "Worker started successfully (http://localhost:37777)"; \
|
echo "Worker started successfully (http://localhost:37777)"; \
|
||||||
@@ -147,26 +144,31 @@ start-worker:
|
|||||||
# Restart worker
|
# Restart worker
|
||||||
restart-worker: stop-worker start-worker
|
restart-worker: stop-worker start-worker
|
||||||
|
|
||||||
# Install to Claude plugins directory
|
# Install to stable binary location and register with Claude Code
|
||||||
install: build stop-worker
|
install: build stop-worker
|
||||||
@echo "Installing to Claude plugins directory..."
|
@echo "Installing claude-mnemonic..."
|
||||||
@# Verify build output binaries exist
|
@# Verify build output binaries exist
|
||||||
@test -f $(BUILD_DIR)/worker || { echo "ERROR: $(BUILD_DIR)/worker not found. Build may have failed."; exit 1; }
|
@test -f $(BUILD_DIR)/worker || { echo "ERROR: $(BUILD_DIR)/worker not found. Build may have failed."; exit 1; }
|
||||||
@test -f $(BUILD_DIR)/mcp-server || { echo "ERROR: $(BUILD_DIR)/mcp-server not found. Build may have failed."; exit 1; }
|
@test -f $(BUILD_DIR)/mcp-server || { echo "ERROR: $(BUILD_DIR)/mcp-server not found. Build may have failed."; exit 1; }
|
||||||
@test -d $(BUILD_DIR)/hooks || { echo "ERROR: $(BUILD_DIR)/hooks not found. Build may have failed."; exit 1; }
|
@test -d $(BUILD_DIR)/hooks || { echo "ERROR: $(BUILD_DIR)/hooks not found. Build may have failed."; exit 1; }
|
||||||
@# Install to marketplaces directory (for direct installs)
|
@# Install binaries to stable location (survives Claude Code updates)
|
||||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
|
@mkdir -p $(STABLE_BIN)/hooks
|
||||||
|
cp $(BUILD_DIR)/worker $(STABLE_BIN)/
|
||||||
|
cp $(BUILD_DIR)/mcp-server $(STABLE_BIN)/
|
||||||
|
cp $(BUILD_DIR)/hooks/* $(STABLE_BIN)/hooks/
|
||||||
|
@echo "Binaries installed to $(STABLE_BIN)"
|
||||||
|
@# Set up marketplace directory with wrapper scripts and metadata
|
||||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
|
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
|
||||||
|
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
|
||||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
|
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
|
||||||
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
@cp .claude-plugin/plugin.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/
|
||||||
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
@cp marketplace.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json 2>/dev/null || true
|
||||||
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
@cp hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||||
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
@cp hooks/session-start hooks/user-prompt hooks/post-tool-use hooks/stop hooks/subagent-stop hooks/statusline $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||||
@# Copy slash commands if they exist
|
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/*
|
||||||
@if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi
|
@cp mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||||
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
|
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/mcp-server
|
||||||
@sed 's/{{ .Version }}/$(VERSION)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json.tpl > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
|
@cp commands/restart.md $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/
|
||||||
@sed 's/{{ .Version }}/$(VERSION)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json.tpl > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
|
|
||||||
@echo "Registering plugin with Claude Code..."
|
@echo "Registering plugin with Claude Code..."
|
||||||
@./scripts/register-plugin.sh "$(VERSION)"
|
@./scripts/register-plugin.sh "$(VERSION)"
|
||||||
@$(MAKE) start-worker
|
@$(MAKE) start-worker
|
||||||
|
|||||||
@@ -177,15 +177,27 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
if initErr != nil {
|
if initErr != nil {
|
||||||
return "", initErr
|
return "", initErr
|
||||||
}
|
}
|
||||||
|
if initResult == nil {
|
||||||
|
return contextToInject, nil // Non-JSON response from worker, skip session init
|
||||||
|
}
|
||||||
|
|
||||||
// Check if skipped due to privacy
|
// Check if skipped due to privacy
|
||||||
if skipped, ok := initResult["skipped"].(bool); ok && skipped {
|
if skipped, ok := initResult["skipped"].(bool); ok && skipped {
|
||||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session skipped (private)\n")
|
fmt.Fprintf(os.Stderr, "[user-prompt] Session skipped (private)\n")
|
||||||
return "", nil
|
return contextToInject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID := int64(initResult["sessionDbId"].(float64))
|
sessionDBIDVal, ok := initResult["sessionDbId"].(float64)
|
||||||
promptNumber := int(initResult["promptNumber"].(float64))
|
if !ok {
|
||||||
|
return contextToInject, nil // Missing or wrong type, skip gracefully
|
||||||
|
}
|
||||||
|
sessionID := int64(sessionDBIDVal)
|
||||||
|
|
||||||
|
promptNumberVal, ok := initResult["promptNumber"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return contextToInject, nil
|
||||||
|
}
|
||||||
|
promptNumber := int(promptNumberVal)
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
|
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEstimateTokens tests the token estimator.
|
||||||
|
func TestEstimateTokens(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
minToken int
|
||||||
|
maxToken int
|
||||||
|
}{
|
||||||
|
{"empty string", "", 0, 0},
|
||||||
|
{"single word", "hello", 1, 3},
|
||||||
|
{"simple sentence", "Hello world this is a test", 5, 15},
|
||||||
|
{"code-heavy", "func() { return x.y.z(); }", 5, 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := estimateTokens(tt.input)
|
||||||
|
assert.GreaterOrEqual(t, result, tt.minToken)
|
||||||
|
assert.LessOrEqual(t, result, tt.maxToken)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleUserPrompt_NilInitResult_Compile verifies that the nil-safety
|
||||||
|
// fix in handleUserPrompt compiles correctly. The actual nil dereference
|
||||||
|
// was at initResult["sessionDbId"].(float64) when initResult was nil.
|
||||||
|
// This test ensures the defensive type assertions are present by exercising
|
||||||
|
// the token estimator (the handler requires a live HookContext+worker).
|
||||||
|
func TestHandleUserPrompt_NilInitResult_Compile(t *testing.T) {
|
||||||
|
// The real regression test is that `go build ./cmd/hooks/user-prompt/`
|
||||||
|
// succeeds with the nil-safe assertions. We can't easily spin up
|
||||||
|
// a full HookContext here, but we verify the package compiles and
|
||||||
|
// the helper functions are sane.
|
||||||
|
assert.Equal(t, 0, estimateTokens(""))
|
||||||
|
assert.Greater(t, estimateTokens("test input"), 0)
|
||||||
|
}
|
||||||
+12
-6
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/mcp"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/mcp"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
|
||||||
|
"github.com/lukaszraczylo/oss-telemetry"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -58,25 +59,30 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Start file watchers for config changes
|
// Start file watchers for config changes
|
||||||
startWatchers()
|
startWatchers(cancel)
|
||||||
|
|
||||||
|
telemetry.Send("claude-mnemonic", Version)
|
||||||
|
|
||||||
// Create and run MCP server
|
// Create and run MCP server
|
||||||
server := mcp.NewServer(client, workerURL, *project, Version)
|
server := mcp.NewServer(client, workerURL, *project, Version)
|
||||||
log.Info().Str("project", *project).Str("version", Version).Str("worker", workerURL).Msg("Starting MCP server")
|
log.Info().Str("project", *project).Str("version", Version).Str("worker", workerURL).Msg("Starting MCP server")
|
||||||
|
|
||||||
if err := server.Run(ctx); err != nil {
|
if err := server.Run(ctx); err != nil {
|
||||||
|
if err == context.Canceled {
|
||||||
|
log.Info().Msg("MCP server shut down (config change or signal)")
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Fatal().Err(err).Msg("MCP server error")
|
log.Fatal().Err(err).Msg("MCP server error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWatchers initializes file watchers for config.
|
// startWatchers initializes file watchers for config.
|
||||||
func startWatchers() {
|
func startWatchers(cancel context.CancelFunc) {
|
||||||
// Watch config file for changes (triggers process exit for restart)
|
// Watch config file for changes (triggers graceful shutdown via context cancellation)
|
||||||
configPath := config.SettingsPath()
|
configPath := config.SettingsPath()
|
||||||
configWatcher, err := watcher.New(configPath, func() {
|
configWatcher, err := watcher.New(configPath, func() {
|
||||||
log.Warn().Str("path", configPath).Msg("Config file changed, exiting for restart...")
|
log.Warn().Str("path", configPath).Msg("Config file changed, shutting down gracefully...")
|
||||||
time.Sleep(100 * time.Millisecond) // Give logs time to flush
|
cancel() // Triggers ctx.Done() in server.Run(), which drains in-flight requests
|
||||||
os.Exit(0)
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Failed to create config watcher")
|
log.Warn().Err(err).Msg("Failed to create config watcher")
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Restart Claude Mnemonic Worker
|
||||||
|
|
||||||
|
Restart the claude-mnemonic worker process. Use this command when experiencing issues with the memory system.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Call the restart API endpoint using curl:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:37777/api/restart
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Wait a moment for the worker to restart (typically 1-2 seconds)
|
||||||
|
|
||||||
|
3. Verify the worker is running by checking the version:
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:37777/api/version
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Report the result to the user, including the version number from the response.
|
||||||
|
|
||||||
|
If the restart fails, suggest the user check `/tmp/claude-mnemonic-worker.log` for errors.
|
||||||
@@ -6,17 +6,18 @@ replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.10.6
|
||||||
github.com/mattn/go-sqlite3 v1.14.34
|
github.com/lukaszraczylo/oss-telemetry v0.2.1
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/sugarme/tokenizer v0.3.0
|
github.com/sugarme/tokenizer v0.3.0
|
||||||
github.com/yalue/onnxruntime_go v1.27.0
|
github.com/yalue/onnxruntime_go v1.30.1
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.20.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -27,14 +28,14 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
||||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,46 +2,41 @@ github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI
|
|||||||
github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
|
github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
|
||||||
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43 h1:j8YQypEqa5OjqbGciCNb9hOcYbo1oTVuEjd/iu9U2SY=
|
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43 h1:j8YQypEqa5OjqbGciCNb9hOcYbo1oTVuEjd/iu9U2SY=
|
||||||
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
|
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
|
||||||
|
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
|
||||||
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
||||||
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||||
@@ -52,17 +47,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
||||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
||||||
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
|
github.com/yalue/onnxruntime_go v1.30.1 h1:NaEng5lWbsHZ/8X1dtaw1mIj7eV1ozyjbFo//g0ktl4=
|
||||||
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
github.com/yalue/onnxruntime_go v1.30.1/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"description": "Claude Mnemonic - Persistent memory hooks for observations, prompts, and session summaries",
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/post-tool-use"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/session-start"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/statusline"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/stop"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/subagent-stop"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/user-prompt"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
@@ -99,8 +99,9 @@ func NewStore(cfg Config) (*Store, error) {
|
|||||||
"PRAGMA synchronous=NORMAL",
|
"PRAGMA synchronous=NORMAL",
|
||||||
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
|
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
|
||||||
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
||||||
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
||||||
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
||||||
|
"PRAGMA wal_autocheckpoint=1000", // Explicit default; checkpoint every 1000 WAL frames
|
||||||
}
|
}
|
||||||
for _, pragma := range pragmas {
|
for _, pragma := range pragmas {
|
||||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
if _, err := sqlDB.Exec(pragma); err != nil {
|
||||||
@@ -192,6 +193,11 @@ func (s *Store) Optimize(ctx context.Context) error {
|
|||||||
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
|
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passive WAL checkpoint — doesn't block readers/writers
|
||||||
|
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(PASSIVE)"); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("WAL checkpoint failed (non-fatal)")
|
||||||
|
}
|
||||||
|
|
||||||
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
|
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -513,13 +519,6 @@ func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, quer
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryRowWithTimeout executes a row query with timeout.
|
|
||||||
func (s *Store) QueryRowWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) *sql.Row {
|
|
||||||
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "query_row")
|
|
||||||
// Note: cancel will be called when row.Scan() completes or errors
|
|
||||||
_ = cancel // Caller must ensure proper cleanup
|
|
||||||
return s.sqlDB.QueryRowContext(timeoutCtx, query, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
||||||
// The transaction is automatically rolled back if the context times out.
|
// The transaction is automatically rolled back if the context times out.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package gorm
|
package gorm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -150,3 +151,91 @@ func TestMigrationIdempotency(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("✅ Migrations are idempotent")
|
t.Logf("✅ Migrations are idempotent")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWALAutocheckpoint(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gorm_wal_checkpoint_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
store, err := NewStore(Config{
|
||||||
|
Path: dbPath,
|
||||||
|
MaxConns: 2,
|
||||||
|
LogLevel: logger.Silent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Verify wal_autocheckpoint is set to 1000
|
||||||
|
var checkpoint int
|
||||||
|
err = store.GetRawDB().QueryRow("PRAGMA wal_autocheckpoint").Scan(&checkpoint)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query wal_autocheckpoint: %v", err)
|
||||||
|
}
|
||||||
|
if checkpoint != 1000 {
|
||||||
|
t.Errorf("expected wal_autocheckpoint=1000, got %d", checkpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptimize_RunsWALCheckpoint(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gorm_optimize_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
store, err := NewStore(Config{
|
||||||
|
Path: dbPath,
|
||||||
|
MaxConns: 2,
|
||||||
|
LogLevel: logger.Silent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Insert some data to generate WAL frames
|
||||||
|
_, err = store.GetRawDB().Exec("INSERT INTO observations (sdk_session_id, title, scope, project, type, created_at, created_at_epoch) VALUES ('test-sess', 'test data', 'project', '/tmp/test', 'decision', '2026-01-01T00:00:00Z', 1735689600)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert test data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimize should succeed (includes PASSIVE WAL checkpoint)
|
||||||
|
err = store.Optimize(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Optimize failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptimize_RespectsContextCancellation(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gorm_optimize_cancel_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
store, err := NewStore(Config{
|
||||||
|
Path: dbPath,
|
||||||
|
MaxConns: 2,
|
||||||
|
LogLevel: logger.Silent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Already-cancelled context should cause Optimize to fail
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err = store.Optimize(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error with cancelled context, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.24.3
|
1.26.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.24.3
|
1.26.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.24.3
|
1.26.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.24.3
|
1.26.0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package embedding
|
package embedding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -44,6 +45,14 @@ type EmbeddingModel interface {
|
|||||||
// EmbedBatch generates embeddings for multiple texts.
|
// EmbedBatch generates embeddings for multiple texts.
|
||||||
EmbedBatch(texts []string) ([][]float32, error)
|
EmbedBatch(texts []string) ([][]float32, error)
|
||||||
|
|
||||||
|
// EmbedWithContext generates an embedding for a single text with context-aware cancellation.
|
||||||
|
// The context controls mutex acquisition timeout — if ctx is cancelled while waiting
|
||||||
|
// for the model lock, the call returns immediately with ctx.Err().
|
||||||
|
EmbedWithContext(ctx context.Context, text string) ([]float32, error)
|
||||||
|
|
||||||
|
// EmbedBatchWithContext generates embeddings for multiple texts with context-aware cancellation.
|
||||||
|
EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error)
|
||||||
|
|
||||||
// Close releases model resources.
|
// Close releases model resources.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package embedding
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -225,6 +226,103 @@ func (m *bgeModel) EmbedBatch(texts []string) ([][]float32, error) {
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acquireMutex attempts to acquire the model mutex, respecting context cancellation.
|
||||||
|
// On success the caller MUST call the returned unlock function.
|
||||||
|
// If ctx is cancelled while waiting, returns ctx.Err() and no unlock is needed.
|
||||||
|
func (m *bgeModel) acquireMutex(ctx context.Context) (unlock func(), err error) {
|
||||||
|
acquired := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
close(acquired)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-acquired:
|
||||||
|
// Got the lock normally.
|
||||||
|
return m.mu.Unlock, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Context cancelled while waiting. The goroutine above will eventually
|
||||||
|
// acquire the mutex — we must ensure it gets unlocked.
|
||||||
|
go func() {
|
||||||
|
<-acquired
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedWithContext generates an embedding for a single text with context-aware mutex acquisition.
|
||||||
|
// If ctx is cancelled while waiting for the model lock, returns immediately with ctx.Err().
|
||||||
|
func (m *bgeModel) EmbedWithContext(ctx context.Context, text string) ([]float32, error) {
|
||||||
|
if text == "" {
|
||||||
|
return make([]float32, EmbeddingDim), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock, err := m.acquireMutex(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire embedding lock: %w", err)
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
results, err := m.computeBatch([]string{text})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return make([]float32, EmbeddingDim), nil
|
||||||
|
}
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedBatchWithContext generates embeddings for multiple texts with context-aware mutex acquisition.
|
||||||
|
func (m *bgeModel) EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error) {
|
||||||
|
if len(texts) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty texts and track indices
|
||||||
|
nonEmpty := make([]string, 0, len(texts))
|
||||||
|
indices := make([]int, 0, len(texts))
|
||||||
|
for i, t := range texts {
|
||||||
|
if t != "" {
|
||||||
|
nonEmpty = append(nonEmpty, t)
|
||||||
|
indices = append(indices, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all texts are empty, return zero vectors
|
||||||
|
if len(nonEmpty) == 0 {
|
||||||
|
results := make([][]float32, len(texts))
|
||||||
|
for i := range results {
|
||||||
|
results[i] = make([]float32, EmbeddingDim)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock, err := m.acquireMutex(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire embedding lock: %w", err)
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Compute embeddings for non-empty texts
|
||||||
|
embeddings, err := m.computeBatch(nonEmpty)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compute batch embeddings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result with zero vectors for empty texts
|
||||||
|
results := make([][]float32, len(texts))
|
||||||
|
for i := range results {
|
||||||
|
results[i] = make([]float32, EmbeddingDim)
|
||||||
|
}
|
||||||
|
for i, idx := range indices {
|
||||||
|
results[idx] = embeddings[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
// computeBatch runs inference on a batch of texts. Must be called with lock held.
|
// computeBatch runs inference on a batch of texts. Must be called with lock held.
|
||||||
func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
|
func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
|
||||||
if len(sentences) == 0 {
|
if len(sentences) == 0 {
|
||||||
@@ -509,6 +607,17 @@ func (s *Service) EmbedBatch(texts []string) ([][]float32, error) {
|
|||||||
return s.model.EmbedBatch(texts)
|
return s.model.EmbedBatch(texts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmbedWithContext generates an embedding with context-aware cancellation.
|
||||||
|
// If ctx is cancelled while waiting for the model lock, returns immediately.
|
||||||
|
func (s *Service) EmbedWithContext(ctx context.Context, text string) ([]float32, error) {
|
||||||
|
return s.model.EmbedWithContext(ctx, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedBatchWithContext generates embeddings with context-aware cancellation.
|
||||||
|
func (s *Service) EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error) {
|
||||||
|
return s.model.EmbedBatchWithContext(ctx, texts)
|
||||||
|
}
|
||||||
|
|
||||||
// Close releases model resources.
|
// Close releases model resources.
|
||||||
func (s *Service) Close() error {
|
func (s *Service) Close() error {
|
||||||
return s.model.Close()
|
return s.model.Close()
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package embedding
|
package embedding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -310,6 +313,146 @@ func TestEmbed_Deterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Regression tests: context-aware mutex (Fix #45) ---
|
||||||
|
|
||||||
|
// asBGE casts the service model to *bgeModel for direct mutex access.
|
||||||
|
// Tests are in the same package so this is safe.
|
||||||
|
func asBGE(t *testing.T, svc *Service) *bgeModel {
|
||||||
|
t.Helper()
|
||||||
|
m, ok := svc.model.(*bgeModel)
|
||||||
|
require.True(t, ok, "model is not *bgeModel — test invariant broken")
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdMutex locks m.mu in a background goroutine and returns a release func.
|
||||||
|
// The returned ready channel is closed once the lock is held.
|
||||||
|
func holdMutex(m *bgeModel) (ready <-chan struct{}, release func()) {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
close(ch) // signal: lock acquired
|
||||||
|
<-done // wait for release signal
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
return ch, func() { close(done) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedWithContext_CancelWhileWaitingForMutex is the core regression test.
|
||||||
|
// If the mutex is held and the context times out, EmbedWithContext must return
|
||||||
|
// immediately with a context error — not block until the mutex is released.
|
||||||
|
func TestEmbedWithContext_CancelWhileWaitingForMutex(t *testing.T) {
|
||||||
|
svc, err := NewService()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer svc.Close()
|
||||||
|
|
||||||
|
m := asBGE(t, svc)
|
||||||
|
|
||||||
|
// Hold the mutex to simulate a stuck ONNX call.
|
||||||
|
ready, release := holdMutex(m)
|
||||||
|
<-ready // ensure lock is held before proceeding
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = svc.EmbedWithContext(ctx, "test text")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Must return a context error.
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t,
|
||||||
|
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
|
||||||
|
"expected context error, got: %v", err)
|
||||||
|
|
||||||
|
// Must return quickly (well under the 30 s default; allow 2× the timeout for CI slack).
|
||||||
|
assert.Less(t, elapsed, 200*time.Millisecond,
|
||||||
|
"EmbedWithContext blocked too long: %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedWithContext_SuccessWhenUncontended verifies normal operation still works.
|
||||||
|
func TestEmbedWithContext_SuccessWhenUncontended(t *testing.T) {
|
||||||
|
svc, err := NewService()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer svc.Close()
|
||||||
|
|
||||||
|
emb, err := svc.EmbedWithContext(context.Background(), "hello world")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, emb, EmbeddingDim)
|
||||||
|
|
||||||
|
var sum float32
|
||||||
|
for _, v := range emb {
|
||||||
|
sum += v * v
|
||||||
|
}
|
||||||
|
assert.Greater(t, sum, float32(0), "embedding should not be all zeros")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedBatchWithContext_CancelDuringBatch verifies batch embedding respects
|
||||||
|
// context cancellation while blocked on mutex acquisition.
|
||||||
|
func TestEmbedBatchWithContext_CancelDuringBatch(t *testing.T) {
|
||||||
|
svc, err := NewService()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer svc.Close()
|
||||||
|
|
||||||
|
m := asBGE(t, svc)
|
||||||
|
|
||||||
|
ready, release := holdMutex(m)
|
||||||
|
<-ready
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = svc.EmbedBatchWithContext(ctx, []string{"a", "b", "c"})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t,
|
||||||
|
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
|
||||||
|
"expected context error, got: %v", err)
|
||||||
|
assert.Less(t, elapsed, 200*time.Millisecond,
|
||||||
|
"EmbedBatchWithContext blocked too long: %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedWithContext_CleanupAfterCancel verifies the cleanup goroutine in
|
||||||
|
// acquireMutex properly unlocks the mutex after context cancellation,
|
||||||
|
// so subsequent calls do not deadlock.
|
||||||
|
func TestEmbedWithContext_CleanupAfterCancel(t *testing.T) {
|
||||||
|
svc, err := NewService()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer svc.Close()
|
||||||
|
|
||||||
|
m := asBGE(t, svc)
|
||||||
|
|
||||||
|
// --- first call: context expires while mutex is held ---
|
||||||
|
ready, release := holdMutex(m)
|
||||||
|
<-ready
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, firstErr := svc.EmbedWithContext(ctx, "should fail")
|
||||||
|
require.Error(t, firstErr)
|
||||||
|
assert.True(t,
|
||||||
|
errors.Is(firstErr, context.DeadlineExceeded) || errors.Is(firstErr, context.Canceled),
|
||||||
|
"expected context error on first call, got: %v", firstErr)
|
||||||
|
|
||||||
|
// Release the held mutex so the cleanup goroutine inside acquireMutex can finish.
|
||||||
|
release()
|
||||||
|
|
||||||
|
// Give the cleanup goroutine a moment to acquire-and-release the mutex.
|
||||||
|
// 50 ms is generous; the goroutine only has to lock+unlock with no contention.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// --- second call: mutex should be free, no deadlock ---
|
||||||
|
emb, secondErr := svc.EmbedWithContext(context.Background(), "should work")
|
||||||
|
require.NoError(t, secondErr, "second call should succeed after cleanup goroutine released mutex")
|
||||||
|
assert.Len(t, emb, EmbeddingDim)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to calculate cosine similarity
|
// Helper function to calculate cosine similarity
|
||||||
func cosineSimilarity(a, b []float32) float64 {
|
func cosineSimilarity(a, b []float32) float64 {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
//go:build fts5
|
||||||
|
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func makeObs(id int64, sessionID string, concepts, filesRead, filesModified []string) *models.Observation {
|
||||||
|
return &models.Observation{
|
||||||
|
ID: id,
|
||||||
|
SDKSessionID: sessionID,
|
||||||
|
Title: sql.NullString{String: "title", Valid: true},
|
||||||
|
Project: "test-project",
|
||||||
|
Type: models.ObsTypeDecision,
|
||||||
|
Concepts: concepts,
|
||||||
|
FilesRead: filesRead,
|
||||||
|
FilesModified: filesModified,
|
||||||
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ObservationGraph -------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNewObservationGraph_Empty(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
require.NotNil(t, g)
|
||||||
|
|
||||||
|
stats := g.Stats()
|
||||||
|
assert.Equal(t, 0, stats.NodeCount)
|
||||||
|
assert.Equal(t, 0, stats.EdgeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddNode_StoresAndRetrieves(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
node := &Node{
|
||||||
|
ID: 42,
|
||||||
|
Degree: 0,
|
||||||
|
Metadata: NodeMetadata{
|
||||||
|
Project: "proj",
|
||||||
|
Type: "decision",
|
||||||
|
Title: "test node",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.AddNode(node)
|
||||||
|
|
||||||
|
got, err := g.GetNode(42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(42), got.ID)
|
||||||
|
assert.Equal(t, "test node", got.Metadata.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddNode_OverwritesExisting(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
g.AddNode(&Node{ID: 1, Metadata: NodeMetadata{Title: "old"}})
|
||||||
|
g.AddNode(&Node{ID: 1, Metadata: NodeMetadata{Title: "new"}})
|
||||||
|
|
||||||
|
got, err := g.GetNode(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new", got.Metadata.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNode_NotFound(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
_, err := g.GetNode(999)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEdge_UpdatesDegree(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
g.AddNode(&Node{ID: 1})
|
||||||
|
g.AddNode(&Node{ID: 2})
|
||||||
|
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
|
||||||
|
|
||||||
|
n1, _ := g.GetNode(1)
|
||||||
|
n2, _ := g.GetNode(2)
|
||||||
|
assert.Equal(t, 1, n1.Degree)
|
||||||
|
assert.Equal(t, 1, n2.Degree)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEdge_MissingNodesDontPanic(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
// Adding edge referencing non-existent nodes must not panic
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
g.AddEdge(Edge{FromID: 100, ToID: 200, Relation: RelationConcept, Weight: 0.5})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BuildCSR / GetNeighbors ------------------------------------------------
|
||||||
|
|
||||||
|
func TestBuildCSR_NoNodes_ReturnsError(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
err := g.BuildCSR()
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNeighbors_AfterBuildCSR(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
for _, id := range []int64{1, 2, 3} {
|
||||||
|
g.AddNode(&Node{ID: id})
|
||||||
|
}
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationConcept, Weight: 0.6})
|
||||||
|
|
||||||
|
require.NoError(t, g.BuildCSR())
|
||||||
|
|
||||||
|
neighbors, weights, err := g.GetNeighbors(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, neighbors, 2)
|
||||||
|
assert.Len(t, weights, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNeighbors_NodeWithNoOutgoingEdges(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
g.AddNode(&Node{ID: 1})
|
||||||
|
g.AddNode(&Node{ID: 2})
|
||||||
|
// Edge only from 2 → 1; node 2 is a leaf from 1's perspective
|
||||||
|
g.AddEdge(Edge{FromID: 2, ToID: 1, Relation: RelationTemporal, Weight: 0.8})
|
||||||
|
require.NoError(t, g.BuildCSR())
|
||||||
|
|
||||||
|
neighbors, weights, err := g.GetNeighbors(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, neighbors)
|
||||||
|
assert.Empty(t, weights)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNeighbors_NodeNotInGraph(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
g.AddNode(&Node{ID: 1})
|
||||||
|
require.NoError(t, g.BuildCSR())
|
||||||
|
|
||||||
|
_, _, err := g.GetNeighbors(999)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FindHubs ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestFindHubs_EmptyGraph(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
hubs := g.FindHubs(0.1)
|
||||||
|
assert.Nil(t, hubs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindHubs_IdentifiesHighDegreeNodes(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
// Node 1 connected to everyone else → hub
|
||||||
|
for id := int64(1); id <= 5; id++ {
|
||||||
|
g.AddNode(&Node{ID: id})
|
||||||
|
}
|
||||||
|
for id := int64(2); id <= 5; id++ {
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: id, Relation: RelationConcept, Weight: 0.5})
|
||||||
|
}
|
||||||
|
|
||||||
|
hubs := g.FindHubs(0.2) // top 20%
|
||||||
|
assert.Contains(t, hubs, int64(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindHubs_Percentile100_ReturnsEmpty(t *testing.T) {
|
||||||
|
// percentile=1.0 → cutoff = ceil(N * (1 - 1.0)) = ceil(0) = 0 → no hubs
|
||||||
|
g := NewObservationGraph()
|
||||||
|
for id := int64(1); id <= 4; id++ {
|
||||||
|
g.AddNode(&Node{ID: id})
|
||||||
|
}
|
||||||
|
hubs := g.FindHubs(1.0)
|
||||||
|
assert.Empty(t, hubs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindHubs_Percentile0_ReturnsAllNodes(t *testing.T) {
|
||||||
|
// percentile=0.0 → cutoff = ceil(N * 1.0) = N → all nodes returned
|
||||||
|
g := NewObservationGraph()
|
||||||
|
for id := int64(1); id <= 4; id++ {
|
||||||
|
g.AddNode(&Node{ID: id})
|
||||||
|
}
|
||||||
|
hubs := g.FindHubs(0.0)
|
||||||
|
assert.Len(t, hubs, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stats ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestStats_EdgeTypesCounted(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
for _, id := range []int64{1, 2, 3} {
|
||||||
|
g.AddNode(&Node{ID: id})
|
||||||
|
}
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationConcept, Weight: 0.6})
|
||||||
|
g.AddEdge(Edge{FromID: 2, ToID: 3, Relation: RelationConcept, Weight: 0.6})
|
||||||
|
|
||||||
|
stats := g.Stats()
|
||||||
|
assert.Equal(t, 3, stats.NodeCount)
|
||||||
|
assert.Equal(t, 3, stats.EdgeCount)
|
||||||
|
assert.Equal(t, 1, stats.EdgeTypes[RelationTemporal])
|
||||||
|
assert.Equal(t, 2, stats.EdgeTypes[RelationConcept])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_DegreeMetrics(t *testing.T) {
|
||||||
|
g := NewObservationGraph()
|
||||||
|
// Node 1: degree 2, nodes 2,3: degree 1 each
|
||||||
|
for _, id := range []int64{1, 2, 3} {
|
||||||
|
g.AddNode(&Node{ID: id})
|
||||||
|
}
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
|
||||||
|
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationTemporal, Weight: 0.8})
|
||||||
|
|
||||||
|
stats := g.Stats()
|
||||||
|
assert.Equal(t, 2, stats.MaxDegree)
|
||||||
|
assert.Equal(t, 1, stats.MinDegree)
|
||||||
|
assert.InDelta(t, 4.0/3.0, stats.AvgDegree, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BuildFromObservations --------------------------------------------------
|
||||||
|
|
||||||
|
func TestBuildFromObservations_SingleObservation_ReturnsError(t *testing.T) {
|
||||||
|
obs := []*models.Observation{makeObs(1, "s1", nil, nil, nil)}
|
||||||
|
// Single observation: DetectEdges returns nil, BuildCSR errors (no nodes never happens
|
||||||
|
// since node was added — but CSR build will succeed with 1 node and 0 edges).
|
||||||
|
g, err := BuildFromObservations(context.Background(), obs)
|
||||||
|
// With 1 node, BuildCSR succeeds (nodes exist); no edges → valid graph.
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, g)
|
||||||
|
stats := g.Stats()
|
||||||
|
assert.Equal(t, 1, stats.NodeCount)
|
||||||
|
assert.Equal(t, 0, stats.EdgeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFromObservations_SetsNodeMetadata(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
{
|
||||||
|
ID: 7,
|
||||||
|
SDKSessionID: "sess",
|
||||||
|
Project: "myproject",
|
||||||
|
Type: models.ObsTypeFeature,
|
||||||
|
Title: sql.NullString{String: "feature title", Valid: true},
|
||||||
|
IsSuperseded: true,
|
||||||
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g, err := BuildFromObservations(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
node, err := g.GetNode(7)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "myproject", node.Metadata.Project)
|
||||||
|
assert.Equal(t, "feature title", node.Metadata.Title)
|
||||||
|
assert.Equal(t, string(models.ObsTypeFeature), node.Metadata.Type)
|
||||||
|
assert.True(t, node.Metadata.IsSuperseded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFromObservations_TitleMissing_EmptyString(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
SDKSessionID: "s",
|
||||||
|
Title: sql.NullString{Valid: false},
|
||||||
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g, err := BuildFromObservations(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
node, err := g.GetNode(3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "", node.Metadata.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFromObservations_WithTemporalEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "sess-a", nil, nil, nil),
|
||||||
|
makeObs(2, "sess-a", nil, nil, nil),
|
||||||
|
makeObs(3, "sess-b", nil, nil, nil),
|
||||||
|
}
|
||||||
|
g, err := BuildFromObservations(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stats := g.Stats()
|
||||||
|
assert.Equal(t, 3, stats.NodeCount)
|
||||||
|
// obs 1 and 2 share session → 1 temporal edge
|
||||||
|
assert.Equal(t, 1, stats.EdgeTypes[RelationTemporal])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFromObservations_WithConceptEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", []string{"security", "auth"}, nil, nil),
|
||||||
|
makeObs(2, "s2", []string{"security"}, nil, nil),
|
||||||
|
makeObs(3, "s3", []string{"unrelated"}, nil, nil),
|
||||||
|
}
|
||||||
|
g, err := BuildFromObservations(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stats := g.Stats()
|
||||||
|
// obs 1 and 2 share "security"
|
||||||
|
assert.GreaterOrEqual(t, stats.EdgeTypes[RelationConcept], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFromObservations_WithFileOverlapEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", nil, []string{"pkg/foo.go", "pkg/bar.go"}, nil),
|
||||||
|
makeObs(2, "s2", nil, []string{"pkg/foo.go", "pkg/baz.go"}, nil),
|
||||||
|
}
|
||||||
|
g, err := BuildFromObservations(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stats := g.Stats()
|
||||||
|
// Jaccard({foo,bar},{foo,baz}) = 1/3 ≈ 0.333 > MinFileOverlapForEdge(0.3)
|
||||||
|
assert.GreaterOrEqual(t, stats.EdgeTypes[RelationFileOverlap], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RelationType.String() --------------------------------------------------
|
||||||
|
|
||||||
|
func TestRelationType_String(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
rt RelationType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{RelationFileOverlap, "file_overlap"},
|
||||||
|
{RelationSemantic, "semantic"},
|
||||||
|
{RelationTemporal, "temporal"},
|
||||||
|
{RelationConcept, "concept"},
|
||||||
|
{RelationType(99), "unknown"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.want, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, tc.rt.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DetectEdges (edge_detector.go) ----------------------------------------
|
||||||
|
|
||||||
|
func TestDetectEdges_LessThanTwo_ReturnsNil(t *testing.T) {
|
||||||
|
edges, err := DetectEdges(context.Background(), nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, edges)
|
||||||
|
|
||||||
|
edges, err = DetectEdges(context.Background(), []*models.Observation{makeObs(1, "s", nil, nil, nil)})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_SameSession_CreatesTemporalEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "session-x", nil, nil, nil),
|
||||||
|
makeObs(2, "session-x", nil, nil, nil),
|
||||||
|
makeObs(3, "session-x", nil, nil, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var temporal []Edge
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.Relation == RelationTemporal {
|
||||||
|
temporal = append(temporal, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Consecutive pairs: (1,2) and (2,3)
|
||||||
|
assert.Len(t, temporal, 2)
|
||||||
|
assert.InDelta(t, 0.8, temporal[0].Weight, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_DifferentSessions_NoTemporalEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "sess-a", nil, nil, nil),
|
||||||
|
makeObs(2, "sess-b", nil, nil, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, e := range edges {
|
||||||
|
assert.NotEqual(t, RelationTemporal, e.Relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_EmptySessionID_NoTemporalEdge(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "", nil, nil, nil),
|
||||||
|
makeObs(2, "", nil, nil, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, e := range edges {
|
||||||
|
assert.NotEqual(t, RelationTemporal, e.Relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_SharedConcepts_CreatesConceptEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", []string{"performance", "caching"}, nil, nil),
|
||||||
|
makeObs(2, "s2", []string{"performance"}, nil, nil),
|
||||||
|
makeObs(3, "s3", []string{"caching"}, nil, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
conceptEdges := filterByRelation(edges, RelationConcept)
|
||||||
|
// obs1↔obs2 (performance), obs1↔obs3 (caching) → 2 concept edges
|
||||||
|
assert.Len(t, conceptEdges, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_NoConcepts_NoConceptEdges(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", nil, nil, nil),
|
||||||
|
makeObs(2, "s2", nil, nil, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Empty(t, filterByRelation(edges, RelationConcept))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_FileOverlap_AboveThreshold_CreatesEdge(t *testing.T) {
|
||||||
|
// Jaccard 2/3 ≈ 0.667 > 0.3 threshold
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", nil, []string{"a.go", "b.go", "c.go"}, nil),
|
||||||
|
makeObs(2, "s2", nil, []string{"a.go", "b.go", "d.go"}, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fileEdges := filterByRelation(edges, RelationFileOverlap)
|
||||||
|
require.Len(t, fileEdges, 1)
|
||||||
|
assert.InDelta(t, 2.0/4.0, float64(fileEdges[0].Weight), 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_FileOverlap_BelowThreshold_NoEdge(t *testing.T) {
|
||||||
|
// Jaccard 1/9 ≈ 0.11 < 0.3 threshold
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", nil, []string{"a.go", "b.go", "c.go", "d.go", "e.go"}, nil),
|
||||||
|
makeObs(2, "s2", nil, []string{"a.go", "f.go", "g.go", "h.go", "i.go"}, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Empty(t, filterByRelation(edges, RelationFileOverlap))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_FilesModified_CountsForOverlap(t *testing.T) {
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", nil, nil, []string{"pkg/core.go", "pkg/util.go"}),
|
||||||
|
makeObs(2, "s2", nil, []string{"pkg/core.go"}, []string{"pkg/util.go"}),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fileEdges := filterByRelation(edges, RelationFileOverlap)
|
||||||
|
assert.NotEmpty(t, fileEdges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEdges_NoEdgeDuplicates(t *testing.T) {
|
||||||
|
// Same pair via two concepts → only one concept edge
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s1", []string{"security", "auth"}, nil, nil),
|
||||||
|
makeObs(2, "s2", []string{"security", "auth"}, nil, nil),
|
||||||
|
}
|
||||||
|
edges, err := DetectEdges(context.Background(), obs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
conceptEdges := filterByRelation(edges, RelationConcept)
|
||||||
|
// Both share security and auth, but deduplication should keep only 1 edge per pair per call
|
||||||
|
// The seen map deduplicates: only first concept that creates the pair wins
|
||||||
|
assert.Len(t, conceptEdges, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- calculateFileOverlap ---------------------------------------------------
|
||||||
|
|
||||||
|
func TestCalculateFileOverlap_DisjointSets_Zero(t *testing.T) {
|
||||||
|
result := calculateFileOverlap([]string{"a.go", "b.go"}, []string{"c.go", "d.go"})
|
||||||
|
assert.Equal(t, float32(0.0), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFileOverlap_IdenticalSets_One(t *testing.T) {
|
||||||
|
files := []string{"a.go", "b.go", "c.go"}
|
||||||
|
result := calculateFileOverlap(files, files)
|
||||||
|
assert.InDelta(t, 1.0, float64(result), 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFileOverlap_EmptySlices_Zero(t *testing.T) {
|
||||||
|
assert.Equal(t, float32(0.0), calculateFileOverlap(nil, []string{"a.go"}))
|
||||||
|
assert.Equal(t, float32(0.0), calculateFileOverlap([]string{"a.go"}, nil))
|
||||||
|
assert.Equal(t, float32(0.0), calculateFileOverlap(nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFileOverlap_Jaccard_Correct(t *testing.T) {
|
||||||
|
// {a,b,c} ∩ {b,c,d} = {b,c} → 2/4 = 0.5
|
||||||
|
result := calculateFileOverlap([]string{"a", "b", "c"}, []string{"b", "c", "d"})
|
||||||
|
assert.InDelta(t, 0.5, float64(result), 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFileOverlap_Duplicates_TreatedAsSet(t *testing.T) {
|
||||||
|
// Duplicates collapse: {a,a,b} → {a,b}; {a,b,b} → {a,b}; Jaccard = 1.0
|
||||||
|
result := calculateFileOverlap([]string{"a", "a", "b"}, []string{"a", "b", "b"})
|
||||||
|
assert.InDelta(t, 1.0, float64(result), 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DetectSemanticEdges ----------------------------------------------------
|
||||||
|
|
||||||
|
func TestDetectSemanticEdges_AboveThreshold_CreatesEdge(t *testing.T) {
|
||||||
|
// Identical vectors → similarity = 1.0 > 0.85
|
||||||
|
emb := []float32{1.0, 0.0, 0.0}
|
||||||
|
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
|
||||||
|
embeddings := map[int64][]float32{1: emb, 2: emb}
|
||||||
|
|
||||||
|
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
|
||||||
|
require.Len(t, edges, 1)
|
||||||
|
assert.Equal(t, RelationSemantic, edges[0].Relation)
|
||||||
|
assert.InDelta(t, 1.0, float64(edges[0].Weight), 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectSemanticEdges_BelowThreshold_NoEdge(t *testing.T) {
|
||||||
|
// Orthogonal vectors → similarity = 0.0
|
||||||
|
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
|
||||||
|
embeddings := map[int64][]float32{
|
||||||
|
1: {1.0, 0.0, 0.0},
|
||||||
|
2: {0.0, 1.0, 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
|
||||||
|
assert.Empty(t, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectSemanticEdges_MissingEmbedding_Skipped(t *testing.T) {
|
||||||
|
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
|
||||||
|
// Only obs 1 has embedding
|
||||||
|
embeddings := map[int64][]float32{1: {1.0, 0.0, 0.0}}
|
||||||
|
|
||||||
|
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
|
||||||
|
assert.Empty(t, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectSemanticEdges_NoDuplicates(t *testing.T) {
|
||||||
|
emb := []float32{0.9, 0.1, 0.0}
|
||||||
|
obs := []*models.Observation{
|
||||||
|
makeObs(1, "s", nil, nil, nil),
|
||||||
|
makeObs(2, "s", nil, nil, nil),
|
||||||
|
makeObs(3, "s", nil, nil, nil),
|
||||||
|
}
|
||||||
|
embeddings := map[int64][]float32{1: emb, 2: emb, 3: emb}
|
||||||
|
|
||||||
|
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
|
||||||
|
// 3 pairs: (1,2),(1,3),(2,3)
|
||||||
|
assert.Len(t, edges, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cosineSimilarity -------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCosineSimilarity_IdenticalVectors(t *testing.T) {
|
||||||
|
v := []float32{1.0, 2.0, 3.0}
|
||||||
|
result := cosineSimilarity(v, v)
|
||||||
|
assert.InDelta(t, 1.0, float64(result), 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCosineSimilarity_OppositeVectors(t *testing.T) {
|
||||||
|
a := []float32{1.0, 0.0}
|
||||||
|
b := []float32{-1.0, 0.0}
|
||||||
|
result := cosineSimilarity(a, b)
|
||||||
|
assert.InDelta(t, -1.0, float64(result), 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCosineSimilarity_OrthogonalVectors(t *testing.T) {
|
||||||
|
a := []float32{1.0, 0.0}
|
||||||
|
b := []float32{0.0, 1.0}
|
||||||
|
result := cosineSimilarity(a, b)
|
||||||
|
assert.InDelta(t, 0.0, float64(result), 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCosineSimilarity_ZeroVector_ReturnsZero(t *testing.T) {
|
||||||
|
a := []float32{0.0, 0.0}
|
||||||
|
b := []float32{1.0, 0.0}
|
||||||
|
assert.Equal(t, float32(0.0), cosineSimilarity(a, b))
|
||||||
|
assert.Equal(t, float32(0.0), cosineSimilarity(b, a))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCosineSimilarity_MismatchedLength_ReturnsZero(t *testing.T) {
|
||||||
|
a := []float32{1.0, 2.0}
|
||||||
|
b := []float32{1.0, 2.0, 3.0}
|
||||||
|
assert.Equal(t, float32(0.0), cosineSimilarity(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- edgeKey ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestEdgeKey_Symmetric(t *testing.T) {
|
||||||
|
// Must produce the same key regardless of order
|
||||||
|
assert.Equal(t, edgeKey(1, 2), edgeKey(2, 1))
|
||||||
|
assert.Equal(t, edgeKey(100, 5), edgeKey(5, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdgeKey_DifferentPairs_DifferentKeys(t *testing.T) {
|
||||||
|
assert.NotEqual(t, edgeKey(1, 2), edgeKey(1, 3))
|
||||||
|
assert.NotEqual(t, edgeKey(1, 2), edgeKey(2, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pruneEdges -------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestPruneEdges_BelowLimit_NoChange(t *testing.T) {
|
||||||
|
edges := []Edge{
|
||||||
|
{FromID: 1, ToID: 2, Weight: 0.9},
|
||||||
|
{FromID: 1, ToID: 3, Weight: 0.7},
|
||||||
|
}
|
||||||
|
pruned := pruneEdges(edges, 5)
|
||||||
|
assert.Len(t, pruned, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneEdges_ZeroLimit_ReturnsAll(t *testing.T) {
|
||||||
|
edges := []Edge{{FromID: 1, ToID: 2, Weight: 0.5}}
|
||||||
|
pruned := pruneEdges(edges, 0)
|
||||||
|
assert.Len(t, pruned, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneEdges_KeepsHighWeightEdges(t *testing.T) {
|
||||||
|
// Node 1 gets 4 edges, limit is 2 → only the 2 heaviest should survive
|
||||||
|
edges := []Edge{
|
||||||
|
{FromID: 1, ToID: 2, Weight: 0.9},
|
||||||
|
{FromID: 1, ToID: 3, Weight: 0.8},
|
||||||
|
{FromID: 1, ToID: 4, Weight: 0.3},
|
||||||
|
{FromID: 1, ToID: 5, Weight: 0.1},
|
||||||
|
}
|
||||||
|
pruned := pruneEdges(edges, 2)
|
||||||
|
|
||||||
|
weights := make([]float32, len(pruned))
|
||||||
|
for i, e := range pruned {
|
||||||
|
weights[i] = e.Weight
|
||||||
|
}
|
||||||
|
assert.Contains(t, weights, float32(0.9))
|
||||||
|
assert.Contains(t, weights, float32(0.8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sortEdgesByWeight ------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSortEdgesByWeight_DescendingOrder(t *testing.T) {
|
||||||
|
edges := []Edge{
|
||||||
|
{Weight: 0.3},
|
||||||
|
{Weight: 0.9},
|
||||||
|
{Weight: 0.1},
|
||||||
|
{Weight: 0.7},
|
||||||
|
}
|
||||||
|
sortEdgesByWeight(edges)
|
||||||
|
for i := 1; i < len(edges); i++ {
|
||||||
|
assert.GreaterOrEqual(t, edges[i-1].Weight, edges[i].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortEdgesByWeight_EmptySlice_NoPanic(t *testing.T) {
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
sortEdgesByWeight([]Edge{})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortEdgesByWeight_SingleElement_Unchanged(t *testing.T) {
|
||||||
|
edges := []Edge{{Weight: 0.5}}
|
||||||
|
sortEdgesByWeight(edges)
|
||||||
|
assert.Equal(t, float32(0.5), edges[0].Weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func filterByRelation(edges []Edge, rel RelationType) []Edge {
|
||||||
|
var out []Edge
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.Relation == rel {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -151,10 +151,11 @@ func (s *Service) runMaintenance(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Task 3: Optimize database
|
// Task 3: Optimize database
|
||||||
|
var optimized bool
|
||||||
if err := s.store.Optimize(ctx); err != nil {
|
if err := s.store.Optimize(ctx); err != nil {
|
||||||
s.log.Error().Err(err).Msg("Failed to optimize database")
|
s.log.Error().Err(err).Msg("Failed to optimize database")
|
||||||
} else {
|
} else {
|
||||||
s.totalOptimizeRun++
|
optimized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 4: Clean up old prompts (keep last 1000 per session)
|
// Task 4: Clean up old prompts (keep last 1000 per session)
|
||||||
@@ -170,6 +171,9 @@ func (s *Service) runMaintenance(ctx context.Context) {
|
|||||||
s.lastRunTime = time.Now()
|
s.lastRunTime = time.Now()
|
||||||
s.lastRunDuration = time.Since(start)
|
s.lastRunDuration = time.Since(start)
|
||||||
s.totalCleanedObs += totalCleaned
|
s.totalCleanedObs += totalCleaned
|
||||||
|
if optimized {
|
||||||
|
s.totalOptimizeRun++
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.log.Info().
|
s.log.Info().
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
//go:build fts5
|
||||||
|
|
||||||
|
// Package maintenance provides scheduled maintenance tasks for claude-mnemonic.
|
||||||
|
package maintenance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||||
|
gormdb "github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSetup creates a full maintenance service with a real temporary database.
|
||||||
|
func testSetup(t *testing.T, cfg *config.Config) (*Service, *gormdb.Store, *gormdb.ObservationStore, *gormdb.PromptStore, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "maintenance_test_*")
|
||||||
|
require.NoError(t, err, "create temp dir")
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
storeCfg := gormdb.Config{
|
||||||
|
Path: dbPath,
|
||||||
|
MaxConns: 4,
|
||||||
|
LogLevel: logger.Silent,
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := gormdb.NewStore(storeCfg)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
observationStore := gormdb.NewObservationStore(store, nil, nil, nil)
|
||||||
|
summaryStore := gormdb.NewSummaryStore(store)
|
||||||
|
promptStore := gormdb.NewPromptStore(store, nil)
|
||||||
|
|
||||||
|
svc := NewService(store, observationStore, summaryStore, promptStore, nil, cfg, zerolog.Nop())
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
store.Close()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc, store, observationStore, promptStore, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCfg returns a maintenance-enabled config for tests.
|
||||||
|
func defaultCfg() *config.Config {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.MaintenanceEnabled = true
|
||||||
|
cfg.MaintenanceIntervalHours = 1
|
||||||
|
cfg.ObservationRetentionDays = 0
|
||||||
|
cfg.CleanupStaleObservations = false
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertObservation is a helper that inserts an observation and returns its ID.
|
||||||
|
func insertObservation(t *testing.T, obsStore *gormdb.ObservationStore, session, project string, seq int) int64 {
|
||||||
|
t.Helper()
|
||||||
|
obs := &models.ParsedObservation{
|
||||||
|
Type: models.ObsTypeDiscovery,
|
||||||
|
Title: "test observation",
|
||||||
|
}
|
||||||
|
id, _, err := obsStore.StoreObservation(context.Background(), session, project, obs, seq, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NewService ----
|
||||||
|
|
||||||
|
func TestNewService_ReturnsNonNilService(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
assert.NotNil(t, svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewService_InitializesChannels(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// stopCh and doneCh must be non-nil so Stop/Wait don't panic.
|
||||||
|
assert.NotNil(t, svc.stopCh)
|
||||||
|
assert.NotNil(t, svc.doneCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stats ----
|
||||||
|
|
||||||
|
func TestStats_DefaultValues(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stats := svc.Stats()
|
||||||
|
|
||||||
|
assert.Equal(t, true, stats["enabled"])
|
||||||
|
assert.Equal(t, 1, stats["interval_hours"])
|
||||||
|
assert.Equal(t, 0, stats["retention_days"])
|
||||||
|
assert.Equal(t, false, stats["cleanup_stale"])
|
||||||
|
assert.Equal(t, int64(0), stats["total_cleaned_obs"])
|
||||||
|
assert.Equal(t, int64(0), stats["total_optimizes"])
|
||||||
|
assert.Equal(t, false, stats["running"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_ReflectsConfigFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *config.Config
|
||||||
|
wantEnabled bool
|
||||||
|
wantHours int
|
||||||
|
wantDays int
|
||||||
|
wantStale bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "maintenance disabled",
|
||||||
|
cfg: func() *config.Config {
|
||||||
|
c := defaultCfg()
|
||||||
|
c.MaintenanceEnabled = false
|
||||||
|
return c
|
||||||
|
}(),
|
||||||
|
wantEnabled: false,
|
||||||
|
wantHours: 1,
|
||||||
|
wantDays: 0,
|
||||||
|
wantStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "retention and stale cleanup enabled",
|
||||||
|
cfg: func() *config.Config {
|
||||||
|
c := defaultCfg()
|
||||||
|
c.ObservationRetentionDays = 30
|
||||||
|
c.CleanupStaleObservations = true
|
||||||
|
c.MaintenanceIntervalHours = 12
|
||||||
|
return c
|
||||||
|
}(),
|
||||||
|
wantEnabled: true,
|
||||||
|
wantHours: 12,
|
||||||
|
wantDays: 30,
|
||||||
|
wantStale: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, tt.cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stats := svc.Stats()
|
||||||
|
assert.Equal(t, tt.wantEnabled, stats["enabled"])
|
||||||
|
assert.Equal(t, tt.wantHours, stats["interval_hours"])
|
||||||
|
assert.Equal(t, tt.wantDays, stats["retention_days"])
|
||||||
|
assert.Equal(t, tt.wantStale, stats["cleanup_stale"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stop (idempotency) ----
|
||||||
|
|
||||||
|
func TestStop_WhenNotRunning_DoesNotPanic(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Service was never started — Stop must be a no-op.
|
||||||
|
assert.NotPanics(t, func() { svc.Stop() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop_CalledTwice_DoesNotPanic(t *testing.T) {
|
||||||
|
// Start with maintenance disabled so Start() returns immediately.
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.MaintenanceEnabled = false
|
||||||
|
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
go svc.Start(ctx)
|
||||||
|
svc.Wait() // drains doneCh after early return
|
||||||
|
|
||||||
|
// Stop after Wait — must not panic or double-close.
|
||||||
|
assert.NotPanics(t, func() { svc.Stop() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Start / running flag ----
|
||||||
|
|
||||||
|
func TestStart_MaintenanceDisabled_ExitsImmediately(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.MaintenanceEnabled = false
|
||||||
|
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
go svc.Start(ctx)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
svc.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Good — returned without blocking.
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Start() did not return promptly when maintenance is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := svc.Stats()
|
||||||
|
assert.Equal(t, false, stats["running"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_StopSignal_ExitsCleanly(t *testing.T) {
|
||||||
|
// Start() with maintenance disabled exits immediately — verified in
|
||||||
|
// TestStart_MaintenanceDisabled_ExitsImmediately.
|
||||||
|
//
|
||||||
|
// The ticker/stop path is hard to test because Start() always sleeps
|
||||||
|
// 5 minutes before entering the loop. We verify instead that Stop()
|
||||||
|
// on an already-stopped service is safe and that the doneCh is closed
|
||||||
|
// after exit (i.e., Wait() returns).
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.MaintenanceEnabled = false
|
||||||
|
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
go svc.Start(context.Background())
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
svc.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// doneCh was closed — Start exited and Wait returned.
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Wait() did not return after Start exited")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop after Wait must be a no-op and must not panic.
|
||||||
|
assert.NotPanics(t, func() { svc.Stop() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_DoubleStart_SecondCallIsNoOp(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.MaintenanceEnabled = false // exits immediately
|
||||||
|
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First call.
|
||||||
|
go svc.Start(ctx)
|
||||||
|
svc.Wait()
|
||||||
|
|
||||||
|
// Second call on the same (exhausted) svc should be a no-op and not panic.
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
// svc.running is now false again — but doneCh is already closed.
|
||||||
|
// A second Start would attempt to close doneCh again which would panic
|
||||||
|
// if the running guard is missing. Verify the guard works.
|
||||||
|
svc.mu.Lock()
|
||||||
|
running := svc.running
|
||||||
|
svc.mu.Unlock()
|
||||||
|
assert.False(t, running)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RunNow ----
|
||||||
|
|
||||||
|
func TestRunNow_UpdatesLastRunTime(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.ObservationRetentionDays = 0
|
||||||
|
cfg.CleanupStaleObservations = false
|
||||||
|
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
svc.RunNow(context.Background())
|
||||||
|
|
||||||
|
// Allow async goroutine to finish.
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
lastRun := svc.lastRunTime
|
||||||
|
svc.mu.Unlock()
|
||||||
|
|
||||||
|
assert.True(t, lastRun.After(before) || lastRun.Equal(before),
|
||||||
|
"lastRunTime should be updated after RunNow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_IncrementsOptimizeCounter(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc.RunNow(context.Background())
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
optimizes := svc.totalOptimizeRun
|
||||||
|
svc.mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1), optimizes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_StatsTotalOptimizesReflected(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc.RunNow(context.Background())
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
stats := svc.Stats()
|
||||||
|
assert.Equal(t, int64(1), stats["total_optimizes"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cleanupOldObservations (via RunNow) ----
|
||||||
|
|
||||||
|
func TestRunNow_RetentionDaysZero_NothingDeleted(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.ObservationRetentionDays = 0
|
||||||
|
|
||||||
|
svc, _, obsStore, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Insert observations.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertObservation(t, obsStore, "session-1", "proj", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.RunNow(context.Background())
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
remaining, err := obsStore.GetRecentObservations(context.Background(), "proj", 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 5, len(remaining), "nothing should be deleted when retention_days = 0")
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
cleaned := svc.totalCleanedObs
|
||||||
|
svc.mu.Unlock()
|
||||||
|
assert.Equal(t, int64(0), cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_RetentionDays_DeletesExpiredObservations(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.ObservationRetentionDays = 1 // keep only last 1 day
|
||||||
|
|
||||||
|
svc, store, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert an observation and back-date it to 2 days ago.
|
||||||
|
obs := &gormdb.Observation{
|
||||||
|
SDKSessionID: "old-session",
|
||||||
|
Project: "proj",
|
||||||
|
Type: models.ObsTypeDiscovery,
|
||||||
|
CreatedAt: "2000-01-01T00:00:00Z",
|
||||||
|
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||||
|
Scope: models.ScopeProject,
|
||||||
|
ImportanceScore: 1.0,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||||
|
|
||||||
|
// Insert a recent observation (should survive).
|
||||||
|
recentObs := &gormdb.Observation{
|
||||||
|
SDKSessionID: "new-session",
|
||||||
|
Project: "proj",
|
||||||
|
Type: models.ObsTypeDiscovery,
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
CreatedAtEpoch: time.Now().Unix(),
|
||||||
|
Scope: models.ScopeProject,
|
||||||
|
ImportanceScore: 1.0,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(recentObs).Error)
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Only the recent observation should remain.
|
||||||
|
var count int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Count(&count)
|
||||||
|
assert.Equal(t, int64(1), count, "expired observation should have been deleted")
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
cleaned := svc.totalCleanedObs
|
||||||
|
svc.mu.Unlock()
|
||||||
|
assert.Equal(t, int64(1), cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_RetentionDays_VectorCleanupCalled(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.ObservationRetentionDays = 1
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "maintenance_vec_test_*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
store, err := gormdb.NewStore(gormdb.Config{
|
||||||
|
Path: filepath.Join(tmpDir, "test.db"),
|
||||||
|
MaxConns: 4,
|
||||||
|
LogLevel: logger.Silent,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
observationStore := gormdb.NewObservationStore(store, nil, nil, nil)
|
||||||
|
summaryStore := gormdb.NewSummaryStore(store)
|
||||||
|
promptStore := gormdb.NewPromptStore(store, nil)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var capturedIDs []int64
|
||||||
|
|
||||||
|
vectorCleanupFn := func(_ context.Context, ids []int64) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
capturedIDs = append(capturedIDs, ids...)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(store, observationStore, summaryStore, promptStore, vectorCleanupFn, cfg, zerolog.Nop())
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert an expired observation directly.
|
||||||
|
obs := &gormdb.Observation{
|
||||||
|
SDKSessionID: "session-x",
|
||||||
|
Project: "proj",
|
||||||
|
Type: models.ObsTypeDiscovery,
|
||||||
|
CreatedAt: "2000-01-01T00:00:00Z",
|
||||||
|
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||||
|
Scope: models.ScopeProject,
|
||||||
|
ImportanceScore: 1.0,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
ids := capturedIDs
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
assert.NotEmpty(t, ids, "vector cleanup callback must be called with deleted IDs")
|
||||||
|
assert.Contains(t, ids, obs.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cleanupStaleObservations (via RunNow) ----
|
||||||
|
|
||||||
|
func TestRunNow_CleanupStale_DeletesSupersededObservations(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.CleanupStaleObservations = true
|
||||||
|
cfg.ObservationRetentionDays = 0
|
||||||
|
|
||||||
|
svc, store, obsStore, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert an active observation.
|
||||||
|
activeID := insertObservation(t, obsStore, "session-1", "proj", 1)
|
||||||
|
|
||||||
|
// Insert and mark a stale observation.
|
||||||
|
staleID := insertObservation(t, obsStore, "session-1", "proj", 2)
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).
|
||||||
|
Model(&gormdb.Observation{}).
|
||||||
|
Where("id = ?", staleID).
|
||||||
|
Update("is_superseded", 1).Error)
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Active observation must survive.
|
||||||
|
var activeCount int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("id = ?", activeID).Count(&activeCount)
|
||||||
|
assert.Equal(t, int64(1), activeCount, "active observation must not be deleted")
|
||||||
|
|
||||||
|
// Stale observation must be gone.
|
||||||
|
var staleCount int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("id = ?", staleID).Count(&staleCount)
|
||||||
|
assert.Equal(t, int64(0), staleCount, "stale observation must be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_CleanupStale_DisabledLeavesStaleObservations(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.CleanupStaleObservations = false
|
||||||
|
|
||||||
|
svc, store, obsStore, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
staleID := insertObservation(t, obsStore, "session-1", "proj", 1)
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).
|
||||||
|
Model(&gormdb.Observation{}).
|
||||||
|
Where("id = ?", staleID).
|
||||||
|
Update("is_superseded", 1).Error)
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("id = ?", staleID).Count(&count)
|
||||||
|
assert.Equal(t, int64(1), count, "stale observation must survive when cleanup_stale is false")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_CleanupStale_NoStaleRows_NothingChanged(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.CleanupStaleObservations = true
|
||||||
|
|
||||||
|
svc, _, obsStore, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Only active observations.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
insertObservation(t, obsStore, "session-1", "proj", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
remaining, err := obsStore.GetRecentObservations(ctx, "proj", 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, len(remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cleanupOldPrompts (via RunNow) ----
|
||||||
|
|
||||||
|
func TestRunNow_CleanupOldPrompts_DeletesExpiredPrompts(t *testing.T) {
|
||||||
|
svc, store, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert a prompt with an old epoch (31 days ago).
|
||||||
|
oldPrompt := &gormdb.UserPrompt{
|
||||||
|
ClaudeSessionID: "session-old",
|
||||||
|
PromptText: "old prompt",
|
||||||
|
PromptNumber: 1,
|
||||||
|
CreatedAt: "2000-01-01T00:00:00Z",
|
||||||
|
CreatedAtEpoch: time.Now().AddDate(0, 0, -31).Unix(),
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(oldPrompt).Error)
|
||||||
|
|
||||||
|
// Insert a recent prompt (should survive).
|
||||||
|
recentPrompt := &gormdb.UserPrompt{
|
||||||
|
ClaudeSessionID: "session-new",
|
||||||
|
PromptText: "recent prompt",
|
||||||
|
PromptNumber: 1,
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
CreatedAtEpoch: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(recentPrompt).Error)
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.UserPrompt{}).Count(&count)
|
||||||
|
assert.Equal(t, int64(1), count, "only the recent prompt should survive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_CleanupOldPrompts_NothingExpired_AllSurvive(t *testing.T) {
|
||||||
|
svc, store, _, promptStore, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
_, err := promptStore.SaveUserPromptWithMatches(ctx, "session-1", i, "prompt", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.UserPrompt{}).Count(&count)
|
||||||
|
assert.Equal(t, int64(5), count, "no prompts should be deleted when none are expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stats race safety ----
|
||||||
|
|
||||||
|
func TestStats_ConcurrentAccess_NoRace(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = svc.Stats()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RunNow concurrent safety ----
|
||||||
|
|
||||||
|
func TestRunNow_ConcurrentCalls_NoRace(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- lastRunDuration is populated ----
|
||||||
|
|
||||||
|
func TestRunNow_LastRunDuration_IsPopulated(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc.RunNow(context.Background())
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
dur := svc.lastRunDuration
|
||||||
|
svc.mu.Unlock()
|
||||||
|
|
||||||
|
assert.Greater(t, int64(dur), int64(0), "lastRunDuration should be set after a maintenance run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_LastDurationMs_IsPopulated(t *testing.T) {
|
||||||
|
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc.RunNow(context.Background())
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
stats := svc.Stats()
|
||||||
|
// The value is int64 milliseconds; it might be 0 for very fast runs — just verify the key exists.
|
||||||
|
_, ok := stats["last_duration_ms"]
|
||||||
|
assert.True(t, ok, "stats must contain last_duration_ms key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Batch deletion boundary ----
|
||||||
|
|
||||||
|
func TestRunNow_RetentionDays_BatchDeletion_MoreThan100Rows(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.ObservationRetentionDays = 1
|
||||||
|
|
||||||
|
svc, store, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert 150 expired observations (forces 2 batches of 100).
|
||||||
|
for i := 0; i < 150; i++ {
|
||||||
|
obs := &gormdb.Observation{
|
||||||
|
SDKSessionID: "session-old",
|
||||||
|
Project: "proj",
|
||||||
|
Type: models.ObsTypeDiscovery,
|
||||||
|
CreatedAt: "2000-01-01T00:00:00Z",
|
||||||
|
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||||
|
Scope: models.ScopeProject,
|
||||||
|
ImportanceScore: 1.0,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
var remaining int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Count(&remaining)
|
||||||
|
assert.Equal(t, int64(0), remaining, "all 150 expired observations should be deleted in batches")
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
cleaned := svc.totalCleanedObs
|
||||||
|
svc.mu.Unlock()
|
||||||
|
assert.Equal(t, int64(150), cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNow_CleanupStale_BatchDeletion_MoreThan100Rows(t *testing.T) {
|
||||||
|
cfg := defaultCfg()
|
||||||
|
cfg.CleanupStaleObservations = true
|
||||||
|
|
||||||
|
svc, store, _, _, cleanup := testSetup(t, cfg)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert 120 superseded observations.
|
||||||
|
for i := 0; i < 120; i++ {
|
||||||
|
obs := &gormdb.Observation{
|
||||||
|
SDKSessionID: "session-stale",
|
||||||
|
Project: "proj",
|
||||||
|
Type: models.ObsTypeDiscovery,
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
CreatedAtEpoch: time.Now().Unix(),
|
||||||
|
Scope: models.ScopeProject,
|
||||||
|
ImportanceScore: 1.0,
|
||||||
|
IsSuperseded: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.RunNow(ctx)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
var remaining int64
|
||||||
|
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("is_superseded = ?", 1).Count(&remaining)
|
||||||
|
assert.Equal(t, int64(0), remaining, "all 120 stale observations should be deleted in batches")
|
||||||
|
}
|
||||||
+75
-18
@@ -129,13 +129,22 @@ func (s *Server) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Semaphore limits concurrent request goroutines.
|
||||||
|
const maxConcurrent = 10
|
||||||
|
sem := make(chan struct{}, maxConcurrent)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
// Drain in-flight requests before returning.
|
||||||
|
wg.Wait()
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case line, ok := <-lines:
|
case line, ok := <-lines:
|
||||||
if !ok {
|
if !ok {
|
||||||
// Scanner finished, check for errors
|
// Scanner finished — drain in-flight requests, then check for errors.
|
||||||
|
wg.Wait()
|
||||||
err := <-scanErr
|
err := <-scanErr
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, bufio.ErrTooLong) {
|
if errors.Is(err, bufio.ErrTooLong) {
|
||||||
@@ -154,18 +163,38 @@ func (s *Server) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
var req Request
|
var req Request
|
||||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||||
|
// Parse errors are cheap — send inline, no goroutine needed.
|
||||||
if werr := s.sendError(nil, -32700, "Parse error", err.Error()); werr != nil {
|
if werr := s.sendError(nil, -32700, "Parse error", err.Error()); werr != nil {
|
||||||
return fmt.Errorf("write error: %w", werr)
|
return fmt.Errorf("write error: %w", werr)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := s.handleRequest(ctx, &req)
|
// Dispatch request to its own goroutine.
|
||||||
if resp != nil {
|
// Semaphore is acquired inside the goroutine so the main
|
||||||
if werr := s.sendResponse(resp); werr != nil {
|
// loop never blocks on a full semaphore (Fix #1).
|
||||||
return fmt.Errorf("write error: %w", werr)
|
wg.Add(1)
|
||||||
|
go func(r Request) {
|
||||||
|
defer wg.Done()
|
||||||
|
// Acquire semaphore inside goroutine, not blocking main loop
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Server shutting down — send error so caller isn't left waiting
|
||||||
|
if r.ID != nil {
|
||||||
|
_ = s.sendError(r.ID, -32000, "Server shutting down", nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
resp := s.handleRequest(ctx, &r)
|
||||||
|
if resp != nil {
|
||||||
|
if werr := s.sendResponse(resp); werr != nil {
|
||||||
|
log.Error().Err(werr).Msg("Failed to send response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +214,10 @@ func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
|
|||||||
case "notifications/initialized", "notifications/cancelled":
|
case "notifications/initialized", "notifications/cancelled":
|
||||||
return nil // Notifications don't get responses
|
return nil // Notifications don't get responses
|
||||||
default:
|
default:
|
||||||
|
if req.ID == nil {
|
||||||
|
// Notifications must not receive responses per JSON-RPC 2.0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return &Response{
|
return &Response{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
@@ -757,13 +790,15 @@ func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
|
|||||||
|
|
||||||
result, err := s.callTool(ctx, params.Name, params.Arguments)
|
result, err := s.callTool(ctx, params.Name, params.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// MCP spec: tool failures use Result with isError, not top-level Error
|
||||||
return &Response{
|
return &Response{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
Error: &Error{
|
Result: map[string]any{
|
||||||
Code: -32000,
|
"content": []map[string]any{
|
||||||
Message: "Tool error",
|
{"type": "text", "text": "Error: " + err.Error()},
|
||||||
Data: err.Error(),
|
},
|
||||||
|
"isError": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1884,16 +1919,38 @@ func (s *Server) sendResponse(resp *Response) error {
|
|||||||
data, err := json.Marshal(resp)
|
data, err := json.Marshal(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to marshal response")
|
log.Error().Err(err).Msg("Failed to marshal response")
|
||||||
|
// Send a fallback error so the caller isn't left waiting (Fix #3)
|
||||||
|
fallback, _ := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": resp.ID,
|
||||||
|
"error": map[string]any{"code": -32603, "message": "internal marshal error"},
|
||||||
|
})
|
||||||
|
s.writeMu.Lock()
|
||||||
|
_, _ = fmt.Fprintln(s.stdout, string(fallback))
|
||||||
|
s.writeMu.Unlock()
|
||||||
|
return fmt.Errorf("marshal error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound the write to prevent pipe deadlock (Fix #2)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
s.writeMu.Lock()
|
||||||
|
_, werr := fmt.Fprintln(s.stdout, string(data))
|
||||||
|
s.writeMu.Unlock()
|
||||||
|
done <- werr
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case werr := <-done:
|
||||||
|
if werr != nil {
|
||||||
|
log.Error().Err(werr).Msg("Failed to write response to stdout")
|
||||||
|
return werr
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
log.Error().Msg("Stdout write timed out — pipe likely full")
|
||||||
|
return fmt.Errorf("write timeout: stdout pipe full")
|
||||||
}
|
}
|
||||||
s.writeMu.Lock()
|
|
||||||
_, err = fmt.Fprintln(s.stdout, string(data))
|
|
||||||
s.writeMu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to write response to stdout")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendError sends a JSON-RPC error response. Returns an error if writing fails.
|
// sendError sends a JSON-RPC error response. Returns an error if writing fails.
|
||||||
|
|||||||
+629
-9
@@ -2,11 +2,17 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -1659,6 +1665,7 @@ func TestToolListContainsExpectedSchemas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleToolsCall_UnknownTool tests tools/call with unknown tool name.
|
// TestHandleToolsCall_UnknownTool tests tools/call with unknown tool name.
|
||||||
|
// After Fix #5: tool errors use Result with isError, not top-level Error.
|
||||||
func TestHandleToolsCall_UnknownTool(t *testing.T) {
|
func TestHandleToolsCall_UnknownTool(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1673,9 +1680,13 @@ func TestHandleToolsCall_UnknownTool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := server.handleToolsCall(ctx, req)
|
resp := server.handleToolsCall(ctx, req)
|
||||||
require.NotNil(t, resp.Error)
|
assert.Nil(t, resp.Error, "tool errors must not use top-level Error (MCP spec)")
|
||||||
assert.Equal(t, -32000, resp.Error.Code)
|
require.NotNil(t, resp.Result)
|
||||||
assert.Contains(t, resp.Error.Data, "unknown tool")
|
result, ok := resp.Result.(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, true, result["isError"])
|
||||||
|
content := result["content"].([]map[string]any)
|
||||||
|
assert.Contains(t, content[0]["text"], "unknown tool")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCallTool_ToolNameRecognition tests that valid tool names are recognized.
|
// TestCallTool_ToolNameRecognition tests that valid tool names are recognized.
|
||||||
@@ -1976,6 +1987,7 @@ func TestTimelineParamsStruct_Validation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleToolsCall_EmptyParams tests tools/call with empty params.
|
// TestHandleToolsCall_EmptyParams tests tools/call with empty params.
|
||||||
|
// After Fix #5: tool errors use Result with isError, not top-level Error.
|
||||||
func TestHandleToolsCall_EmptyParams(t *testing.T) {
|
func TestHandleToolsCall_EmptyParams(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1991,8 +2003,12 @@ func TestHandleToolsCall_EmptyParams(t *testing.T) {
|
|||||||
|
|
||||||
resp := server.handleToolsCall(ctx, req)
|
resp := server.handleToolsCall(ctx, req)
|
||||||
|
|
||||||
// Should error due to missing name
|
// Empty name goes through callTool default branch -> isError
|
||||||
require.NotNil(t, resp.Error)
|
assert.Nil(t, resp.Error, "tool errors must use isError in Result")
|
||||||
|
require.NotNil(t, resp.Result)
|
||||||
|
result, ok := resp.Result.(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, true, result["isError"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSendResponse_WithError tests sendResponse with an error response.
|
// TestSendResponse_WithError tests sendResponse with an error response.
|
||||||
@@ -2056,6 +2072,7 @@ func TestToolCallParamsWithComplexArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleToolsCall_UnknownToolNameError tests tools/call with unknown tool returns error.
|
// TestHandleToolsCall_UnknownToolNameError tests tools/call with unknown tool returns error.
|
||||||
|
// After Fix #5: tool errors use Result with isError, not top-level Error.
|
||||||
func TestHandleToolsCall_UnknownToolNameError(t *testing.T) {
|
func TestHandleToolsCall_UnknownToolNameError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2071,12 +2088,13 @@ func TestHandleToolsCall_UnknownToolNameError(t *testing.T) {
|
|||||||
|
|
||||||
resp := server.handleToolsCall(ctx, req)
|
resp := server.handleToolsCall(ctx, req)
|
||||||
|
|
||||||
// Should get an error response
|
|
||||||
assert.Equal(t, "2.0", resp.JSONRPC)
|
assert.Equal(t, "2.0", resp.JSONRPC)
|
||||||
assert.Equal(t, 1, resp.ID)
|
assert.Equal(t, 1, resp.ID)
|
||||||
require.NotNil(t, resp.Error)
|
assert.Nil(t, resp.Error, "tool errors must use isError in Result, not top-level Error")
|
||||||
// Error is "Tool error" with message containing "unknown tool"
|
require.NotNil(t, resp.Result)
|
||||||
assert.True(t, resp.Error.Code != 0)
|
result, ok := resp.Result.(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, true, result["isError"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2798,3 +2816,605 @@ func TestHandleMergeObservations_WorkerUnavailable(t *testing.T) {
|
|||||||
_, err := server.handleMergeProxy(ctx, json.RawMessage(`{"source_id": 1, "target_id": 2}`))
|
_, err := server.handleMergeProxy(ctx, json.RawMessage(`{"source_id": 1, "target_id": 2}`))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS — Fix #45: Concurrent request dispatching
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// collectResponses reads newline-delimited JSON responses from r until it has
|
||||||
|
// collected n responses or the context is done. Returns collected responses.
|
||||||
|
func collectResponses(t *testing.T, r io.Reader, n int) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
results := make([]map[string]any, 0, n)
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &resp); err != nil {
|
||||||
|
t.Logf("collectResponses: bad JSON line: %s", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, resp)
|
||||||
|
if len(results) >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRequests writes newline-delimited JSON requests to w and closes it when done.
|
||||||
|
func writeRequests(t *testing.T, w io.WriteCloser, reqs []string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range reqs {
|
||||||
|
_, err := io.WriteString(w, r+"\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_ = w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_ConcurrentRequests verifies multiple requests are processed concurrently
|
||||||
|
// and not serially. If they ran serially, 5 × 100ms = 500ms. Concurrent should be
|
||||||
|
// well under 400ms on any reasonable machine.
|
||||||
|
func TestRun_ConcurrentRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const delay = 100 * time.Millisecond
|
||||||
|
const numRequests = 5
|
||||||
|
|
||||||
|
// Mock worker: every request sleeps delay then returns "{}"
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(delay)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
stdinR, stdinW := io.Pipe()
|
||||||
|
stdoutR, stdoutW := io.Pipe()
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
client: ts.Client(),
|
||||||
|
workerURL: ts.URL,
|
||||||
|
project: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
stdin: stdinR,
|
||||||
|
stdout: stdoutW,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build requests — use get_memory_stats which goes to GET /api/stats
|
||||||
|
reqs := make([]string, numRequests)
|
||||||
|
for i := range reqs {
|
||||||
|
req := Request{JSONRPC: "2.0", ID: i + 1, Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"get_memory_stats","arguments":{}}`)}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reqs[i] = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect responses in background
|
||||||
|
var responses []map[string]any
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
responses = collectResponses(t, stdoutR, numRequests)
|
||||||
|
_ = stdoutR.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Write all requests then close stdin (triggers Run to drain and return)
|
||||||
|
go writeRequests(t, stdinW, reqs)
|
||||||
|
|
||||||
|
err := server.Run(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
_ = stdoutW.Close()
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// All responses received
|
||||||
|
assert.Len(t, responses, numRequests, "expected %d responses", numRequests)
|
||||||
|
|
||||||
|
// Concurrent execution: should be much less than numRequests × delay
|
||||||
|
serialTime := time.Duration(numRequests) * delay
|
||||||
|
assert.Less(t, elapsed, serialTime*4/5,
|
||||||
|
"elapsed %v not significantly less than serial %v — requests may be sequential", elapsed, serialTime)
|
||||||
|
|
||||||
|
// Each response has correct jsonrpc field
|
||||||
|
for _, resp := range responses {
|
||||||
|
assert.Equal(t, "2.0", resp["jsonrpc"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_SlowRequestDoesNotBlockOthers is the core regression for #45.
|
||||||
|
// A slow search request must not block a fast stats request from being answered first.
|
||||||
|
func TestRun_SlowRequestDoesNotBlockOthers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const slowDelay = 300 * time.Millisecond
|
||||||
|
|
||||||
|
// responseOrder records which request IDs responded, in arrival order
|
||||||
|
var mu sync.Mutex
|
||||||
|
var responseOrder []any
|
||||||
|
|
||||||
|
// Mock worker: /api/context/search is slow, everything else is fast
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/context/search") {
|
||||||
|
time.Sleep(slowDelay)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
stdinR, stdinW := io.Pipe()
|
||||||
|
stdoutR, stdoutW := io.Pipe()
|
||||||
|
|
||||||
|
// Intercept stdout to record response order before passing through
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(pr)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &resp); err == nil {
|
||||||
|
mu.Lock()
|
||||||
|
responseOrder = append(responseOrder, resp["id"])
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(stdoutW, line+"\n")
|
||||||
|
}
|
||||||
|
_ = stdoutW.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
client: ts.Client(),
|
||||||
|
workerURL: ts.URL,
|
||||||
|
project: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
stdin: stdinR,
|
||||||
|
stdout: pw,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request 1: slow search (id=1)
|
||||||
|
slowReq := Request{JSONRPC: "2.0", ID: 1, Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"search","arguments":{"query":"anything"}}`)}
|
||||||
|
slowData, err := json.Marshal(slowReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Request 2: fast stats (id=2)
|
||||||
|
fastReq := Request{JSONRPC: "2.0", ID: 2, Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"get_memory_stats","arguments":{}}`)}
|
||||||
|
fastData, err := json.Marshal(fastReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Collect 2 responses
|
||||||
|
var responses []map[string]any
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
responses = collectResponses(t, stdoutR, 2)
|
||||||
|
_ = stdoutR.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write slow then fast, then close
|
||||||
|
go func() {
|
||||||
|
_, _ = io.WriteString(stdinW, string(slowData)+"\n")
|
||||||
|
// Small pause to ensure slow request goroutine is dispatched first
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
_, _ = io.WriteString(stdinW, string(fastData)+"\n")
|
||||||
|
_ = stdinW.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
runErr := server.Run(context.Background())
|
||||||
|
require.NoError(t, runErr)
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
require.Len(t, responses, 2, "expected 2 responses")
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
order := responseOrder
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
require.Len(t, order, 2, "expected 2 recorded response IDs")
|
||||||
|
|
||||||
|
// The fast request (id=2) must arrive before the slow one (id=1)
|
||||||
|
assert.Equal(t, float64(2), order[0],
|
||||||
|
"fast request (id=2) should respond before slow request (id=1); got order %v", order)
|
||||||
|
assert.Equal(t, float64(1), order[1],
|
||||||
|
"slow request (id=1) should respond second; got order %v", order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_SemaphoreLimitsConcurrency verifies the semaphore cap (10) does not deadlock
|
||||||
|
// when more than 10 requests are sent and all eventually complete.
|
||||||
|
func TestRun_SemaphoreLimitsConcurrency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const blockDelay = 200 * time.Millisecond
|
||||||
|
const numRequests = 15 // exceeds semaphore cap of 10
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(blockDelay)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
stdinR, stdinW := io.Pipe()
|
||||||
|
stdoutR, stdoutW := io.Pipe()
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
client: ts.Client(),
|
||||||
|
workerURL: ts.URL,
|
||||||
|
project: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
stdin: stdinR,
|
||||||
|
stdout: stdoutW,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqs := make([]string, numRequests)
|
||||||
|
for i := range reqs {
|
||||||
|
req := Request{JSONRPC: "2.0", ID: i + 1, Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"get_memory_stats","arguments":{}}`)}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reqs[i] = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []map[string]any
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
responses = collectResponses(t, stdoutR, numRequests)
|
||||||
|
_ = stdoutR.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
go writeRequests(t, stdinW, reqs)
|
||||||
|
|
||||||
|
err := server.Run(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
_ = stdoutW.Close()
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// All 15 responses received — no deadlock
|
||||||
|
assert.Len(t, responses, numRequests, "all %d requests must complete", numRequests)
|
||||||
|
|
||||||
|
// With semaphore=10 and 15 requests at 200ms each, we need at least 2 batches.
|
||||||
|
// Should complete in ~2×blockDelay not 15×blockDelay.
|
||||||
|
// Upper bound: 3×blockDelay gives comfortable headroom for scheduling.
|
||||||
|
upperBound := 3 * blockDelay * 2 // generous: 3 batches + 2× overhead factor
|
||||||
|
assert.Less(t, elapsed, upperBound,
|
||||||
|
"elapsed %v suggests sequential processing (15×%v = %v)", elapsed, blockDelay, time.Duration(numRequests)*blockDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_GracefulDrainOnCancel verifies that cancelling the context causes Run to
|
||||||
|
// drain in-flight requests (wg.Wait) before returning ctx.Canceled.
|
||||||
|
func TestRun_GracefulDrainOnCancel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const reqDelay = 200 * time.Millisecond
|
||||||
|
const numRequests = 3
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Use a fixed sleep; the request-level context will be cancelled but the
|
||||||
|
// HTTP handler runs to completion independently (server-side).
|
||||||
|
time.Sleep(reqDelay)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
stdinR, stdinW := io.Pipe()
|
||||||
|
stdoutR, stdoutW := io.Pipe()
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
client: ts.Client(),
|
||||||
|
workerURL: ts.URL,
|
||||||
|
project: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
stdin: stdinR,
|
||||||
|
stdout: stdoutW,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Build requests
|
||||||
|
reqs := make([]string, numRequests)
|
||||||
|
for i := range reqs {
|
||||||
|
req := Request{JSONRPC: "2.0", ID: i + 1, Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"get_memory_stats","arguments":{}}`)}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reqs[i] = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write all requests before cancelling so they're all dispatched as goroutines
|
||||||
|
go func() {
|
||||||
|
for _, r := range reqs {
|
||||||
|
_, _ = io.WriteString(stdinW, r+"\n")
|
||||||
|
}
|
||||||
|
// Cancel after requests are dispatched but while they're still in-flight
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
// Leave stdin open — Run should return from the ctx.Done branch
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Drain responses in background; we don't know exactly how many will complete
|
||||||
|
// because goroutines may get context cancelled on their HTTP calls too.
|
||||||
|
var responseMu sync.Mutex
|
||||||
|
var collectedResponses []map[string]any
|
||||||
|
var collectWg sync.WaitGroup
|
||||||
|
collectWg.Go(func() {
|
||||||
|
scanner := bufio.NewScanner(stdoutR)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &resp); err == nil {
|
||||||
|
responseMu.Lock()
|
||||||
|
collectedResponses = append(collectedResponses, resp)
|
||||||
|
responseMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
runErr := server.Run(ctx)
|
||||||
|
_ = stdoutW.Close()
|
||||||
|
_ = stdinW.Close()
|
||||||
|
|
||||||
|
collectWg.Wait()
|
||||||
|
|
||||||
|
// Core assertion: Run returned the context cancellation error
|
||||||
|
assert.ErrorIs(t, runErr, context.Canceled,
|
||||||
|
"Run must return context.Canceled when context is cancelled")
|
||||||
|
|
||||||
|
// Run returned only after wg.Wait() drained goroutines.
|
||||||
|
// The goroutines may have returned errors (ctx cancelled HTTP calls) but
|
||||||
|
// the key invariant is Run itself did not panic and returned cleanly.
|
||||||
|
// Any responses that did complete should be valid JSON-RPC.
|
||||||
|
responseMu.Lock()
|
||||||
|
defer responseMu.Unlock()
|
||||||
|
for _, resp := range collectedResponses {
|
||||||
|
assert.Equal(t, "2.0", resp["jsonrpc"], "any completed response must be valid JSON-RPC 2.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS — Fix #1-#5
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// blockingWriter is an io.Writer that blocks forever on Write.
|
||||||
|
type blockingWriter struct {
|
||||||
|
blocked chan struct{} // closed when Write is entered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bw *blockingWriter) Write(p []byte) (int, error) {
|
||||||
|
if bw.blocked != nil {
|
||||||
|
close(bw.blocked)
|
||||||
|
}
|
||||||
|
select {} // block forever
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_SemaphoreDoesNotBlockMainLoop (Fix #1 regression) fills all semaphore
|
||||||
|
// slots with blocked requests, then sends a notification. The main loop must
|
||||||
|
// stay responsive and not hang on semaphore acquisition.
|
||||||
|
func TestRun_SemaphoreDoesNotBlockMainLoop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const maxConcurrent = 10
|
||||||
|
|
||||||
|
// Mock worker that blocks until test context is cancelled
|
||||||
|
handlerCtx, handlerCancel := context.WithCancel(context.Background())
|
||||||
|
defer handlerCancel()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-handlerCtx.Done() // block until test cleanup
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer func() {
|
||||||
|
handlerCancel() // unblock all handlers first
|
||||||
|
ts.CloseClientConnections()
|
||||||
|
ts.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
stdinR, stdinW := io.Pipe()
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
client: ts.Client(),
|
||||||
|
workerURL: ts.URL,
|
||||||
|
project: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
stdin: stdinR,
|
||||||
|
stdout: &stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
runDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
runDone <- server.Run(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Send maxConcurrent+2 requests to fill all semaphore slots
|
||||||
|
for i := 0; i < maxConcurrent+2; i++ {
|
||||||
|
req := Request{JSONRPC: "2.0", ID: i + 1, Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"get_memory_stats","arguments":{}}`)}
|
||||||
|
data, _ := json.Marshal(req)
|
||||||
|
_, err := io.WriteString(stdinW, string(data)+"\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give goroutines time to start and fill semaphore
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Now send a notification — this must not hang the main loop
|
||||||
|
notifSent := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
notif := `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n"
|
||||||
|
_, _ = io.WriteString(stdinW, notif)
|
||||||
|
close(notifSent)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-notifSent:
|
||||||
|
// Main loop accepted the notification write (stdin is a pipe, so
|
||||||
|
// the write completing means the reader consumed it)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("main loop blocked — semaphore acquisition is blocking the main goroutine")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up: cancel server context, close stdin
|
||||||
|
cancel()
|
||||||
|
_ = stdinW.Close()
|
||||||
|
|
||||||
|
// Wait for Run to finish
|
||||||
|
select {
|
||||||
|
case <-runDone:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// Acceptable — some goroutines may still be draining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendResponse_WriteTimeout (Fix #2 regression) verifies that sendResponse
|
||||||
|
// returns an error within a bounded time when the writer blocks forever.
|
||||||
|
func TestSendResponse_WriteTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
bw := &blockingWriter{blocked: make(chan struct{})}
|
||||||
|
server := &Server{stdout: bw}
|
||||||
|
|
||||||
|
resp := &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: 1,
|
||||||
|
Result: "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- server.sendResponse(resp)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.Error(t, err, "sendResponse must return error on write timeout")
|
||||||
|
assert.Contains(t, err.Error(), "write timeout")
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("sendResponse hung forever — write timeout not working")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendResponse_MarshalError (Fix #3 regression) verifies that when
|
||||||
|
// json.Marshal fails, sendResponse sends a fallback error response and
|
||||||
|
// returns an error (instead of silently returning nil).
|
||||||
|
func TestSendResponse_MarshalError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
server := &Server{stdout: &buf}
|
||||||
|
|
||||||
|
// Channels are not JSON-serializable — this will cause json.Marshal to fail
|
||||||
|
resp := &Response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: 42,
|
||||||
|
Result: make(chan int), // unserializable
|
||||||
|
}
|
||||||
|
|
||||||
|
err := server.sendResponse(resp)
|
||||||
|
|
||||||
|
// (a) Must return an error
|
||||||
|
require.Error(t, err, "sendResponse must return error when marshal fails")
|
||||||
|
assert.Contains(t, err.Error(), "marshal error")
|
||||||
|
|
||||||
|
// (b) Must have written a fallback JSON-RPC error to stdout
|
||||||
|
output := buf.String()
|
||||||
|
require.NotEmpty(t, output, "fallback response must be written to stdout")
|
||||||
|
|
||||||
|
var fallback map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(output)), &fallback),
|
||||||
|
"fallback must be valid JSON")
|
||||||
|
assert.Equal(t, "2.0", fallback["jsonrpc"])
|
||||||
|
assert.Equal(t, float64(42), fallback["id"], "fallback must preserve original request ID")
|
||||||
|
|
||||||
|
errObj, ok := fallback["error"].(map[string]any)
|
||||||
|
require.True(t, ok, "fallback must contain error object")
|
||||||
|
assert.Equal(t, float64(-32603), errObj["code"])
|
||||||
|
assert.Equal(t, "internal marshal error", errObj["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleRequest_UnknownNotification (Fix #4 regression) verifies that
|
||||||
|
// unknown notification methods (ID == nil) get no response, while unknown
|
||||||
|
// methods with an ID still get a -32601 error.
|
||||||
|
func TestHandleRequest_UnknownNotification(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server := NewServer(nil, "", "", "1.0.0")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Case 1: Unknown notification (no ID) — must return nil
|
||||||
|
notifReq := &Request{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: nil,
|
||||||
|
Method: "notifications/roots/list_changed",
|
||||||
|
}
|
||||||
|
resp := server.handleRequest(ctx, notifReq)
|
||||||
|
assert.Nil(t, resp, "unknown notification must not produce a response")
|
||||||
|
|
||||||
|
// Case 2: Unknown method WITH an ID — must return error response
|
||||||
|
methodReq := &Request{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: 99,
|
||||||
|
Method: "some/unknown/method",
|
||||||
|
}
|
||||||
|
resp = server.handleRequest(ctx, methodReq)
|
||||||
|
require.NotNil(t, resp, "unknown method with ID must produce an error response")
|
||||||
|
require.NotNil(t, resp.Error)
|
||||||
|
assert.Equal(t, -32601, resp.Error.Code)
|
||||||
|
assert.Equal(t, "Method not found", resp.Error.Message)
|
||||||
|
assert.Equal(t, 99, resp.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleToolsCall_ErrorUsesIsError (Fix #5 regression) verifies that when
|
||||||
|
// callTool returns an error, the response uses Result with isError:true instead
|
||||||
|
// of top-level Error field (per MCP spec).
|
||||||
|
func TestHandleToolsCall_ErrorUsesIsError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server := NewServer(nil, "", "", "1.0.0")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: 7,
|
||||||
|
Method: "tools/call",
|
||||||
|
Params: json.RawMessage(`{"name":"nonexistent_tool","arguments":{}}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := server.handleToolsCall(ctx, req)
|
||||||
|
|
||||||
|
// (a) Response must NOT have top-level Error
|
||||||
|
assert.Nil(t, resp.Error, "tool errors must not use top-level JSON-RPC Error")
|
||||||
|
|
||||||
|
// (b) Response must have Result with isError: true
|
||||||
|
require.NotNil(t, resp.Result, "tool error response must have Result")
|
||||||
|
result, ok := resp.Result.(map[string]any)
|
||||||
|
require.True(t, ok, "Result must be a map")
|
||||||
|
assert.Equal(t, true, result["isError"], "Result must contain isError: true")
|
||||||
|
|
||||||
|
// (c) Result.content[0].text must contain the error message
|
||||||
|
content, ok := result["content"].([]map[string]any)
|
||||||
|
require.True(t, ok, "Result.content must be []map[string]any")
|
||||||
|
require.Len(t, content, 1)
|
||||||
|
assert.Equal(t, "text", content[0]["type"])
|
||||||
|
errText, ok := content[0]["text"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, errText, "unknown tool: nonexistent_tool")
|
||||||
|
}
|
||||||
|
|||||||
+33
-15
@@ -46,7 +46,7 @@ const (
|
|||||||
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
|
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
|
||||||
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
|
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
|
||||||
cacheCleanupInterval = time.Minute // Cleanup expired cache every minute
|
cacheCleanupInterval = time.Minute // Cleanup expired cache every minute
|
||||||
warmingQueryTimeout = 5 * time.Second // Timeout for warming queries
|
warmingQueryTimeout = 2 * time.Second // Timeout for warming queries (kept short to minimize mutex hold)
|
||||||
warmingBatchSize = 5 // Warm top 5 queries per cycle
|
warmingBatchSize = 5 // Warm top 5 queries per cycle
|
||||||
minRecencyFactor = 0.1 // Minimum recency factor for scoring
|
minRecencyFactor = 0.1 // Minimum recency factor for scoring
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ type Manager struct {
|
|||||||
queryFrequency map[string]*queryFrequencyInfo
|
queryFrequency map[string]*queryFrequencyInfo
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
cacheMaxSize int
|
cacheMaxSize int
|
||||||
|
activeQueries atomic.Int32 // tracks in-flight user queries to yield embedding mutex
|
||||||
resultCacheMu sync.RWMutex
|
resultCacheMu sync.RWMutex
|
||||||
queryFrequencyMu sync.RWMutex
|
queryFrequencyMu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -256,7 +257,14 @@ func (m *Manager) cleanupStaleFrequencyEntries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// warmFrequentQueries pre-executes frequently used queries to warm the cache.
|
// warmFrequentQueries pre-executes frequently used queries to warm the cache.
|
||||||
|
// Skips warming entirely when user queries are in-flight to avoid competing
|
||||||
|
// for the embedding model mutex on throttled hardware.
|
||||||
func (m *Manager) warmFrequentQueries() {
|
func (m *Manager) warmFrequentQueries() {
|
||||||
|
if m.activeQueries.Load() > 0 {
|
||||||
|
log.Debug().Msg("Skipping cache warming: user queries in flight")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.queryFrequencyMu.RLock()
|
m.queryFrequencyMu.RLock()
|
||||||
// Find top N most frequent queries that aren't recently cached
|
// Find top N most frequent queries that aren't recently cached
|
||||||
type queryScore struct {
|
type queryScore struct {
|
||||||
@@ -687,30 +695,40 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif
|
|||||||
params.OrderBy = defaultOrderBy
|
params.OrderBy = defaultOrderBy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track active user queries so cache warming can yield the embedding mutex
|
||||||
|
m.activeQueries.Add(1)
|
||||||
|
defer m.activeQueries.Add(-1)
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
cacheKey := m.getCacheKey(params)
|
cacheKey := m.getCacheKey(params)
|
||||||
if cached, ok := m.getFromCache(cacheKey); ok {
|
if cached, ok := m.getFromCache(cacheKey); ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use singleflight to coalesce concurrent identical requests
|
// Use singleflight DoChan to coalesce concurrent identical requests.
|
||||||
result, err, _ := m.searchGroup.Do(cacheKey, func() (any, error) {
|
// DoChan + select allows per-caller context cancellation: waiting callers
|
||||||
|
// can bail out when their context expires without blocking on a slow first call.
|
||||||
|
ch := m.searchGroup.DoChan(cacheKey, func() (any, error) {
|
||||||
return m.executeSearch(ctx, params)
|
return m.executeSearch(ctx, params)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
select {
|
||||||
return nil, err
|
case res := <-ch:
|
||||||
|
if res.Err != nil {
|
||||||
|
return nil, res.Err
|
||||||
|
}
|
||||||
|
searchResult := res.Val.(*UnifiedSearchResult)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
m.putInCache(cacheKey, searchResult)
|
||||||
|
|
||||||
|
// Track query frequency for cache warming
|
||||||
|
m.trackQueryFrequency(params)
|
||||||
|
|
||||||
|
return searchResult, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
searchResult := result.(*UnifiedSearchResult)
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
m.putInCache(cacheKey, searchResult)
|
|
||||||
|
|
||||||
// Track query frequency for cache warming
|
|
||||||
m.trackQueryFrequency(params)
|
|
||||||
|
|
||||||
return searchResult, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeSearch performs the actual search without caching/coalescing.
|
// executeSearch performs the actual search without caching/coalescing.
|
||||||
|
|||||||
@@ -1009,6 +1009,91 @@ func TestSearchParams_FormatValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR Fix #3: Cache warming skipped during active queries
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestCacheWarming_SkippedDuringActiveQueries verifies that warmFrequentQueries
|
||||||
|
// returns immediately without doing work when activeQueries > 0, and actually
|
||||||
|
// warms when activeQueries == 0.
|
||||||
|
func TestCacheWarming_SkippedDuringActiveQueries(t *testing.T) {
|
||||||
|
m := NewManager(nil, nil, nil, nil)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
// Seed a frequent query so warming has something to consider
|
||||||
|
params := SearchParams{
|
||||||
|
Query: "test warming query",
|
||||||
|
Project: "test-project",
|
||||||
|
Limit: 10,
|
||||||
|
}
|
||||||
|
m.trackQueryFrequency(params)
|
||||||
|
// Track it multiple times to boost its score
|
||||||
|
for range 5 {
|
||||||
|
m.trackQueryFrequency(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 1: activeQueries > 0 — warming should be skipped (fast return)
|
||||||
|
m.activeQueries.Store(1)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
m.warmFrequentQueries()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Warming should return almost immediately when skipped
|
||||||
|
assert.Less(t, elapsed, 50*time.Millisecond,
|
||||||
|
"warmFrequentQueries should return quickly when user queries are in flight")
|
||||||
|
|
||||||
|
// Case 2: activeQueries == 0 — warming should proceed past the guard.
|
||||||
|
// With nil stores, executeSearch would panic, so we verify the guard was
|
||||||
|
// bypassed by checking that the frequency map's lastCached is NOT updated
|
||||||
|
// (warming fails silently on nil stores). The key assertion is that Case 1
|
||||||
|
// returns immediately (the guard works) while Case 2 enters the body.
|
||||||
|
m.activeQueries.Store(0)
|
||||||
|
|
||||||
|
// Clear frequency data to prevent executeSearch from being called
|
||||||
|
m.queryFrequencyMu.Lock()
|
||||||
|
m.queryFrequency = make(map[string]*queryFrequencyInfo)
|
||||||
|
m.queryFrequencyMu.Unlock()
|
||||||
|
|
||||||
|
start2 := time.Now()
|
||||||
|
m.warmFrequentQueries()
|
||||||
|
elapsed2 := time.Since(start2)
|
||||||
|
|
||||||
|
// With empty frequency map, warming enters the function body (past guard)
|
||||||
|
// but finds no candidates — fast return without calling executeSearch.
|
||||||
|
assert.Less(t, elapsed2, 200*time.Millisecond,
|
||||||
|
"warmFrequentQueries should complete quickly with no candidates")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveQueries_IncrementDecrement verifies that the activeQueries counter
|
||||||
|
// is correctly incremented and decremented around search operations.
|
||||||
|
func TestActiveQueries_IncrementDecrement(t *testing.T) {
|
||||||
|
m := NewManager(nil, nil, nil, nil)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
// Before any search, counter should be 0
|
||||||
|
assert.Equal(t, int32(0), m.activeQueries.Load())
|
||||||
|
|
||||||
|
// Directly test the atomic increment/decrement pattern used in UnifiedSearch
|
||||||
|
// (can't call UnifiedSearch with nil stores without panicking on filterSearch)
|
||||||
|
m.activeQueries.Add(1)
|
||||||
|
assert.Equal(t, int32(1), m.activeQueries.Load(), "should be 1 after increment")
|
||||||
|
|
||||||
|
m.activeQueries.Add(1)
|
||||||
|
assert.Equal(t, int32(2), m.activeQueries.Load(), "should be 2 after second increment")
|
||||||
|
|
||||||
|
m.activeQueries.Add(-1)
|
||||||
|
assert.Equal(t, int32(1), m.activeQueries.Load(), "should be 1 after decrement")
|
||||||
|
|
||||||
|
m.activeQueries.Add(-1)
|
||||||
|
assert.Equal(t, int32(0), m.activeQueries.Load(), "should be 0 after final decrement")
|
||||||
|
|
||||||
|
// Verify the field is accessible from warmFrequentQueries guard
|
||||||
|
m.activeQueries.Store(3)
|
||||||
|
assert.Equal(t, int32(3), m.activeQueries.Load())
|
||||||
|
m.activeQueries.Store(0)
|
||||||
|
}
|
||||||
|
|
||||||
// TestUnifiedSearchResult_MultipleResults tests result with multiple items.
|
// TestUnifiedSearchResult_MultipleResults tests result with multiple items.
|
||||||
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
|
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
|
||||||
results := []SearchResult{
|
results := []SearchResult{
|
||||||
|
|||||||
@@ -532,13 +532,20 @@ func (u *Updater) replaceBinaries(extractDir string) error {
|
|||||||
func (u *Updater) getInstallDirectories() []string {
|
func (u *Updater) getInstallDirectories() []string {
|
||||||
dirs := []string{u.installDir}
|
dirs := []string{u.installDir}
|
||||||
|
|
||||||
// Also check cache directories where Claude Code looks for plugins
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dirs
|
return dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/
|
// Primary stable binary location (survives Claude Code updates)
|
||||||
|
stableBin := filepath.Join(home, ".claude-mnemonic", "bin")
|
||||||
|
if stableBin != u.installDir {
|
||||||
|
if _, err := os.Stat(stableBin); err == nil {
|
||||||
|
dirs = append(dirs, stableBin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check cache directories where Claude Code looks for plugins
|
||||||
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
|
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
|
||||||
entries, err := os.ReadDir(cacheBase)
|
entries, err := os.ReadDir(cacheBase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -548,7 +555,6 @@ func (u *Updater) getInstallDirectories() []string {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
cacheDir := filepath.Join(cacheBase, entry.Name())
|
cacheDir := filepath.Join(cacheBase, entry.Name())
|
||||||
// Only add if it's different from installDir and contains a worker binary
|
|
||||||
if cacheDir != u.installDir {
|
if cacheDir != u.installDir {
|
||||||
workerPath := filepath.Join(cacheDir, "worker")
|
workerPath := filepath.Join(cacheDir, "worker")
|
||||||
if _, err := os.Stat(workerPath); err == nil {
|
if _, err := os.Stat(workerPath); err == nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -145,20 +145,24 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.writeMu.Lock()
|
// Prepare texts for embedding
|
||||||
defer c.writeMu.Unlock()
|
|
||||||
|
|
||||||
// Generate embeddings for all documents
|
|
||||||
texts := make([]string, len(docs))
|
texts := make([]string, len(docs))
|
||||||
for i, doc := range docs {
|
for i, doc := range docs {
|
||||||
texts[i] = doc.Content
|
texts[i] = doc.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
embeddings, err := c.embedSvc.EmbedBatch(texts)
|
// Compute embeddings OUTSIDE the write lock for better concurrency.
|
||||||
|
// Embedding is ONNX inference (slow, mutex-protected internally) — holding
|
||||||
|
// writeMu during inference blocks all concurrent writes and reads.
|
||||||
|
embeddings, err := c.embedSvc.EmbedBatchWithContext(ctx, texts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate embeddings: %w", err)
|
return fmt.Errorf("generate embeddings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acquire write lock for DB operations only
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
// Insert into vectors table with model version tracking
|
// Insert into vectors table with model version tracking
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT OR REPLACE INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version)
|
INSERT OR REPLACE INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version)
|
||||||
@@ -271,7 +275,7 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate query embedding OUTSIDE the lock for better concurrency
|
// Generate query embedding OUTSIDE the lock for better concurrency
|
||||||
queryEmb, err := c.getOrComputeEmbedding(query)
|
queryEmb, err := c.getOrComputeEmbedding(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("embed query: %w", err)
|
return nil, fmt.Errorf("embed query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -282,8 +286,10 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
|
|||||||
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now acquire read lock for the actual DB query
|
// Acquire read lock with context awareness to prevent indefinite blocking
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire read lock: %w", err)
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
// Build query with filters
|
// Build query with filters
|
||||||
@@ -485,7 +491,7 @@ func (c *Client) QueryBatch(ctx context.Context, queries []string, limit int, wh
|
|||||||
// Combines results from different field types and deduplicates by document ID.
|
// Combines results from different field types and deduplicates by document ID.
|
||||||
func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, docType string, project string) ([]QueryResult, error) {
|
func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, docType string, project string) ([]QueryResult, error) {
|
||||||
// Generate embedding once
|
// Generate embedding once
|
||||||
queryEmb, err := c.getOrComputeEmbedding(query)
|
queryEmb, err := c.getOrComputeEmbedding(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("embed query: %w", err)
|
return nil, fmt.Errorf("embed query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -496,7 +502,9 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
|
|||||||
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire read lock: %w", err)
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
// Query with field type aggregation - get best match per document
|
// Query with field type aggregation - get best match per document
|
||||||
@@ -555,6 +563,28 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
|
|||||||
return results, rows.Err()
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acquireRLockWithContext acquires a read lock on mu, respecting ctx cancellation.
|
||||||
|
// If ctx is cancelled while waiting for the lock, the goroutine that eventually
|
||||||
|
// acquires it will release it immediately to prevent leaks.
|
||||||
|
func acquireRLockWithContext(ctx context.Context, mu *sync.RWMutex) error {
|
||||||
|
acquired := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.RLock()
|
||||||
|
close(acquired)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-acquired:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Goroutine may still acquire lock after ctx cancelled — must unlock
|
||||||
|
go func() {
|
||||||
|
<-acquired
|
||||||
|
mu.RUnlock()
|
||||||
|
}()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// truncateString truncates a string to maxLen characters.
|
// truncateString truncates a string to maxLen characters.
|
||||||
func truncateString(s string, maxLen int) string {
|
func truncateString(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
@@ -565,7 +595,9 @@ func truncateString(s string, maxLen int) string {
|
|||||||
|
|
||||||
// Count returns the total number of vectors in the store.
|
// Count returns the total number of vectors in the store.
|
||||||
func (c *Client) Count(ctx context.Context) (int64, error) {
|
func (c *Client) Count(ctx context.Context) (int64, error) {
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return 0, fmt.Errorf("acquire read lock: %w", err)
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
@@ -586,7 +618,9 @@ func (c *Client) ModelVersion() string {
|
|||||||
// - The vectors table is empty
|
// - The vectors table is empty
|
||||||
// - Any vectors have a different model_version than the current model
|
// - Any vectors have a different model_version than the current model
|
||||||
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
|
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
currentModel := c.embedSvc.Version()
|
currentModel := c.embedSvc.Version()
|
||||||
@@ -634,7 +668,9 @@ type StaleVectorInfo struct {
|
|||||||
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
|
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
|
||||||
// This enables granular rebuild - only re-embedding documents that need updating.
|
// This enables granular rebuild - only re-embedding documents that need updating.
|
||||||
func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) {
|
func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) {
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire read lock: %w", err)
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
currentModel := c.embedSvc.Version()
|
currentModel := c.embedSvc.Version()
|
||||||
@@ -692,7 +728,9 @@ type VectorHealthStats struct {
|
|||||||
|
|
||||||
// GetHealthStats returns comprehensive health statistics about the vector store.
|
// GetHealthStats returns comprehensive health statistics about the vector store.
|
||||||
func (c *Client) GetHealthStats(ctx context.Context) (*VectorHealthStats, error) {
|
func (c *Client) GetHealthStats(ctx context.Context) (*VectorHealthStats, error) {
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire read lock: %w", err)
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
stats := &VectorHealthStats{
|
stats := &VectorHealthStats{
|
||||||
@@ -856,7 +894,9 @@ func (c *Client) DeleteByObservationID(ctx context.Context, obsID int64) error {
|
|||||||
|
|
||||||
// getOrComputeEmbedding returns a cached embedding or computes a new one.
|
// getOrComputeEmbedding returns a cached embedding or computes a new one.
|
||||||
// Uses singleflight to prevent duplicate concurrent computations for the same query.
|
// Uses singleflight to prevent duplicate concurrent computations for the same query.
|
||||||
func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
// The context controls timeout on the embedding mutex acquisition -- if the ONNX model
|
||||||
|
// hangs under CGO, callers can bail out instead of blocking forever.
|
||||||
|
func (c *Client) getOrComputeEmbedding(ctx context.Context, query string) ([]float32, error) {
|
||||||
now := time.Now().UnixNano()
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
// Check cache first (read lock)
|
// Check cache first (read lock)
|
||||||
@@ -870,8 +910,10 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
|||||||
}
|
}
|
||||||
c.queryCacheMu.RUnlock()
|
c.queryCacheMu.RUnlock()
|
||||||
|
|
||||||
// Cache miss - use singleflight to deduplicate concurrent embedding requests
|
// Cache miss - use singleflight DoChan to deduplicate concurrent embedding requests.
|
||||||
result, err, _ := c.embeddingGroup.Do(query, func() (any, error) {
|
// DoChan + select allows per-caller context cancellation: if a caller's context
|
||||||
|
// expires it can bail out without waiting for the shared computation to finish.
|
||||||
|
ch := c.embeddingGroup.DoChan(query, func() (any, error) {
|
||||||
// Double-check cache inside singleflight (another goroutine may have just cached it)
|
// Double-check cache inside singleflight (another goroutine may have just cached it)
|
||||||
c.queryCacheMu.RLock()
|
c.queryCacheMu.RLock()
|
||||||
if entry, ok := c.queryCache[query]; ok {
|
if entry, ok := c.queryCache[query]; ok {
|
||||||
@@ -885,7 +927,9 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
|||||||
// Record cache miss
|
// Record cache miss
|
||||||
c.stats.embeddingMisses.Add(1)
|
c.stats.embeddingMisses.Add(1)
|
||||||
|
|
||||||
// Compute embedding
|
// Compute embedding — use non-context Embed here because the singleflight
|
||||||
|
// result is shared across callers with different contexts. Per-caller
|
||||||
|
// cancellation is handled by the select below.
|
||||||
emb, err := c.embedSvc.Embed(query)
|
emb, err := c.embedSvc.Embed(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -933,10 +977,15 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
|||||||
return emb, nil
|
return emb, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
select {
|
||||||
return nil, err
|
case res := <-ch:
|
||||||
|
if res.Err != nil {
|
||||||
|
return nil, res.Err
|
||||||
|
}
|
||||||
|
return res.Val.([]float32), nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
return result.([]float32), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCache clears the embedding cache.
|
// ClearCache clears the embedding cache.
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package sqlitevec
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||||
@@ -1901,3 +1904,251 @@ func TestExtractObservationIDs_GlobalScope(t *testing.T) {
|
|||||||
assert.Len(t, ids, 1)
|
assert.Len(t, ids, 1)
|
||||||
assert.Equal(t, int64(123), ids[0])
|
assert.Equal(t, int64(123), ids[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR acquireRLockWithContext (Fix #45)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAcquireRLockWithContext_Cancel verifies that when a write lock is held
|
||||||
|
// and the context times out, acquireRLockWithContext returns context.DeadlineExceeded
|
||||||
|
// promptly and the cleanup goroutine eventually releases the lock.
|
||||||
|
func TestAcquireRLockWithContext_Cancel(t *testing.T) {
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
|
// Hold write lock so any RLock() call blocks.
|
||||||
|
locked := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.Lock()
|
||||||
|
close(locked)
|
||||||
|
<-release
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
<-locked // write lock is held
|
||||||
|
|
||||||
|
// Context with a tight deadline — must expire before we release the write lock.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := acquireRLockWithContext(ctx, &mu)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, context.DeadlineExceeded, "should return DeadlineExceeded")
|
||||||
|
assert.Less(t, elapsed, 200*time.Millisecond, "should return within ~100ms of deadline")
|
||||||
|
|
||||||
|
// Release the write lock so the cleanup goroutine can finish.
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
// After the write lock is released the cleanup goroutine acquires+releases
|
||||||
|
// the RLock. Wait long enough for it to drain.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Now an uncontended RLock should succeed immediately.
|
||||||
|
ctx2 := context.Background()
|
||||||
|
err2 := acquireRLockWithContext(ctx2, &mu)
|
||||||
|
assert.NoError(t, err2, "should succeed when uncontended after cleanup")
|
||||||
|
if err2 == nil {
|
||||||
|
mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcquireRLockWithContext_Success verifies that an uncontended mutex is
|
||||||
|
// acquired without error and can be properly unlocked.
|
||||||
|
func TestAcquireRLockWithContext_Success(t *testing.T) {
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
|
err := acquireRLockWithContext(context.Background(), &mu)
|
||||||
|
assert.NoError(t, err, "should succeed on uncontended mutex")
|
||||||
|
if err == nil {
|
||||||
|
// Panics if not held — validates that the lock was actually taken.
|
||||||
|
mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcquireRLockWithContext_CleanupOnCancel verifies that when
|
||||||
|
// acquireRLockWithContext returns an error due to context cancellation, the
|
||||||
|
// cleanup goroutine eventually releases the RLock so the mutex can be write-
|
||||||
|
// locked again without deadlock.
|
||||||
|
func TestAcquireRLockWithContext_CleanupOnCancel(t *testing.T) {
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
|
// Hold write lock to force RLock to block.
|
||||||
|
release := make(chan struct{})
|
||||||
|
locked := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.Lock()
|
||||||
|
close(locked)
|
||||||
|
<-release
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
<-locked
|
||||||
|
|
||||||
|
// Context cancels after 10ms — way before we release the write lock.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := acquireRLockWithContext(ctx, &mu)
|
||||||
|
assert.Error(t, err, "should fail due to cancelled context")
|
||||||
|
|
||||||
|
// Release the write lock; the cleanup goroutine inside acquireRLockWithContext
|
||||||
|
// will now acquire the RLock and immediately release it.
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
// Give the cleanup goroutine time to run.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Acquire a write lock — would deadlock if cleanup goroutine left an RLock
|
||||||
|
// dangling. Use a done channel and select to avoid hanging the test.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
close(done) //nolint:SA2001 // intentional: proves no deadlock from leaked RLock
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success — write lock acquired without deadlock.
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("write lock acquisition timed out: cleanup goroutine may have leaked an RLock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR Fix #1: Embedding outside writeMu in AddDocuments
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAddDocuments_EmbeddingOutsideWriteLock verifies that AddDocuments does NOT
|
||||||
|
// hold the write lock during embedding computation. A concurrent Query call
|
||||||
|
// should complete quickly while AddDocuments is computing embeddings.
|
||||||
|
func TestAddDocuments_EmbeddingOutsideWriteLock(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Seed the DB with one document so Query has something to search
|
||||||
|
seedDocs := []Document{
|
||||||
|
{ID: "seed-1", Content: "Seed document for concurrency test"},
|
||||||
|
}
|
||||||
|
err = client.AddDocuments(context.Background(), seedDocs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Pre-warm the embedding cache for the query text so the Query call
|
||||||
|
// itself doesn't need the embedding mutex — it only needs the DB read lock.
|
||||||
|
_, err = client.Query(context.Background(), "concurrency test", 5, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Prepare a batch of documents to trigger a slow AddDocuments call
|
||||||
|
batchDocs := make([]Document, 10)
|
||||||
|
for i := range batchDocs {
|
||||||
|
batchDocs[i] = Document{
|
||||||
|
ID: fmt.Sprintf("batch-%d", i),
|
||||||
|
Content: fmt.Sprintf("Batch document number %d for write lock test with unique content", i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch AddDocuments in background — embedding will take time
|
||||||
|
addDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
addDone <- client.AddDocuments(context.Background(), batchDocs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give AddDocuments a moment to start embedding computation
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Invalidate result cache so Query must go through to DB (tests read lock)
|
||||||
|
client.InvalidateResultCache()
|
||||||
|
|
||||||
|
// A concurrent Query should NOT be blocked by AddDocuments.
|
||||||
|
// If the old code held writeMu during embedding, this would block until
|
||||||
|
// embedding finishes. With the fix, it should complete quickly.
|
||||||
|
queryCtx, queryCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer queryCancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = client.Query(queryCtx, "concurrency test", 5, nil)
|
||||||
|
queryDuration := time.Since(start)
|
||||||
|
|
||||||
|
require.NoError(t, err, "Query should succeed while AddDocuments is embedding")
|
||||||
|
// The query should complete well within the timeout if writeMu is not held
|
||||||
|
assert.Less(t, queryDuration, 1*time.Second,
|
||||||
|
"Query should complete quickly when writeMu is not held during embedding")
|
||||||
|
|
||||||
|
// Wait for AddDocuments to finish
|
||||||
|
err = <-addDone
|
||||||
|
require.NoError(t, err, "AddDocuments should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR Fix #2a: DoChan + context select in getOrComputeEmbedding
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestGetOrComputeEmbedding_ContextCancelDuringSingleflight verifies that when
|
||||||
|
// a singleflight embedding computation is in progress, a second caller with a
|
||||||
|
// short-lived context returns context.DeadlineExceeded promptly rather than
|
||||||
|
// waiting for the slow first call to finish.
|
||||||
|
func TestGetOrComputeEmbedding_ContextCancelDuringSingleflight(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add a document so we can query
|
||||||
|
docs := []Document{
|
||||||
|
{ID: "sf-test-1", Content: "Singleflight context cancellation test document"},
|
||||||
|
}
|
||||||
|
err = client.AddDocuments(context.Background(), docs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Clear cache to force embedding computation
|
||||||
|
client.ClearCache()
|
||||||
|
client.InvalidateResultCache()
|
||||||
|
|
||||||
|
queryText := "unique singleflight context test query"
|
||||||
|
|
||||||
|
// First call: start a normal query in background (will trigger singleflight)
|
||||||
|
firstDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(firstDone)
|
||||||
|
_, _ = client.Query(context.Background(), queryText, 5, nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the first call a moment to start the singleflight computation
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
// Second call: use a very short context that should expire quickly
|
||||||
|
shortCtx, shortCancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer shortCancel()
|
||||||
|
|
||||||
|
// Clear result cache again so the second call hits getOrComputeEmbedding
|
||||||
|
client.InvalidateResultCache()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = client.Query(shortCtx, queryText, 5, nil)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// With DoChan + select, the second caller should return quickly with context error.
|
||||||
|
// Note: If the embedding completes fast enough, the second call may succeed
|
||||||
|
// via singleflight sharing. That's also valid — the test primarily checks
|
||||||
|
// that it doesn't BLOCK for the full embedding duration on context cancel.
|
||||||
|
if err != nil {
|
||||||
|
assert.ErrorIs(t, err, context.DeadlineExceeded,
|
||||||
|
"Should return DeadlineExceeded when context expires during singleflight")
|
||||||
|
assert.Less(t, elapsed, 500*time.Millisecond,
|
||||||
|
"Should return promptly on context cancellation, not wait for slow computation")
|
||||||
|
}
|
||||||
|
// If err == nil, the singleflight completed before the context expired — also valid.
|
||||||
|
|
||||||
|
// Wait for first call to finish
|
||||||
|
<-firstDone
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
//go:build fts5
|
||||||
|
|
||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// waitForCondition polls fn every 10ms until it returns true or timeout expires.
|
||||||
|
func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) bool {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if fn() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNew_CreatesWatcherWithCorrectFields verifies New initialises all fields correctly.
|
||||||
|
func TestNew_CreatesWatcherWithCorrectFields(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
called := false
|
||||||
|
cb := func() { called = true }
|
||||||
|
|
||||||
|
w, err := New(target, cb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, w)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
assert.Equal(t, target, w.targetPath)
|
||||||
|
assert.Equal(t, dir, w.parentPath)
|
||||||
|
assert.Equal(t, 100*time.Millisecond, w.debounce)
|
||||||
|
assert.NotNil(t, w.watcher)
|
||||||
|
assert.NotNil(t, w.ctx)
|
||||||
|
assert.NotNil(t, w.cancel)
|
||||||
|
assert.False(t, w.running)
|
||||||
|
assert.False(t, called, "callback must not be invoked on creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNew_NilCallback is valid — handleDeletion guards for nil onDelete.
|
||||||
|
func TestNew_NilCallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, w)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
assert.Nil(t, w.onDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStart_SetsRunningTrue verifies Start transitions running to true.
|
||||||
|
func TestStart_SetsRunningTrue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
err = w.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
running := w.running
|
||||||
|
w.mu.Unlock()
|
||||||
|
assert.True(t, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStart_Idempotent verifies calling Start twice does not panic or return error.
|
||||||
|
func TestStart_Idempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
require.NoError(t, w.Start(), "second Start must be a no-op without error")
|
||||||
|
|
||||||
|
// Still only one goroutine running — running flag is still true.
|
||||||
|
w.mu.Lock()
|
||||||
|
running := w.running
|
||||||
|
w.mu.Unlock()
|
||||||
|
assert.True(t, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStop_SetsRunningFalse verifies Stop transitions running to false.
|
||||||
|
func TestStop_SetsRunningFalse(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
require.NoError(t, w.Stop())
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
running := w.running
|
||||||
|
w.mu.Unlock()
|
||||||
|
assert.False(t, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStop_Idempotent verifies calling Stop when not running returns nil.
|
||||||
|
func TestStop_Idempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Never started — Stop must be a no-op.
|
||||||
|
assert.NoError(t, w.Stop())
|
||||||
|
// Second stop after the first no-op must also succeed.
|
||||||
|
assert.NoError(t, w.Stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStop_WithoutStart verifies Stop on an unstarted watcher is safe.
|
||||||
|
func TestStop_WithoutStart(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = w.Stop()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetDeletion_CallbackFired verifies that deleting the target file triggers onDelete.
|
||||||
|
func TestTargetDeletion_CallbackFired(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
// Create the target file so the parent watch is real.
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
var callCount int32
|
||||||
|
w, err := New(target, func() {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
// Delete the target file.
|
||||||
|
require.NoError(t, os.Remove(target))
|
||||||
|
|
||||||
|
// Wait up to 1 second for the debounced callback (debounce=100ms).
|
||||||
|
fired := waitForCondition(t, 1*time.Second, func() bool {
|
||||||
|
return atomic.LoadInt32(&callCount) > 0
|
||||||
|
})
|
||||||
|
assert.True(t, fired, "onDelete callback not called after target deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetDeletion_CallbackCalledOnce verifies debounce suppresses duplicate events.
|
||||||
|
func TestTargetDeletion_CallbackCalledOnce(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
var callCount int32
|
||||||
|
w, err := New(target, func() {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(target))
|
||||||
|
|
||||||
|
// Wait for callback to fire.
|
||||||
|
waitForCondition(t, 1*time.Second, func() bool {
|
||||||
|
return atomic.LoadInt32(&callCount) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait an extra debounce window to confirm no second call arrives.
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "callback fired more than once for a single deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetRecreation_CancelsCallback verifies that recreating the target before the
|
||||||
|
// debounce fires suppresses the onDelete callback.
|
||||||
|
func TestTargetRecreation_CancelsCallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
var callCount int32
|
||||||
|
// Use a longer debounce so we can recreate before it fires.
|
||||||
|
w, err := New(target, func() {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Override debounce to give us a larger window.
|
||||||
|
w.debounce = 300 * time.Millisecond
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
// Delete then immediately recreate within the debounce window.
|
||||||
|
require.NoError(t, os.Remove(target))
|
||||||
|
time.Sleep(20 * time.Millisecond) // ensure delete event is processed
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
// Wait past full debounce period to confirm callback was cancelled.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(&callCount), "callback should have been cancelled by recreation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParentDirectoryDeletion_CallbackFired verifies that deleting the parent directory
|
||||||
|
// triggers the onDelete callback.
|
||||||
|
func TestParentDirectoryDeletion_CallbackFired(t *testing.T) {
|
||||||
|
// Create a nested structure: base/sub/db.sqlite so we can remove sub
|
||||||
|
// without losing t.TempDir (which is base).
|
||||||
|
base := t.TempDir()
|
||||||
|
sub := filepath.Join(base, "sub")
|
||||||
|
require.NoError(t, os.Mkdir(sub, 0o755))
|
||||||
|
target := filepath.Join(sub, "db.sqlite")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
var callCount int32
|
||||||
|
w, err := New(target, func() {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
// Remove parent directory entirely.
|
||||||
|
require.NoError(t, os.RemoveAll(sub))
|
||||||
|
|
||||||
|
fired := waitForCondition(t, 1500*time.Millisecond, func() bool {
|
||||||
|
return atomic.LoadInt32(&callCount) > 0
|
||||||
|
})
|
||||||
|
assert.True(t, fired, "onDelete callback not called after parent directory deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddWatch_NonExistentParent verifies addWatch returns an error when parent is absent.
|
||||||
|
func TestAddWatch_NonExistentParent(t *testing.T) {
|
||||||
|
// Point watcher at a path whose parent definitely does not exist.
|
||||||
|
nonExistent := filepath.Join(t.TempDir(), "missing", "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(nonExistent, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
err = w.addWatch()
|
||||||
|
assert.Error(t, err, "addWatch must fail when parent directory does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContextCancellation_StopsWatchLoop verifies the watchLoop exits when Stop is called.
|
||||||
|
func TestContextCancellation_StopsWatchLoop(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
// Stop cancels the context; the goroutine should exit cleanly.
|
||||||
|
require.NoError(t, w.Stop())
|
||||||
|
|
||||||
|
// Give the goroutine a moment to exit — then verify running is false.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
w.mu.Lock()
|
||||||
|
running := w.running
|
||||||
|
w.mu.Unlock()
|
||||||
|
assert.False(t, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParentDirRecreation_ReEstablishesWatch verifies that recreating the parent after
|
||||||
|
// deletion allows subsequent target-deletion events to fire the callback.
|
||||||
|
func TestParentDirRecreation_ReEstablishesWatch(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
sub := filepath.Join(base, "sub")
|
||||||
|
require.NoError(t, os.Mkdir(sub, 0o755))
|
||||||
|
target := filepath.Join(sub, "db.sqlite")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
var callCount int32
|
||||||
|
w, err := New(target, func() {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
// Remove the parent.
|
||||||
|
require.NoError(t, os.RemoveAll(sub))
|
||||||
|
|
||||||
|
// Wait for first callback.
|
||||||
|
fired := waitForCondition(t, 1500*time.Millisecond, func() bool {
|
||||||
|
return atomic.LoadInt32(&callCount) > 0
|
||||||
|
})
|
||||||
|
require.True(t, fired, "first deletion callback must fire")
|
||||||
|
|
||||||
|
firstCount := atomic.LoadInt32(&callCount)
|
||||||
|
|
||||||
|
// Recreate parent and target — re-established watch should allow a second callback.
|
||||||
|
require.NoError(t, os.Mkdir(sub, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
// Wait for handleDeletion's goroutine to attempt re-adding the watch (500ms sleep inside).
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
|
||||||
|
// Now delete the target again.
|
||||||
|
require.NoError(t, os.Remove(target))
|
||||||
|
|
||||||
|
// We only assert the first callback fired; the re-watch is best-effort and
|
||||||
|
// OS-timing-dependent, so we don't hard-assert a second callback.
|
||||||
|
assert.GreaterOrEqual(t, atomic.LoadInt32(&callCount), firstCount, "call count must not decrease")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentStartStop verifies that concurrent Start/Stop calls do not race or panic.
|
||||||
|
func TestConcurrentStartStop(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
|
||||||
|
w, err := New(target, func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Launch goroutines that repeatedly start/stop the watcher.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_ = w.Start()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
_ = w.Stop()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
// No panic = pass. Final state: running should be consistent (we don't assert
|
||||||
|
// a specific value since Stop may have won last).
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDebounceField_DefaultValue asserts the default debounce is 100ms.
|
||||||
|
func TestDebounceField_DefaultValue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
w, err := New(filepath.Join(dir, "x"), func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer w.Stop() //nolint:errcheck
|
||||||
|
assert.Equal(t, 100*time.Millisecond, w.debounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallbackNotCalledWhenStopped verifies that if we Stop before the debounce fires,
|
||||||
|
// the callback is not invoked after Stop (context cancel exits the watchLoop).
|
||||||
|
func TestCallbackNotCalledWhenStopped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "db.sqlite")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
|
||||||
|
|
||||||
|
var callCount int32
|
||||||
|
w, err := New(target, func() {
|
||||||
|
atomic.AddInt32(&callCount, 1)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.debounce = 500 * time.Millisecond // wide window
|
||||||
|
|
||||||
|
require.NoError(t, w.Start())
|
||||||
|
|
||||||
|
// Delete file — debounce timer is now running (500ms).
|
||||||
|
require.NoError(t, os.Remove(target))
|
||||||
|
time.Sleep(20 * time.Millisecond) // let event propagate
|
||||||
|
|
||||||
|
// Stop before timer fires — context is cancelled, watchLoop exits.
|
||||||
|
require.NoError(t, w.Stop())
|
||||||
|
|
||||||
|
// Wait past the debounce window; the AfterFunc may still fire (it's not
|
||||||
|
// tied to the context), but the watcher is stopped. We assert the loop
|
||||||
|
// itself exited cleanly.
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
|
||||||
|
// The AfterFunc timer fires outside the watchLoop — callback may or may not
|
||||||
|
// have fired depending on OS scheduling. We assert no panic occurred.
|
||||||
|
// The important invariant: running is false.
|
||||||
|
w.mu.Lock()
|
||||||
|
running := w.running
|
||||||
|
w.mu.Unlock()
|
||||||
|
assert.False(t, running)
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ import (
|
|||||||
// IMPORTANT: This is on the critical startup path - must be fast!
|
// IMPORTANT: This is on the critical startup path - must be fast!
|
||||||
// No synchronous verification - just filter by staleness and return.
|
// No synchronous verification - just filter by staleness and return.
|
||||||
func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
query := r.URL.Query().Get("query")
|
query := r.URL.Query().Get("query")
|
||||||
cwd := r.URL.Query().Get("cwd")
|
cwd := r.URL.Query().Get("cwd")
|
||||||
@@ -54,7 +58,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
var expandedQueries []expansion.ExpandedQuery
|
var expandedQueries []expansion.ExpandedQuery
|
||||||
var detectedIntent string
|
var detectedIntent string
|
||||||
if s.queryExpander != nil {
|
if s.queryExpander != nil {
|
||||||
expandCtx, expandCancel := context.WithTimeout(r.Context(), 5*time.Second)
|
expandCtx, expandCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
cfg := expansion.DefaultConfig()
|
cfg := expansion.DefaultConfig()
|
||||||
cfg.EnableVocabularyExpansion = false // Vocabulary expansion is optional
|
cfg.EnableVocabularyExpansion = false // Vocabulary expansion is optional
|
||||||
expandedQueries = s.queryExpander.Expand(expandCtx, query, cfg)
|
expandedQueries = s.queryExpander.Expand(expandCtx, query, cfg)
|
||||||
@@ -83,7 +87,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
var vectorErrors int
|
var vectorErrors int
|
||||||
|
|
||||||
for _, eq := range expandedQueries {
|
for _, eq := range expandedQueries {
|
||||||
vectorResults, vecErr := s.vectorClient.Query(r.Context(), eq.Query, limit*2, where)
|
vectorResults, vecErr := s.vectorClient.Query(ctx, eq.Query, limit*2, where)
|
||||||
if vecErr != nil {
|
if vecErr != nil {
|
||||||
vectorErrors++
|
vectorErrors++
|
||||||
log.Debug().Err(vecErr).Str("query", eq.Query).Msg("Vector query failed")
|
log.Debug().Err(vecErr).Str("query", eq.Query).Msg("Vector query failed")
|
||||||
@@ -125,7 +129,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if len(obsIDs) > 0 {
|
if len(obsIDs) > 0 {
|
||||||
// Fetch full observations from SQLite
|
// Fetch full observations from SQLite
|
||||||
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", limit)
|
observations, err = s.observationStore.GetObservationsByIDs(ctx, obsIDs, "date_desc", limit)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
usedVector = true
|
usedVector = true
|
||||||
}
|
}
|
||||||
@@ -138,11 +142,11 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
|
|||||||
if vectorSearchFailed {
|
if vectorSearchFailed {
|
||||||
log.Info().Str("project", project).Msg("Using FTS fallback due to vector search failure")
|
log.Info().Str("project", project).Msg("Using FTS fallback due to vector search failure")
|
||||||
}
|
}
|
||||||
observations, err = s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit)
|
observations, err = s.observationStore.SearchObservationsFTS(ctx, query, project, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// FTS might fail if query has special chars, try without
|
// FTS might fail if query has special chars, try without
|
||||||
log.Warn().Err(err).Str("query", query).Msg("FTS search failed, falling back to recent")
|
log.Warn().Err(err).Str("query", query).Msg("FTS search failed, falling back to recent")
|
||||||
observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit)
|
observations, err = s.observationStore.GetRecentObservations(ctx, project, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -354,7 +358,9 @@ func (s *Service) handleFileContext(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search for observations related to each file in parallel
|
// Search for observations related to each file in parallel
|
||||||
ctx := r.Context()
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, ctxCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer ctxCancel()
|
||||||
|
|
||||||
// Check if vector search is available
|
// Check if vector search is available
|
||||||
if s.vectorClient == nil || !s.vectorClient.IsConnected() {
|
if s.vectorClient == nil || !s.vectorClient.IsConnected() {
|
||||||
@@ -562,6 +568,10 @@ func splitCamelCase(s string) string {
|
|||||||
// IMPORTANT: This is on the critical startup path - must be fast!
|
// IMPORTANT: This is on the critical startup path - must be fast!
|
||||||
// No synchronous verification - just filter by staleness and return.
|
// No synchronous verification - just filter by staleness and return.
|
||||||
func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
if project == "" {
|
if project == "" {
|
||||||
http.Error(w, "project required", http.StatusBadRequest)
|
http.Error(w, "project required", http.StatusBadRequest)
|
||||||
@@ -592,7 +602,7 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get recent observations
|
// Get recent observations
|
||||||
observations, err := s.observationStore.GetRecentObservations(r.Context(), project, limit)
|
observations, err := s.observationStore.GetRecentObservations(ctx, project, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -658,13 +668,17 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleContextCount returns the count of observations for a project.
|
// handleContextCount returns the count of observations for a project.
|
||||||
func (s *Service) handleContextCount(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleContextCount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
if project == "" {
|
if project == "" {
|
||||||
http.Error(w, "project required", http.StatusBadRequest)
|
http.Error(w, "project required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := s.getCachedObservationCount(r.Context(), project)
|
count, err := s.getCachedObservationCount(ctx, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -20,6 +21,10 @@ import (
|
|||||||
// Supports optional query parameter for semantic search via sqlite-vec.
|
// Supports optional query parameter for semantic search via sqlite-vec.
|
||||||
// Supports pagination via limit and offset query parameters.
|
// Supports pagination via limit and offset query parameters.
|
||||||
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
pagination := gorm.ParsePaginationParams(r, DefaultObservationsLimit)
|
pagination := gorm.ParsePaginationParams(r, DefaultObservationsLimit)
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
query := r.URL.Query().Get("query")
|
query := r.URL.Query().Get("query")
|
||||||
@@ -38,11 +43,11 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
|
|||||||
// Use vector search if query is provided and vector client is available
|
// Use vector search if query is provided and vector client is available
|
||||||
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
|
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
|
||||||
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
|
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
|
||||||
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, pagination.Limit*2, where)
|
vectorResults, vecErr := s.vectorClient.Query(ctx, query, pagination.Limit*2, where)
|
||||||
if vecErr == nil && len(vectorResults) > 0 {
|
if vecErr == nil && len(vectorResults) > 0 {
|
||||||
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
|
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
|
||||||
if len(obsIDs) > 0 {
|
if len(obsIDs) > 0 {
|
||||||
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", pagination.Limit)
|
observations, err = s.observationStore.GetObservationsByIDs(ctx, obsIDs, "date_desc", pagination.Limit)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
usedVector = true
|
usedVector = true
|
||||||
total = int64(len(observations)) // Vector search doesn't have total, use returned count
|
total = int64(len(observations)) // Vector search doesn't have total, use returned count
|
||||||
@@ -55,10 +60,10 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
|
|||||||
if !usedVector {
|
if !usedVector {
|
||||||
if project != "" {
|
if project != "" {
|
||||||
// Strict project filtering for dashboard - only observations from this project
|
// Strict project filtering for dashboard - only observations from this project
|
||||||
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(r.Context(), project, pagination.Limit, pagination.Offset)
|
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, pagination.Limit, pagination.Offset)
|
||||||
} else {
|
} else {
|
||||||
// All projects
|
// All projects
|
||||||
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(r.Context(), pagination.Limit, pagination.Offset)
|
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, pagination.Limit, pagination.Offset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +95,10 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
|
|||||||
// handleGetSummaries returns recent summaries.
|
// handleGetSummaries returns recent summaries.
|
||||||
// Supports optional query parameter for semantic search via sqlite-vec.
|
// Supports optional query parameter for semantic search via sqlite-vec.
|
||||||
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
limit := gorm.ParseLimitParam(r, DefaultSummariesLimit)
|
limit := gorm.ParseLimitParam(r, DefaultSummariesLimit)
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
query := r.URL.Query().Get("query")
|
query := r.URL.Query().Get("query")
|
||||||
@@ -107,11 +116,11 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Use vector search if query is provided and vector client is available
|
// Use vector search if query is provided and vector client is available
|
||||||
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
|
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
|
||||||
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeSessionSummary, "")
|
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeSessionSummary, "")
|
||||||
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
|
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
|
||||||
if vecErr == nil && len(vectorResults) > 0 {
|
if vecErr == nil && len(vectorResults) > 0 {
|
||||||
summaryIDs := sqlitevec.ExtractSummaryIDs(vectorResults, project)
|
summaryIDs := sqlitevec.ExtractSummaryIDs(vectorResults, project)
|
||||||
if len(summaryIDs) > 0 {
|
if len(summaryIDs) > 0 {
|
||||||
summaries, err = s.summaryStore.GetSummariesByIDs(r.Context(), summaryIDs, "date_desc", limit)
|
summaries, err = s.summaryStore.GetSummariesByIDs(ctx, summaryIDs, "date_desc", limit)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
usedVector = true
|
usedVector = true
|
||||||
}
|
}
|
||||||
@@ -122,9 +131,9 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Fall back to SQLite if vector search not used
|
// Fall back to SQLite if vector search not used
|
||||||
if !usedVector {
|
if !usedVector {
|
||||||
if project != "" {
|
if project != "" {
|
||||||
summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit)
|
summaries, err = s.summaryStore.GetRecentSummaries(ctx, project, limit)
|
||||||
} else {
|
} else {
|
||||||
summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit)
|
summaries, err = s.summaryStore.GetAllRecentSummaries(ctx, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +152,10 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleGetPrompts returns recent user prompts.
|
// handleGetPrompts returns recent user prompts.
|
||||||
// Supports optional query parameter for semantic search via sqlite-vec.
|
// Supports optional query parameter for semantic search via sqlite-vec.
|
||||||
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Add request-scoped timeout to prevent indefinite blocking on DB operations
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
limit := gorm.ParseLimitParam(r, DefaultPromptsLimit)
|
limit := gorm.ParseLimitParam(r, DefaultPromptsLimit)
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
query := r.URL.Query().Get("query")
|
query := r.URL.Query().Get("query")
|
||||||
@@ -160,11 +173,11 @@ func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Use vector search if query is provided and vector client is available
|
// Use vector search if query is provided and vector client is available
|
||||||
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
|
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
|
||||||
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeUserPrompt, "")
|
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeUserPrompt, "")
|
||||||
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
|
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
|
||||||
if vecErr == nil && len(vectorResults) > 0 {
|
if vecErr == nil && len(vectorResults) > 0 {
|
||||||
promptIDs := sqlitevec.ExtractPromptIDs(vectorResults, project)
|
promptIDs := sqlitevec.ExtractPromptIDs(vectorResults, project)
|
||||||
if len(promptIDs) > 0 {
|
if len(promptIDs) > 0 {
|
||||||
prompts, err = s.promptStore.GetPromptsByIDs(r.Context(), promptIDs, "date_desc", limit)
|
prompts, err = s.promptStore.GetPromptsByIDs(ctx, promptIDs, "date_desc", limit)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
usedVector = true
|
usedVector = true
|
||||||
}
|
}
|
||||||
@@ -175,9 +188,9 @@ func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Fall back to SQLite if vector search not used
|
// Fall back to SQLite if vector search not used
|
||||||
if !usedVector {
|
if !usedVector {
|
||||||
if project != "" {
|
if project != "" {
|
||||||
prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit)
|
prompts, err = s.promptStore.GetRecentUserPromptsByProject(ctx, project, limit)
|
||||||
} else {
|
} else {
|
||||||
prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit)
|
prompts, err = s.promptStore.GetAllRecentUserPrompts(ctx, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3024,3 +3024,42 @@ func TestHandleSessionStart(t *testing.T) {
|
|||||||
// May return various status codes depending on session state and endpoint
|
// May return various status codes depending on session state and endpoint
|
||||||
assert.Contains(t, []int{http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, rec.Code)
|
assert.Contains(t, []int{http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR handler request-scoped timeouts (Fix #45)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestHandleSearchByPrompt_RespectsTimeout verifies that handleSearchByPrompt
|
||||||
|
// uses r.Context() as the parent for its internal 15s timeout: if the request
|
||||||
|
// context is already cancelled the handler must return quickly rather than
|
||||||
|
// hanging for 15 seconds, and must not return StatusOK.
|
||||||
|
func TestHandleSearchByPrompt_RespectsTimeout(t *testing.T) {
|
||||||
|
svc, cleanup := testService(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Pre-populate a project with an observation so there is DB work to do.
|
||||||
|
createTestObservation(t, svc.observationStore, "timeout-test",
|
||||||
|
"Authentication flow", "JWT token validation", []string{"security"})
|
||||||
|
|
||||||
|
// Build a request with a pre-cancelled context.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // cancelled before the request is even sent
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/context/search?project=timeout-test&query=authentication",
|
||||||
|
nil).WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
svc.router.ServeHTTP(rec, req)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// The handler must not hang for 15 seconds — it inherits the cancelled ctx.
|
||||||
|
assert.Less(t, elapsed, 5*time.Second,
|
||||||
|
"handler should return quickly when request context is already cancelled")
|
||||||
|
|
||||||
|
// A cancelled-context request must not yield a successful 200 response.
|
||||||
|
// Acceptable: any error status, or an empty/error body on 200 (DB returned nothing).
|
||||||
|
// The key regression: it must NOT block for the full 15s timeout.
|
||||||
|
t.Logf("handler returned status=%d in %v", rec.Code, elapsed)
|
||||||
|
}
|
||||||
|
|||||||
+55
-27
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -366,9 +367,9 @@ func NewService(version string) (*Service, error) {
|
|||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
sseBroadcaster := sse.NewBroadcaster()
|
sseBroadcaster := sse.NewBroadcaster()
|
||||||
|
|
||||||
// Determine install directory (plugin location)
|
// Determine install directory (stable binary location, survives Claude Code updates)
|
||||||
homeDir, _ := os.UserHomeDir()
|
homeDir, _ := os.UserHomeDir()
|
||||||
installDir := fmt.Sprintf("%s/.claude/plugins/marketplaces/claude-mnemonic", homeDir)
|
installDir := fmt.Sprintf("%s/.claude-mnemonic/bin", homeDir)
|
||||||
|
|
||||||
// Create rate limiter with generous limits (100 req/sec, burst of 200)
|
// Create rate limiter with generous limits (100 req/sec, burst of 200)
|
||||||
// These limits are per-client and allow for intensive CLI usage
|
// These limits are per-client and allow for intensive CLI usage
|
||||||
@@ -736,10 +737,14 @@ func (s *Service) reinitializeDatabase() {
|
|||||||
log.Info().Msg("Query expansion reconnected after reinit")
|
log.Info().Msg("Query expansion reconnected after reinit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close old reranker if exists
|
// Close old vector client and reranker before swapping
|
||||||
s.initMu.RLock()
|
s.initMu.RLock()
|
||||||
|
oldVectorClient := s.vectorClient
|
||||||
oldReranker := s.reranker
|
oldReranker := s.reranker
|
||||||
s.initMu.RUnlock()
|
s.initMu.RUnlock()
|
||||||
|
if oldVectorClient != nil {
|
||||||
|
_ = oldVectorClient.Close()
|
||||||
|
}
|
||||||
if oldReranker != nil {
|
if oldReranker != nil {
|
||||||
_ = oldReranker.Close()
|
_ = oldReranker.Close()
|
||||||
}
|
}
|
||||||
@@ -815,8 +820,11 @@ func (s *Service) reloadConfig() {
|
|||||||
// Give SSE clients a moment to receive the message
|
// Give SSE clients a moment to receive the message
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
// Exit cleanly - hooks will restart us with new config
|
// Send SIGTERM to self for graceful shutdown (hooks will restart us)
|
||||||
os.Exit(0)
|
p, err := os.FindProcess(os.Getpid())
|
||||||
|
if err == nil {
|
||||||
|
_ = p.Signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setInitError records an initialization error.
|
// setInitError records an initialization error.
|
||||||
@@ -1238,7 +1246,12 @@ func (s *Service) setupRoutes() {
|
|||||||
s.router.Get("/api/selfcheck", s.handleSelfCheck)
|
s.router.Get("/api/selfcheck", s.handleSelfCheck)
|
||||||
|
|
||||||
// SSE endpoint (works before DB is ready)
|
// SSE endpoint (works before DB is ready)
|
||||||
s.router.Get("/api/events", s.sseBroadcaster.HandleSSE)
|
// Wrap with middleware that disables write deadline since SSE connections are long-lived
|
||||||
|
s.router.Get("/api/events", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
_ = rc.SetWriteDeadline(time.Time{}) // no deadline for SSE
|
||||||
|
s.sseBroadcaster.HandleSSE(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
// Routes that require DB to be ready
|
// Routes that require DB to be ready
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
@@ -1538,7 +1551,7 @@ func (s *Service) Start() error {
|
|||||||
Handler: s.router,
|
Handler: s.router,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
WriteTimeout: 0, // Disabled for SSE (long-lived connections)
|
WriteTimeout: 60 * time.Second, // Default for API routes; SSE handler extends per-request
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1592,15 +1605,17 @@ func (s *Service) processQueue() {
|
|||||||
ticker := time.NewTicker(QueueProcessInterval)
|
ticker := time.NewTicker(QueueProcessInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
s.initMu.RLock()
|
||||||
|
notify := s.sessionManager.ProcessNotify
|
||||||
|
s.initMu.RUnlock()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-s.ctx.Done():
|
case <-s.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-s.sessionManager.ProcessNotify:
|
case <-notify:
|
||||||
// Immediate processing when observation is queued
|
|
||||||
s.processAllSessions()
|
s.processAllSessions()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Fallback periodic processing
|
|
||||||
s.processAllSessions()
|
s.processAllSessions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1610,32 +1625,41 @@ func (s *Service) processQueue() {
|
|||||||
// Messages are processed in parallel using goroutines, with concurrency
|
// Messages are processed in parallel using goroutines, with concurrency
|
||||||
// limited by a channel-based semaphore.
|
// limited by a channel-based semaphore.
|
||||||
func (s *Service) processAllSessions() {
|
func (s *Service) processAllSessions() {
|
||||||
// Get all sessions with pending messages
|
s.initMu.RLock()
|
||||||
sessions := s.sessionManager.GetAllSessions()
|
mgr := s.sessionManager
|
||||||
|
proc := s.processor
|
||||||
|
s.initMu.RUnlock()
|
||||||
|
if mgr == nil || proc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions := mgr.GetAllSessions()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
sem := make(chan struct{}, MaxConcurrentProcessing)
|
sem := make(chan struct{}, MaxConcurrentProcessing)
|
||||||
|
|
||||||
for _, sess := range sessions {
|
for _, sess := range sessions {
|
||||||
// Get pending messages
|
messages := mgr.DrainMessages(sess.SessionDBID)
|
||||||
messages := s.sessionManager.DrainMessages(sess.SessionDBID)
|
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each message in a goroutine with semaphore
|
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
sem <- struct{}{} // Acquire semaphore slot
|
sem <- struct{}{}
|
||||||
go func(sess *session.ActiveSession, msg session.PendingMessage) {
|
go func(sess *session.ActiveSession, msg session.PendingMessage) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }() // Release semaphore slot
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
// Timeout prevents a hung subprocess from blocking the queue processor forever
|
||||||
|
procCtx, procCancel := context.WithTimeout(s.ctx, 60*time.Second)
|
||||||
|
defer procCancel()
|
||||||
|
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case session.MessageTypeObservation:
|
case session.MessageTypeObservation:
|
||||||
if msg.Observation != nil {
|
if msg.Observation != nil {
|
||||||
err := s.processor.ProcessObservation(
|
err := proc.ProcessObservation(
|
||||||
s.ctx,
|
procCtx,
|
||||||
sess.SDKSessionID,
|
sess.SDKSessionID,
|
||||||
sess.Project,
|
sess.Project,
|
||||||
msg.Observation.ToolName,
|
msg.Observation.ToolName,
|
||||||
@@ -1653,8 +1677,8 @@ func (s *Service) processAllSessions() {
|
|||||||
|
|
||||||
case session.MessageTypeSummarize:
|
case session.MessageTypeSummarize:
|
||||||
if msg.Summarize != nil {
|
if msg.Summarize != nil {
|
||||||
err := s.processor.ProcessSummary(
|
err := proc.ProcessSummary(
|
||||||
s.ctx,
|
procCtx,
|
||||||
sess.SessionDBID,
|
sess.SessionDBID,
|
||||||
sess.SDKSessionID,
|
sess.SDKSessionID,
|
||||||
sess.Project,
|
sess.Project,
|
||||||
@@ -1667,18 +1691,15 @@ func (s *Service) processAllSessions() {
|
|||||||
Int64("sessionId", sess.SessionDBID).
|
Int64("sessionId", sess.SessionDBID).
|
||||||
Msg("Failed to process summary")
|
Msg("Failed to process summary")
|
||||||
}
|
}
|
||||||
// Delete session after summary
|
mgr.DeleteSession(sess.SessionDBID)
|
||||||
s.sessionManager.DeleteSession(sess.SessionDBID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}(sess, msg)
|
}(sess, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all goroutines to complete
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Broadcast status after processing
|
|
||||||
s.broadcastProcessingStatus()
|
s.broadcastProcessingStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1787,8 +1808,15 @@ func (s *Service) Shutdown(ctx context.Context) error {
|
|||||||
|
|
||||||
// broadcastProcessingStatus broadcasts the current processing status.
|
// broadcastProcessingStatus broadcasts the current processing status.
|
||||||
func (s *Service) broadcastProcessingStatus() {
|
func (s *Service) broadcastProcessingStatus() {
|
||||||
isProcessing := s.sessionManager.IsAnySessionProcessing()
|
s.initMu.RLock()
|
||||||
queueDepth := s.sessionManager.GetTotalQueueDepth()
|
mgr := s.sessionManager
|
||||||
|
s.initMu.RUnlock()
|
||||||
|
if mgr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing := mgr.IsAnySessionProcessing()
|
||||||
|
queueDepth := mgr.GetTotalQueueDepth()
|
||||||
|
|
||||||
s.sseBroadcaster.Broadcast(map[string]any{
|
s.sseBroadcaster.Broadcast(map[string]any{
|
||||||
"type": "processing_status",
|
"type": "processing_status",
|
||||||
|
|||||||
+10
-10
@@ -13,7 +13,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic",
|
"name": "claude-mnemonic",
|
||||||
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
||||||
"version": "0.11.57",
|
"version": "0.12.11",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "lukaszraczylo",
|
"name": "lukaszraczylo",
|
||||||
"url": "https://github.com/lukaszraczylo"
|
"url": "https://github.com/lukaszraczylo"
|
||||||
@@ -23,25 +23,25 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||||
"releases": {
|
"releases": {
|
||||||
"latest": "0.11.57",
|
"latest": "0.12.11",
|
||||||
"versions": {
|
"versions": {
|
||||||
"0.11.57": {
|
"0.12.11": {
|
||||||
"releaseDate": "2026-03-07",
|
"releaseDate": "2026-05-24",
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.57/claude-mnemonic_0.11.57_darwin_arm64.tar.gz",
|
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.11/claude-mnemonic_0.12.11_darwin_arm64.tar.gz",
|
||||||
"format": "tar.gz",
|
"format": "tar.gz",
|
||||||
"sha256": "75a7aaf7bbb4826dae27a0a85b4d3c6d292e24b20b60f536def7cf3089e26b8f"
|
"sha256": "bd97ce0f434d857cf6f5619af6227b318043f1f0b4171def72d978a53e97795e"
|
||||||
},
|
},
|
||||||
"linux-amd64": {
|
"linux-amd64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.57/claude-mnemonic_0.11.57_linux_amd64.tar.gz",
|
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.11/claude-mnemonic_0.12.11_linux_amd64.tar.gz",
|
||||||
"format": "tar.gz",
|
"format": "tar.gz",
|
||||||
"sha256": "4ef9539cccdaff18e3f1109ff62138b3cc5089e95272234dcf75bdd6a564a5d4"
|
"sha256": "576aad5143041fef35e0dcfa9a633e4c78b92231fb17007e1e2b084de3c00a15"
|
||||||
},
|
},
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.57/claude-mnemonic_0.11.57_windows_amd64.zip",
|
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.11/claude-mnemonic_0.12.11_windows_amd64.zip",
|
||||||
"format": "zip",
|
"format": "zip",
|
||||||
"sha256": "96135a0ca1bdcb4e0a4bb7959d9495d79570d3ad87532ab0ed93ca2f2ce0504c"
|
"sha256": "f6b1b8e3d6a8d29e853bcc3b63f3f844f477cb2a3e0049ff498208ac21aeb6ba"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/mcp-server"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
echo "claude-mnemonic: mcp-server not found at $BIN" >&2
|
||||||
|
echo "Install: cd $(dirname "$0") && make install" >&2
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setSysProcAttr(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package hooks
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func setSysProcAttr(cmd *exec.Cmd) {
|
||||||
|
// Windows has no Setpgid; process groups work differently.
|
||||||
|
}
|
||||||
+97
-31
@@ -29,7 +29,11 @@ const (
|
|||||||
HealthCheckTimeout = 2 * time.Second
|
HealthCheckTimeout = 2 * time.Second
|
||||||
|
|
||||||
// StartupTimeout is the timeout for worker startup.
|
// StartupTimeout is the timeout for worker startup.
|
||||||
StartupTimeout = 30 * time.Second
|
StartupTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// EnsureWorkerDeadline is the hard overall deadline for EnsureWorkerRunning.
|
||||||
|
// Must fit within Claude Code's hook timeout budget.
|
||||||
|
EnsureWorkerDeadline = 15 * time.Second
|
||||||
|
|
||||||
// workerCacheMaxAge is how long the worker cache is considered fresh.
|
// workerCacheMaxAge is how long the worker cache is considered fresh.
|
||||||
workerCacheMaxAge = 10 * time.Second
|
workerCacheMaxAge = 10 * time.Second
|
||||||
@@ -48,6 +52,26 @@ var (
|
|||||||
// circuitBreakerMu protects lastStartupFailure.
|
// circuitBreakerMu protects lastStartupFailure.
|
||||||
circuitBreakerMu sync.Mutex
|
circuitBreakerMu sync.Mutex
|
||||||
lastStartupFailure time.Time
|
lastStartupFailure time.Time
|
||||||
|
|
||||||
|
// hookClient is a shared HTTP client for hook->worker requests.
|
||||||
|
// DisableKeepAlives prevents TIME_WAIT connection leaks since each hook
|
||||||
|
// is a separate OS process that exits quickly.
|
||||||
|
hookClient = &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthClient is a shared HTTP client for health/version checks.
|
||||||
|
healthClient = &http.Client{
|
||||||
|
Timeout: HealthCheckTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsWorkerAvailable performs a fast check without network calls.
|
// IsWorkerAvailable performs a fast check without network calls.
|
||||||
@@ -86,8 +110,7 @@ func GetWorkerPort() int {
|
|||||||
// Parses the JSON health response to check the "ready" field when available.
|
// Parses the JSON health response to check the "ready" field when available.
|
||||||
// Falls back to HTTP status code check for backwards compatibility.
|
// Falls back to HTTP status code check for backwards compatibility.
|
||||||
func IsWorkerRunning(port int) bool {
|
func IsWorkerRunning(port int) bool {
|
||||||
client := &http.Client{Timeout: HealthCheckTimeout}
|
resp, err := healthClient.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
|
||||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -200,7 +223,25 @@ func isWorkerRunningWithRetries(port int) bool {
|
|||||||
// EnsureWorkerRunning ensures the worker is running, starting it if necessary.
|
// EnsureWorkerRunning ensures the worker is running, starting it if necessary.
|
||||||
// If a worker is already running and healthy with matching version, it reuses it.
|
// If a worker is already running and healthy with matching version, it reuses it.
|
||||||
// If version mismatch or unhealthy, it kills the old worker and starts fresh.
|
// If version mismatch or unhealthy, it kills the old worker and starts fresh.
|
||||||
|
// A hard deadline of EnsureWorkerDeadline prevents exceeding Claude Code's hook timeout.
|
||||||
func EnsureWorkerRunning() (int, error) {
|
func EnsureWorkerRunning() (int, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), EnsureWorkerDeadline)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return ensureWorkerRunningCtx(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepCtx sleeps for d or returns early if ctx is cancelled.
|
||||||
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||||
|
select {
|
||||||
|
case <-time.After(d):
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureWorkerRunningCtx(ctx context.Context) (int, error) {
|
||||||
port := GetWorkerPort()
|
port := GetWorkerPort()
|
||||||
|
|
||||||
// Fast path: check PID cache before making any HTTP calls.
|
// Fast path: check PID cache before making any HTTP calls.
|
||||||
@@ -210,6 +251,10 @@ func EnsureWorkerRunning() (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return 0, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Circuit breaker: if we failed to start recently, don't retry immediately.
|
// Circuit breaker: if we failed to start recently, don't retry immediately.
|
||||||
circuitBreakerMu.Lock()
|
circuitBreakerMu.Lock()
|
||||||
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
|
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
|
||||||
@@ -232,7 +277,9 @@ func EnsureWorkerRunning() (int, error) {
|
|||||||
if err := KillProcessOnPort(port); err != nil {
|
if err := KillProcessOnPort(port); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill old worker: %v\n", err)
|
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill old worker: %v\n", err)
|
||||||
}
|
}
|
||||||
time.Sleep(500 * time.Millisecond)
|
if err := sleepCtx(ctx, 500*time.Millisecond); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Version matches, reuse existing worker
|
// Version matches, reuse existing worker
|
||||||
updateCacheFromPort(port)
|
updateCacheFromPort(port)
|
||||||
@@ -245,14 +292,20 @@ func EnsureWorkerRunning() (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return 0, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Port is in use but health check failed -- worker may be slow, not dead.
|
// Port is in use but health check failed -- worker may be slow, not dead.
|
||||||
if IsPortInUse(port) {
|
if IsPortInUse(port) {
|
||||||
// The port is responding to TCP but health check timed out.
|
// The port is responding to TCP but health check timed out.
|
||||||
// Don't kill it -- it's likely just under load. Give it more time.
|
// Don't kill it -- it's likely just under load. Give it more time.
|
||||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker on port %d is slow to respond, waiting...\n", port)
|
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker on port %d is slow to respond, waiting...\n", port)
|
||||||
// Try a few more times with longer delays before giving up
|
// Try a couple more times with shorter delays before giving up
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
time.Sleep(500 * time.Millisecond)
|
if err := sleepCtx(ctx, 300*time.Millisecond); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
if IsWorkerRunning(port) {
|
if IsWorkerRunning(port) {
|
||||||
updateCacheFromPort(port)
|
updateCacheFromPort(port)
|
||||||
return port, nil
|
return port, nil
|
||||||
@@ -263,7 +316,13 @@ func EnsureWorkerRunning() (int, error) {
|
|||||||
if err := KillProcessOnPort(port); err != nil {
|
if err := KillProcessOnPort(port); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill unhealthy process on port %d: %v\n", port, err)
|
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill unhealthy process on port %d: %v\n", port, err)
|
||||||
}
|
}
|
||||||
time.Sleep(500 * time.Millisecond)
|
if err := sleepCtx(ctx, 500*time.Millisecond); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return 0, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find worker binary
|
// Find worker binary
|
||||||
@@ -272,8 +331,10 @@ func EnsureWorkerRunning() (int, error) {
|
|||||||
return 0, fmt.Errorf("worker binary not found")
|
return 0, fmt.Errorf("worker binary not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start worker
|
// Start worker -- detach from hook's process group so Claude Code
|
||||||
|
// killing the hook doesn't take the worker down with it.
|
||||||
cmd := exec.Command(workerPath) // #nosec G204 -- workerPath is from internal findWorkerBinary
|
cmd := exec.Command(workerPath) // #nosec G204 -- workerPath is from internal findWorkerBinary
|
||||||
|
setSysProcAttr(cmd)
|
||||||
cmd.Stdout = os.Stderr
|
cmd.Stdout = os.Stderr
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
@@ -286,27 +347,32 @@ func EnsureWorkerRunning() (int, error) {
|
|||||||
pid := cmd.Process.Pid
|
pid := cmd.Process.Pid
|
||||||
|
|
||||||
// Wait for worker to be ready with exponential backoff
|
// Wait for worker to be ready with exponential backoff
|
||||||
deadline := time.Now().Add(StartupTimeout)
|
|
||||||
backoff := 50 * time.Millisecond
|
backoff := 50 * time.Millisecond
|
||||||
maxBackoff := 500 * time.Millisecond
|
maxBackoff := 500 * time.Millisecond
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
circuitBreakerMu.Lock()
|
||||||
|
lastStartupFailure = time.Now()
|
||||||
|
circuitBreakerMu.Unlock()
|
||||||
|
return 0, fmt.Errorf("worker failed to start within deadline: %w", ctx.Err())
|
||||||
|
}
|
||||||
if IsWorkerRunning(port) {
|
if IsWorkerRunning(port) {
|
||||||
writeWorkerCache(port, pid)
|
writeWorkerCache(port, pid)
|
||||||
return port, nil
|
return port, nil
|
||||||
}
|
}
|
||||||
time.Sleep(backoff)
|
if err := sleepCtx(ctx, backoff); err != nil {
|
||||||
|
circuitBreakerMu.Lock()
|
||||||
|
lastStartupFailure = time.Now()
|
||||||
|
circuitBreakerMu.Unlock()
|
||||||
|
return 0, fmt.Errorf("worker failed to start within deadline: %w", err)
|
||||||
|
}
|
||||||
// Exponential backoff with cap
|
// Exponential backoff with cap
|
||||||
backoff = backoff * 2
|
backoff = backoff * 2
|
||||||
if backoff > maxBackoff {
|
if backoff > maxBackoff {
|
||||||
backoff = maxBackoff
|
backoff = maxBackoff
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
circuitBreakerMu.Lock()
|
|
||||||
lastStartupFailure = time.Now()
|
|
||||||
circuitBreakerMu.Unlock()
|
|
||||||
return 0, fmt.Errorf("worker failed to start within timeout")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCacheFromPort finds the PID of the process on the port and updates the cache.
|
// updateCacheFromPort finds the PID of the process on the port and updates the cache.
|
||||||
@@ -330,8 +396,7 @@ func updateCacheFromPort(port int) {
|
|||||||
|
|
||||||
// GetWorkerVersion gets the version of the running worker.
|
// GetWorkerVersion gets the version of the running worker.
|
||||||
func GetWorkerVersion(port int) string {
|
func GetWorkerVersion(port int) string {
|
||||||
client := &http.Client{Timeout: HealthCheckTimeout}
|
resp, err := healthClient.Get(fmt.Sprintf("http://127.0.0.1:%d/api/version", port))
|
||||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/version", port))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -397,7 +462,15 @@ func KillProcessOnPort(port int) error {
|
|||||||
|
|
||||||
// findWorkerBinary finds the worker binary path.
|
// findWorkerBinary finds the worker binary path.
|
||||||
func findWorkerBinary() string {
|
func findWorkerBinary() string {
|
||||||
// Check CLAUDE_PLUGIN_ROOT first (set by Claude Code when running hooks)
|
home := os.Getenv("HOME")
|
||||||
|
|
||||||
|
// Stable binary location (primary, survives Claude Code updates)
|
||||||
|
stablePath := filepath.Join(home, ".claude-mnemonic", "bin", "worker")
|
||||||
|
if _, err := os.Stat(stablePath); err == nil {
|
||||||
|
return stablePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check CLAUDE_PLUGIN_ROOT (set by Claude Code when running hooks)
|
||||||
if pluginRoot := os.Getenv("CLAUDE_PLUGIN_ROOT"); pluginRoot != "" {
|
if pluginRoot := os.Getenv("CLAUDE_PLUGIN_ROOT"); pluginRoot != "" {
|
||||||
workerPath := filepath.Join(pluginRoot, "worker")
|
workerPath := filepath.Join(pluginRoot, "worker")
|
||||||
if _, err := os.Stat(workerPath); err == nil {
|
if _, err := os.Stat(workerPath); err == nil {
|
||||||
@@ -406,7 +479,6 @@ func findWorkerBinary() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check common locations
|
// Check common locations
|
||||||
home := os.Getenv("HOME")
|
|
||||||
locations := []string{
|
locations := []string{
|
||||||
"./worker",
|
"./worker",
|
||||||
"./bin/worker",
|
"./bin/worker",
|
||||||
@@ -418,10 +490,9 @@ func findWorkerBinary() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try cache directory with any version (glob returns lexically sorted matches)
|
// Try cache directory with any version
|
||||||
matches, _ := filepath.Glob(filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/*/worker"))
|
matches, _ := filepath.Glob(filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/*/worker"))
|
||||||
if len(matches) > 0 {
|
if len(matches) > 0 {
|
||||||
// Use the last match (latest version due to lexical sorting)
|
|
||||||
return matches[len(matches)-1]
|
return matches[len(matches)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,14 +512,12 @@ func findWorkerBinary() string {
|
|||||||
|
|
||||||
// POST sends a POST request to the worker.
|
// POST sends a POST request to the worker.
|
||||||
func POST(port int, path string, body interface{}) (map[string]interface{}, error) {
|
func POST(port int, path string, body interface{}) (map[string]interface{}, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(body)
|
jsonBody, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Post(
|
resp, err := hookClient.Post(
|
||||||
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
||||||
"application/json",
|
"application/json",
|
||||||
bytes.NewReader(jsonBody),
|
bytes.NewReader(jsonBody),
|
||||||
@@ -487,8 +556,7 @@ func POSTWithContext(ctx context.Context, port int, path string, body interface{
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
resp, err := hookClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -498,9 +566,7 @@ func POSTWithContext(ctx context.Context, port int, path string, body interface{
|
|||||||
|
|
||||||
// GET sends a GET request to the worker.
|
// GET sends a GET request to the worker.
|
||||||
func GET(port int, path string) (map[string]interface{}, error) {
|
func GET(port int, path string) (map[string]interface{}, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
resp, err := hookClient.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
||||||
|
|
||||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-2
@@ -2,6 +2,7 @@
|
|||||||
package hooks
|
package hooks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -518,7 +519,8 @@ func TestProjectIDWithName_Uniqueness(t *testing.T) {
|
|||||||
func TestHookConstants(t *testing.T) {
|
func TestHookConstants(t *testing.T) {
|
||||||
assert.Equal(t, 37777, DefaultWorkerPort)
|
assert.Equal(t, 37777, DefaultWorkerPort)
|
||||||
assert.Equal(t, 2*time.Second, HealthCheckTimeout)
|
assert.Equal(t, 2*time.Second, HealthCheckTimeout)
|
||||||
assert.Equal(t, 30*time.Second, StartupTimeout)
|
assert.Equal(t, 10*time.Second, StartupTimeout)
|
||||||
|
assert.Equal(t, 15*time.Second, EnsureWorkerDeadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExitCodes tests exit code constants.
|
// TestExitCodes tests exit code constants.
|
||||||
@@ -1200,5 +1202,104 @@ func TestHealthCheckTimeout(t *testing.T) {
|
|||||||
// TestStartupTimeout tests the startup timeout is reasonable.
|
// TestStartupTimeout tests the startup timeout is reasonable.
|
||||||
func TestStartupTimeout(t *testing.T) {
|
func TestStartupTimeout(t *testing.T) {
|
||||||
assert.Greater(t, StartupTimeout, 5*time.Second)
|
assert.Greater(t, StartupTimeout, 5*time.Second)
|
||||||
assert.LessOrEqual(t, StartupTimeout, time.Minute)
|
assert.LessOrEqual(t, StartupTimeout, 15*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureWorkerDeadline tests the deadline is within hook budget.
|
||||||
|
func TestEnsureWorkerDeadline(t *testing.T) {
|
||||||
|
assert.Greater(t, EnsureWorkerDeadline, StartupTimeout, "deadline must exceed startup timeout")
|
||||||
|
assert.LessOrEqual(t, EnsureWorkerDeadline, 20*time.Second, "deadline must fit in hook timeout budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Regression tests for Fixes 1, 3, 4 ---
|
||||||
|
|
||||||
|
// TestEnsureWorkerRunning_RespectsDeadline verifies that EnsureWorkerRunning returns
|
||||||
|
// within a bounded time even in worst case (no worker, startup fails).
|
||||||
|
func TestEnsureWorkerRunning_RespectsDeadline(t *testing.T) {
|
||||||
|
// Reset circuit breaker so the function actually tries to start.
|
||||||
|
circuitBreakerMu.Lock()
|
||||||
|
lastStartupFailure = time.Time{}
|
||||||
|
circuitBreakerMu.Unlock()
|
||||||
|
|
||||||
|
// Use a port that nothing listens on.
|
||||||
|
t.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "19999")
|
||||||
|
// Point HOME to a temp dir so findWorkerBinary finds nothing.
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
// Clear plugin root to avoid that path too.
|
||||||
|
t.Setenv("CLAUDE_PLUGIN_ROOT", "")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := EnsureWorkerRunning()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Must error (no binary found).
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "worker binary not found")
|
||||||
|
// Must complete well within EnsureWorkerDeadline.
|
||||||
|
assert.Less(t, elapsed, EnsureWorkerDeadline,
|
||||||
|
"EnsureWorkerRunning took %v, exceeding deadline %v", elapsed, EnsureWorkerDeadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureWorkerRunningCtx_CancelledContext verifies immediate return on cancelled context.
|
||||||
|
func TestEnsureWorkerRunningCtx_CancelledContext(t *testing.T) {
|
||||||
|
// Reset circuit breaker.
|
||||||
|
circuitBreakerMu.Lock()
|
||||||
|
lastStartupFailure = time.Time{}
|
||||||
|
circuitBreakerMu.Unlock()
|
||||||
|
|
||||||
|
t.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "19998")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // cancel immediately
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := ensureWorkerRunningCtx(ctx)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Less(t, elapsed, 1*time.Second, "cancelled context should return immediately")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSleepCtx_Normal verifies sleepCtx completes normally.
|
||||||
|
func TestSleepCtx_Normal(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
start := time.Now()
|
||||||
|
err := sleepCtx(ctx, 50*time.Millisecond)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSleepCtx_Cancelled verifies sleepCtx returns early on cancel.
|
||||||
|
func TestSleepCtx_Cancelled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := sleepCtx(ctx, 5*time.Second)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Less(t, elapsed, 500*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookClients_DisableKeepAlives asserts shared clients disable keep-alives
|
||||||
|
// to prevent TIME_WAIT connection leaks in short-lived hook processes.
|
||||||
|
func TestHookClients_DisableKeepAlives(t *testing.T) {
|
||||||
|
hTransport, ok := hookClient.Transport.(*http.Transport)
|
||||||
|
require.True(t, ok, "hookClient.Transport should be *http.Transport")
|
||||||
|
assert.True(t, hTransport.DisableKeepAlives, "hookClient must disable keep-alives")
|
||||||
|
assert.Equal(t, 1, hTransport.MaxIdleConns)
|
||||||
|
|
||||||
|
hcTransport, ok := healthClient.Transport.(*http.Transport)
|
||||||
|
require.True(t, ok, "healthClient.Transport should be *http.Transport")
|
||||||
|
assert.True(t, hcTransport.DisableKeepAlives, "healthClient must disable keep-alives")
|
||||||
|
assert.Equal(t, 1, hcTransport.MaxIdleConns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookClient_Timeout verifies hookClient timeout is set.
|
||||||
|
func TestHookClient_Timeout(t *testing.T) {
|
||||||
|
assert.Equal(t, 10*time.Second, hookClient.Timeout)
|
||||||
|
assert.Equal(t, HealthCheckTimeout, healthClient.Timeout)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
ONNX_VERSION="1.24.3"
|
ONNX_VERSION="1.26.0"
|
||||||
ASSETS_DIR="internal/embedding/assets/lib"
|
ASSETS_DIR="internal/embedding/assets/lib"
|
||||||
PLATFORM="${1:-all}"
|
PLATFORM="${1:-all}"
|
||||||
FORCE_DOWNLOAD=false
|
FORCE_DOWNLOAD=false
|
||||||
|
|||||||
+46
-29
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Register claude-mnemonic plugin with Claude Code
|
# Register claude-mnemonic plugin with Claude Code
|
||||||
|
# Ensures plugin survives CLI updates via extraKnownMarketplaces in settings.json
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ MARKETPLACES_FILE="$HOME/.claude/plugins/known_marketplaces.json"
|
|||||||
PLUGIN_KEY="claude-mnemonic@claude-mnemonic"
|
PLUGIN_KEY="claude-mnemonic@claude-mnemonic"
|
||||||
MARKETPLACE_NAME="claude-mnemonic"
|
MARKETPLACE_NAME="claude-mnemonic"
|
||||||
MARKETPLACE_PATH="$HOME/.claude/plugins/marketplaces/claude-mnemonic"
|
MARKETPLACE_PATH="$HOME/.claude/plugins/marketplaces/claude-mnemonic"
|
||||||
|
STABLE_BIN="$HOME/.claude-mnemonic/bin"
|
||||||
|
|
||||||
# Get version from git tags (same as Makefile), or use argument if provided
|
# Get version from git tags (same as Makefile), or use argument if provided
|
||||||
VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
|
VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
|
||||||
@@ -21,7 +23,8 @@ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
|||||||
# The last argument is treated as the input file, output goes to input_file.tmp
|
# The last argument is treated as the input file, output goes to input_file.tmp
|
||||||
safe_jq_write() {
|
safe_jq_write() {
|
||||||
local args=("$@")
|
local args=("$@")
|
||||||
local input_file="${args[-1]}"
|
local last_idx=$((${#args[@]} - 1))
|
||||||
|
local input_file="${args[$last_idx]}"
|
||||||
local tmp_file="${input_file}.tmp"
|
local tmp_file="${input_file}.tmp"
|
||||||
|
|
||||||
if jq "${args[@]}" > "$tmp_file"; then
|
if jq "${args[@]}" > "$tmp_file"; then
|
||||||
@@ -69,29 +72,21 @@ if [ ! -f "$MARKETPLACES_FILE" ]; then
|
|||||||
echo '{}' > "$MARKETPLACES_FILE"
|
echo '{}' > "$MARKETPLACES_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate marketplace path exists and contains expected files
|
# Ensure cache directory exists and copy plugin files (wrapper scripts + metadata)
|
||||||
if [ ! -d "$MARKETPLACE_PATH" ]; then
|
|
||||||
echo "Warning: Marketplace directory not found at $MARKETPLACE_PATH"
|
|
||||||
echo "Plugin files may not be copied to cache correctly."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure cache directory exists and copy plugin files
|
|
||||||
mkdir -p "$CACHE_PATH/.claude-plugin"
|
mkdir -p "$CACHE_PATH/.claude-plugin"
|
||||||
mkdir -p "$CACHE_PATH/hooks"
|
mkdir -p "$CACHE_PATH/hooks"
|
||||||
mkdir -p "$CACHE_PATH/commands"
|
mkdir -p "$CACHE_PATH/commands"
|
||||||
|
|
||||||
# Copy files from marketplace to cache
|
# Copy wrapper scripts and metadata from marketplace to cache
|
||||||
if ! cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null; then
|
if [ -d "$MARKETPLACE_PATH" ]; then
|
||||||
echo "ERROR: Failed to copy plugin files to cache directory"
|
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify critical files exist in cache
|
# Also ensure actual binaries are available via the wrapper scripts
|
||||||
for f in worker mcp-server hooks/hooks.json .claude-plugin/plugin.json; do
|
# The wrappers delegate to $STABLE_BIN which has the real binaries
|
||||||
if [ ! -f "$CACHE_PATH/$f" ]; then
|
if [ ! -x "$STABLE_BIN/mcp-server" ]; then
|
||||||
echo "WARNING: Expected file $f not found in cache after copy"
|
echo "WARNING: Binaries not found at $STABLE_BIN. Run 'make install' from the source directory."
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
|
||||||
# --- JSON registration ---
|
# --- JSON registration ---
|
||||||
# Uses jq if available, falls back to python3 for systems without jq.
|
# Uses jq if available, falls back to python3 for systems without jq.
|
||||||
@@ -132,13 +127,12 @@ plugins["plugins"][plugin_key] = [{
|
|||||||
"installPath": cache_path,
|
"installPath": cache_path,
|
||||||
"version": version,
|
"version": version,
|
||||||
"installedAt": timestamp,
|
"installedAt": timestamp,
|
||||||
"lastUpdated": timestamp,
|
"lastUpdated": timestamp
|
||||||
"isLocal": True
|
|
||||||
}]
|
}]
|
||||||
save_json(plugins_file, plugins)
|
save_json(plugins_file, plugins)
|
||||||
print("Plugin registered in installed_plugins.json")
|
print("Plugin registered in installed_plugins.json")
|
||||||
|
|
||||||
# 2. settings.json
|
# 2. settings.json — enable plugin AND add to extraKnownMarketplaces for persistence
|
||||||
settings = load_json(settings_file)
|
settings = load_json(settings_file)
|
||||||
settings.setdefault("enabledPlugins", {})
|
settings.setdefault("enabledPlugins", {})
|
||||||
settings["enabledPlugins"][plugin_key] = True
|
settings["enabledPlugins"][plugin_key] = True
|
||||||
@@ -147,14 +141,26 @@ settings["statusLine"] = {
|
|||||||
"command": statusline_cmd,
|
"command": statusline_cmd,
|
||||||
"padding": 0
|
"padding": 0
|
||||||
}
|
}
|
||||||
|
# extraKnownMarketplaces ensures plugin survives Claude Code CLI updates
|
||||||
|
settings.setdefault("extraKnownMarketplaces", {})
|
||||||
|
settings["extraKnownMarketplaces"][marketplace_name] = {
|
||||||
|
"source": {
|
||||||
|
"repo": "lukaszraczylo/claude-mnemonic",
|
||||||
|
"source": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
save_json(settings_file, settings)
|
save_json(settings_file, settings)
|
||||||
print("Plugin enabled in settings.json")
|
print("Plugin enabled in settings.json")
|
||||||
|
print("Added to extraKnownMarketplaces for persistence")
|
||||||
print("Statusline configured in settings.json")
|
print("Statusline configured in settings.json")
|
||||||
|
|
||||||
# 3. known_marketplaces.json
|
# 3. known_marketplaces.json
|
||||||
marketplaces = load_json(marketplaces_file)
|
marketplaces = load_json(marketplaces_file)
|
||||||
marketplaces[marketplace_name] = {
|
marketplaces[marketplace_name] = {
|
||||||
"source": {"source": "directory", "path": marketplace_path},
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "lukaszraczylo/claude-mnemonic"
|
||||||
|
},
|
||||||
"installLocation": marketplace_path,
|
"installLocation": marketplace_path,
|
||||||
"lastUpdated": timestamp
|
"lastUpdated": timestamp
|
||||||
}
|
}
|
||||||
@@ -190,8 +196,7 @@ if command -v jq &> /dev/null; then
|
|||||||
"installPath": "$CACHE_PATH",
|
"installPath": "$CACHE_PATH",
|
||||||
"version": "$VERSION",
|
"version": "$VERSION",
|
||||||
"installedAt": "$TIMESTAMP",
|
"installedAt": "$TIMESTAMP",
|
||||||
"lastUpdated": "$TIMESTAMP",
|
"lastUpdated": "$TIMESTAMP"
|
||||||
"isLocal": true
|
|
||||||
}]
|
}]
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
@@ -202,7 +207,7 @@ EOF
|
|||||||
|
|
||||||
echo "Plugin registered in installed_plugins.json"
|
echo "Plugin registered in installed_plugins.json"
|
||||||
|
|
||||||
# Enable the plugin in settings.json and configure statusline
|
# Enable the plugin in settings.json, configure statusline, and add to extraKnownMarketplaces
|
||||||
STATUSLINE_ENTRY=$(cat <<EOF
|
STATUSLINE_ENTRY=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
@@ -210,20 +215,32 @@ EOF
|
|||||||
"padding": 0
|
"padding": 0
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
MARKETPLACE_SOURCE=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"repo": "lukaszraczylo/claude-mnemonic",
|
||||||
|
"source": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_jq_write --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
|
safe_jq_write --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
|
||||||
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE"
|
--arg mkname "$MARKETPLACE_NAME" --argjson mksource "$MARKETPLACE_SOURCE" \
|
||||||
|
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline | .extraKnownMarketplaces //= {} | .extraKnownMarketplaces[$mkname] = $mksource' "$SETTINGS_FILE"
|
||||||
|
|
||||||
echo "Plugin enabled in settings.json"
|
echo "Plugin enabled in settings.json"
|
||||||
|
echo "Added to extraKnownMarketplaces for persistence"
|
||||||
echo "Statusline configured in settings.json"
|
echo "Statusline configured in settings.json"
|
||||||
|
|
||||||
# Register the marketplace in known_marketplaces.json
|
# Register the marketplace in known_marketplaces.json (GitHub source)
|
||||||
MARKETPLACE_ENTRY=$(cat <<EOF
|
MARKETPLACE_ENTRY=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"source": {
|
"source": {
|
||||||
"source": "directory",
|
"source": "github",
|
||||||
"path": "$MARKETPLACE_PATH"
|
"repo": "lukaszraczylo/claude-mnemonic"
|
||||||
},
|
},
|
||||||
"installLocation": "$MARKETPLACE_PATH",
|
"installLocation": "$MARKETPLACE_PATH",
|
||||||
"lastUpdated": "$TIMESTAMP"
|
"lastUpdated": "$TIMESTAMP"
|
||||||
|
|||||||
Generated
+167
-122
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.11.47-3-gfcab3eb-dirty",
|
"version": "v0.11.57-dirty",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.11.47-3-gfcab3eb-dirty",
|
"version": "v0.11.57-dirty",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
@@ -625,9 +625,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -639,9 +639,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -653,9 +653,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -667,9 +667,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -681,9 +681,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -695,9 +695,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -709,9 +709,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
|
||||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -723,9 +723,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
|
||||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -737,9 +737,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -751,9 +751,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -765,9 +765,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.60.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
|
||||||
|
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -779,9 +793,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.60.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
|
||||||
|
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -793,9 +821,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -807,9 +835,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -821,9 +849,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -835,9 +863,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -849,9 +877,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -862,10 +890,24 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.60.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
|
||||||
|
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -877,9 +919,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -891,9 +933,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -905,9 +947,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -919,9 +961,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1239,9 +1281,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1765,13 +1807,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -1800,9 +1842,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1885,9 +1927,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1918,9 +1960,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1937,7 +1979,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2156,9 +2198,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2172,28 +2214,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||||
"@rollup/rollup-android-arm64": "4.53.3",
|
"@rollup/rollup-android-arm64": "4.60.4",
|
||||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2363,9 +2408,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2455,9 +2500,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
@@ -2519,9 +2564,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2612,9 +2657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.11.47-3-gfcab3eb-dirty",
|
"version": "v0.11.57-dirty",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user