Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cursorindexingignore
Original file line number Diff line number Diff line change
@@ -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/**
57 changes: 47 additions & 10 deletions specstory-cli/pkg/providers/cursorcli/sqlite_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Comment on lines +357 to +361
db, err := sql.Open("sqlite", dbPath)
Comment on lines +357 to +362
if err != nil {
return fmt.Errorf("failed to open database for WAL check: %w", err)
}
defer func() {
Comment thread
bago2k4 marked this conversation as resolved.
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(&currentMode); 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 {
Expand Down
25 changes: 15 additions & 10 deletions specstory-cli/pkg/providers/cursorcli/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Comment on lines 224 to +227
db, err := sql.Open("sqlite", dbPath+"?mode=ro")
if err != nil {
slog.Error("Failed to open database", "sessionId", sessionID, "error", err)
Expand All @@ -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
Expand All @@ -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)
Expand Down
11 changes: 1 addition & 10 deletions specstory-cli/pkg/providers/cursorcli/watcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading