diff --git a/.cursorindexingignore b/.cursorindexingignore index 953908e7..77b19ebc 100644 --- a/.cursorindexingignore +++ b/.cursorindexingignore @@ -1,3 +1,5 @@ # Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references .specstory/** +# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references +docs/ai/** diff --git a/specstory-cli/pkg/providers/cursorcli/sqlite_reader.go b/specstory-cli/pkg/providers/cursorcli/sqlite_reader.go index 83e63b24..06ed43fd 100644 --- a/specstory-cli/pkg/providers/cursorcli/sqlite_reader.go +++ b/specstory-cli/pkg/providers/cursorcli/sqlite_reader.go @@ -181,7 +181,7 @@ func ReadSessionData(sessionPath string) (string, string, []BlobRecord, []BlobRe slog.Debug("Opening Cursor CLI SQLite database", "path", dbPath) - // Open the database in read-only mode + // Open the database in read-only mode with controlled connection pooling db, err := sql.Open("sqlite", dbPath+"?mode=ro") if err != nil { return "", "", nil, nil, fmt.Errorf("failed to open database: %w", err) @@ -192,16 +192,12 @@ func ReadSessionData(sessionPath string) (string, string, []BlobRecord, []BlobRe } }() - slog.Debug("Successfully opened database", "path", dbPath) + // Limit the connection pool to prevent file descriptor accumulation + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(30 * time.Second) - // Enable WAL mode for non-blocking reads - // This prevents readers from blocking the cursor-agent writer - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - // Log warning but continue - not fatal if WAL fails - slog.Warn("Failed to enable WAL mode", "error", err) - } else { - slog.Debug("Enabled WAL mode for non-blocking reads") - } + slog.Debug("Successfully opened database", "path", dbPath) // Validate that this is actually a Cursor database with the expected schema if err := validateCursorDatabase(db); err != nil { @@ -358,6 +354,47 @@ func ReadSessionData(sessionPath string) (string, string, []BlobRecord, []BlobRe return createdAt, slug, blobRecords, orphanRecords, nil } +// EnsureWALMode ensures the database is running in WAL journal mode. +// WAL mode is required so that the -wal file exists and file modification +// detection works reliably. This opens a brief read-write connection and +// is intended to be called once per session database, not on every read. +func EnsureWALMode(dbPath string) error { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("failed to open database for WAL check: %w", err) + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Warn("Failed to close database after WAL check", "error", closeErr) + } + }() + + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + var currentMode string + if err := db.QueryRow("PRAGMA journal_mode").Scan(¤tMode); err != nil { + return fmt.Errorf("failed to query journal mode: %w", err) + } + + if strings.EqualFold(currentMode, "wal") { + slog.Debug("Database already in WAL mode", "path", dbPath) + return nil + } + + var newMode string + if err := db.QueryRow("PRAGMA journal_mode=WAL").Scan(&newMode); err != nil { + return fmt.Errorf("failed to set WAL mode: %w", err) + } + + if !strings.EqualFold(newMode, "wal") { + return fmt.Errorf("failed to enable WAL mode: got %q instead", newMode) + } + + slog.Info("Enabled WAL mode on database", "path", dbPath) + return nil +} + // min returns the minimum of two integers func min(a, b int) int { if a < b { diff --git a/specstory-cli/pkg/providers/cursorcli/watcher.go b/specstory-cli/pkg/providers/cursorcli/watcher.go index 571f2864..cce2cebc 100644 --- a/specstory-cli/pkg/providers/cursorcli/watcher.go +++ b/specstory-cli/pkg/providers/cursorcli/watcher.go @@ -192,22 +192,21 @@ func (w *CursorWatcher) checkForChanges() { dbPath := filepath.Join(w.hashDir, sessionID, "store.db") // Check if database exists - fileInfo, err := os.Stat(dbPath) - if err != nil { + if _, err := os.Stat(dbPath); err != nil { continue // Skip sessions without store.db } // For NEW sessions, process them once and then watch for changes if !isKnown { // Check if we've already seen this new session - if w.hasSessionChanged(sessionID, fileInfo, dbPath) { + if w.hasSessionChanged(sessionID, dbPath) { slog.Info("Detected NEW Cursor session", "sessionId", sessionID) w.processSessionChanges(sessionID, dbPath) } } else if isResumed { // For resumed session, check for changes slog.Debug("Polling resumed session for changes", "sessionId", sessionID) - if w.hasSessionChanged(sessionID, fileInfo, dbPath) { + if w.hasSessionChanged(sessionID, dbPath) { slog.Info("Detected changes in resumed Cursor session", "sessionId", sessionID) w.processSessionChanges(sessionID, dbPath) } else { @@ -218,14 +217,14 @@ func (w *CursorWatcher) checkForChanges() { } // hasSessionChanged checks if a session database has new records -func (w *CursorWatcher) hasSessionChanged(sessionID string, fileInfo os.FileInfo, dbPath string) bool { +func (w *CursorWatcher) hasSessionChanged(sessionID string, dbPath string) bool { w.mu.Lock() defer w.mu.Unlock() // Always check record count - don't rely on file modification time // SQLite with WAL mode may not update file mtime when records are added - // Open database to count records (with WAL mode) + // Open database to count records (read-only with controlled pool) db, err := sql.Open("sqlite", dbPath+"?mode=ro") if err != nil { slog.Error("Failed to open database", "sessionId", sessionID, "error", err) @@ -237,10 +236,10 @@ func (w *CursorWatcher) hasSessionChanged(sessionID string, fileInfo os.FileInfo } }() - // Enable WAL mode for non-blocking reads - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - slog.Debug("Failed to enable WAL mode in watcher", "error", err) - } + // Limit the connection pool to prevent file descriptor accumulation + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(30 * time.Second) // Count total records in the blobs table var count int @@ -255,6 +254,12 @@ func (w *CursorWatcher) hasSessionChanged(sessionID string, fileInfo os.FileInfo w.lastCounts[sessionID] = count if !countExists { + // First time seeing this session - ensure WAL mode before we start polling it + if err := EnsureWALMode(dbPath); err != nil { + slog.Warn("Failed to ensure WAL mode on session database", + "sessionId", sessionID, "error", err) + } + // First time seeing this session - only process if it has content if count > 0 { slog.Debug("First check of session with content", "sessionId", sessionID, "recordCount", count) diff --git a/specstory-cli/pkg/providers/cursorcli/watcher_test.go b/specstory-cli/pkg/providers/cursorcli/watcher_test.go index 9a36acf4..0f076402 100644 --- a/specstory-cli/pkg/providers/cursorcli/watcher_test.go +++ b/specstory-cli/pkg/providers/cursorcli/watcher_test.go @@ -134,7 +134,7 @@ func TestCursorWatcherSessionDetection(t *testing.T) { } // Check session change detection - hasChanged := watcher.hasSessionChanged(sessionID, mustGetFileInfo(t, dbPath), dbPath) + hasChanged := watcher.hasSessionChanged(sessionID, dbPath) // The function will try to open the database and count blobs // Since this is not a real SQLite database with a blobs table, it will fail gracefully @@ -181,12 +181,3 @@ func TestCursorWatcherCallbackInvocation(t *testing.T) { } callbackMu.Unlock() } - -// Helper function to get FileInfo for a file -func mustGetFileInfo(t *testing.T, path string) os.FileInfo { - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Failed to stat file %s: %v", path, err) - } - return info -}