mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-11 00:09:28 +00:00
a81482d06a
MCP server (5 fixes):
- Move semaphore acquisition inside goroutine so main loop stays
responsive when all slots are taken
- Add 10s write timeout to sendResponse to prevent pipe deadlock
when Claude Code pauses reading stdout
- Send fallback JSON-RPC error when json.Marshal fails instead of
silently swallowing the error and leaving caller waiting forever
- Silence unknown notification methods (req.ID == nil) instead of
sending unsolicited error responses that may desync the host
- Return MCP isError content for tool failures instead of top-level
JSON-RPC error, matching the MCP specification
Vector/embedding (3 fixes):
- Move EmbedBatchWithContext call before writeMu.Lock in AddDocuments
so ONNX inference runs outside the write lock
- Replace singleflight.Do with DoChan + ctx select in both
getOrComputeEmbedding and UnifiedSearch so callers can bail out
independently when their context expires
- Add activeQueries atomic counter; skip cache warming when user
queries are in-flight; reduce warming timeout from 5s to 2s
Hooks (4 fixes):
- Cap EnsureWorkerRunning to 15s hard deadline with context; reduce
StartupTimeout from 30s to 10s; reduce port-in-use retries
- Fix nil dereference panic in user-prompt hook when initResult is
nil (non-JSON worker response); use comma-ok assertions
- Use package-level hookClient/healthClient with DisableKeepAlives
to prevent FD leaks in short-lived hook processes
- Set SysProcAttr{Setpgid: true} to detach worker from hook process
group, preventing kill-cascade from Claude Code
Worker/DB (3 fixes):
- Replace os.Exit(0) in MCP config watcher with context cancellation
for clean protocol shutdown
- Add 60s context.WithTimeout around ProcessObservation calls in
processAllSessions to prevent hung CLI subprocesses from blocking
the queue processor forever
- Set explicit PRAGMA wal_autocheckpoint=1000 and add PASSIVE WAL
checkpoint to Optimize() to prevent checkpoint stalls
Adds 20+ regression tests across all fix areas.
242 lines
6.0 KiB
Go
242 lines
6.0 KiB
Go
//go:build fts5
|
|
|
|
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
|
package gorm
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
func TestNewStore(t *testing.T) {
|
|
// Create temporary directory for test database
|
|
tmpDir, err := os.MkdirTemp("", "gorm_test_*")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
|
|
// Create store with migrations
|
|
cfg := Config{
|
|
Path: dbPath,
|
|
MaxConns: 4,
|
|
LogLevel: logger.Silent,
|
|
}
|
|
|
|
store, err := NewStore(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewStore failed: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Verify connection works
|
|
sqlDB := store.GetRawDB()
|
|
if err := sqlDB.Ping(); err != nil {
|
|
t.Fatalf("ping failed: %v", err)
|
|
}
|
|
|
|
// Verify WAL mode is enabled
|
|
var journalMode string
|
|
err = store.DB.Raw("PRAGMA journal_mode").Scan(&journalMode).Error
|
|
if err != nil {
|
|
t.Fatalf("query journal_mode failed: %v", err)
|
|
}
|
|
if journalMode != "wal" {
|
|
t.Errorf("expected WAL mode, got %q", journalMode)
|
|
}
|
|
|
|
// Verify core tables exist
|
|
tables := []string{
|
|
"sdk_sessions",
|
|
"observations",
|
|
"session_summaries",
|
|
"user_prompts",
|
|
"observation_conflicts",
|
|
"observation_relations",
|
|
"patterns",
|
|
"concept_weights",
|
|
}
|
|
|
|
for _, table := range tables {
|
|
exists := store.DB.Migrator().HasTable(table)
|
|
if !exists {
|
|
t.Errorf("table %q does not exist", table)
|
|
}
|
|
}
|
|
|
|
// Verify FTS5 virtual tables exist (cannot use Migrator().HasTable for virtual tables)
|
|
ftsTables := []string{
|
|
"user_prompts_fts",
|
|
"observations_fts",
|
|
"session_summaries_fts",
|
|
"patterns_fts",
|
|
}
|
|
|
|
for _, table := range ftsTables {
|
|
var count int
|
|
err := store.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count).Error
|
|
if err != nil {
|
|
t.Errorf("check FTS table %q failed: %v", table, err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("FTS table %q does not exist", table)
|
|
}
|
|
}
|
|
|
|
// Verify vectors table exists (virtual table)
|
|
var vectorsCount int
|
|
err = store.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vectors'").Scan(&vectorsCount).Error
|
|
if err != nil {
|
|
t.Errorf("check vectors table failed: %v", err)
|
|
}
|
|
if vectorsCount != 1 {
|
|
t.Errorf("vectors table does not exist")
|
|
}
|
|
|
|
// Verify concept_weights seed data exists
|
|
var conceptCount int64
|
|
store.DB.Model(&ConceptWeight{}).Count(&conceptCount)
|
|
if conceptCount != 12 {
|
|
t.Errorf("expected 12 concept weights, got %d", conceptCount)
|
|
}
|
|
|
|
t.Logf("✅ Phase 1 Foundation: All migrations successful")
|
|
t.Logf(" - Core tables: %d", len(tables))
|
|
t.Logf(" - FTS5 tables: %d", len(ftsTables))
|
|
t.Logf(" - Vector table: 1")
|
|
t.Logf(" - Seed data: %d concept weights", conceptCount)
|
|
}
|
|
|
|
func TestMigrationIdempotency(t *testing.T) {
|
|
// Create temporary directory for test database
|
|
tmpDir, err := os.MkdirTemp("", "gorm_idempotency_*")
|
|
if err != nil {
|
|
t.Fatalf("create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
cfg := Config{
|
|
Path: dbPath,
|
|
MaxConns: 4,
|
|
LogLevel: logger.Silent,
|
|
}
|
|
|
|
// Run migrations first time
|
|
store1, err := NewStore(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewStore (first) failed: %v", err)
|
|
}
|
|
store1.Close()
|
|
|
|
// Run migrations second time (should be idempotent)
|
|
store2, err := NewStore(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewStore (second) failed: %v", err)
|
|
}
|
|
defer store2.Close()
|
|
|
|
// Verify concept_weights seed data is still exactly 12 (INSERT OR IGNORE)
|
|
var conceptCount int64
|
|
store2.DB.Model(&ConceptWeight{}).Count(&conceptCount)
|
|
if conceptCount != 12 {
|
|
t.Errorf("expected 12 concept weights after second migration, got %d", conceptCount)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|