-
Notifications
You must be signed in to change notification settings - Fork 5
feat(watcher): chokidar auto-refresh with debounced incremental reindex #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import chokidar from 'chokidar'; | ||
|
|
||
| export interface FileWatcherOptions { | ||
| rootPath: string; | ||
| /** ms after last change before triggering. Default: 2000 */ | ||
| debounceMs?: number; | ||
| /** Called once the debounce window expires after the last detected change */ | ||
| onChanged: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Watch rootPath for source file changes and call onChanged (debounced). | ||
| * Returns a stop() function that cancels the debounce timer and closes the watcher. | ||
| */ | ||
| export function startFileWatcher(opts: FileWatcherOptions): () => void { | ||
| const { rootPath, debounceMs = 2000, onChanged } = opts; | ||
| let debounceTimer: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| const trigger = () => { | ||
| if (debounceTimer !== undefined) clearTimeout(debounceTimer); | ||
| debounceTimer = setTimeout(() => { | ||
| debounceTimer = undefined; | ||
| onChanged(); | ||
| }, debounceMs); | ||
| }; | ||
|
|
||
| const watcher = chokidar.watch(rootPath, { | ||
| ignored: ['**/node_modules/**', '**/.codebase-context/**', '**/.git/**', '**/dist/**'], | ||
| persistent: true, | ||
| ignoreInitial: true, | ||
| awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 } | ||
| }); | ||
|
|
||
| watcher | ||
| .on('add', trigger) | ||
| .on('change', trigger) | ||
| .on('unlink', trigger) | ||
| .on('error', (err: unknown) => console.error('[file-watcher] error:', err)); | ||
|
|
||
| return () => { | ||
| if (debounceTimer !== undefined) clearTimeout(debounceTimer); | ||
| void watcher.close(); | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,7 @@ import { | |
| } from './constants/codebase-context.js'; | ||
| import { appendMemoryFile } from './memory/store.js'; | ||
| import { handleCliCommand } from './cli.js'; | ||
| import { startFileWatcher } from './core/file-watcher.js'; | ||
| import { parseGitLogLineToMemory } from './memory/git-memory.js'; | ||
| import { | ||
| isComplementaryPatternCategory, | ||
|
|
@@ -251,6 +252,8 @@ const indexState: IndexState = { | |
| status: 'idle' | ||
| }; | ||
|
|
||
| let autoRefreshQueued = false; | ||
|
|
||
| const server: Server = new Server( | ||
| { | ||
| name: 'codebase-context', | ||
|
|
@@ -511,7 +514,7 @@ async function extractGitMemories(): Promise<number> { | |
| return added; | ||
| } | ||
|
|
||
| async function performIndexing(incrementalOnly?: boolean): Promise<void> { | ||
| async function performIndexingOnce(incrementalOnly?: boolean): Promise<void> { | ||
| indexState.status = 'indexing'; | ||
| const mode = incrementalOnly ? 'incremental' : 'full'; | ||
| console.error(`Indexing (${mode}): ${ROOT_PATH}`); | ||
|
|
@@ -565,6 +568,22 @@ async function performIndexing(incrementalOnly?: boolean): Promise<void> { | |
| } | ||
| } | ||
|
|
||
| async function performIndexing(incrementalOnly?: boolean): Promise<void> { | ||
| let nextMode = incrementalOnly; | ||
| for (;;) { | ||
| await performIndexingOnce(nextMode); | ||
|
|
||
| const shouldRunQueuedRefresh = autoRefreshQueued && indexState.status === 'ready'; | ||
| autoRefreshQueued = false; | ||
| if (!shouldRunQueuedRefresh) return; | ||
|
|
||
| if (process.env.CODEBASE_CONTEXT_DEBUG) { | ||
| console.error('[file-watcher] Running queued auto-refresh'); | ||
| } | ||
| nextMode = true; | ||
| } | ||
| } | ||
|
|
||
| async function shouldReindex(): Promise<boolean> { | ||
| const indexPath = PATHS.keywordIndex; | ||
| try { | ||
|
|
@@ -726,6 +745,37 @@ async function main() { | |
| await server.connect(transport); | ||
|
|
||
| if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready'); | ||
|
|
||
| // Auto-refresh: watch for file changes and trigger incremental reindex | ||
| const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10); | ||
| const debounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000; | ||
| const stopWatcher = startFileWatcher({ | ||
| rootPath: ROOT_PATH, | ||
| debounceMs, | ||
| onChanged: () => { | ||
| if (indexState.status === 'indexing') { | ||
| autoRefreshQueued = true; | ||
| if (process.env.CODEBASE_CONTEXT_DEBUG) { | ||
| console.error('[file-watcher] Index in progress — queueing auto-refresh'); | ||
| } | ||
| return; | ||
| } | ||
|
Comment on lines
+756
to
+762
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File changes during initial indexing are silently and permanently dropped
A simple mitigation is to track whether at least one change was skipped and trigger a follow-up incremental reindex after the initial run finishes: let pendingChanges = false;
onChanged: () => {
if (indexState.status === 'indexing') {
pendingChanges = true;
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error('[file-watcher] Index in progress — queuing auto-refresh');
}
return;
}
pendingChanges = false;
console.error('[file-watcher] Changes detected — incremental reindex starting');
performIndexing(true);
}Then, at the end of |
||
| if (process.env.CODEBASE_CONTEXT_DEBUG) { | ||
| console.error('[file-watcher] Changes detected — incremental reindex starting'); | ||
| } | ||
| void performIndexing(true); | ||
| } | ||
| }); | ||
|
|
||
| process.once('exit', stopWatcher); | ||
| process.once('SIGINT', () => { | ||
| stopWatcher(); | ||
| process.exit(0); | ||
| }); | ||
| process.once('SIGTERM', () => { | ||
| stopWatcher(); | ||
| process.exit(0); | ||
| }); | ||
| } | ||
|
|
||
| // Export server components for programmatic use | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | ||
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| import os from 'os'; | ||
| import { startFileWatcher } from '../src/core/file-watcher.js'; | ||
|
|
||
| describe('FileWatcher', () => { | ||
| let tempDir: string; | ||
|
|
||
| beforeEach(async () => { | ||
| tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-watcher-test-')); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await fs.rm(tempDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('triggers onChanged after debounce window', async () => { | ||
| const debounceMs = 400; | ||
| let callCount = 0; | ||
|
|
||
| const stop = startFileWatcher({ | ||
| rootPath: tempDir, | ||
| debounceMs, | ||
| onChanged: () => { callCount++; }, | ||
| }); | ||
|
|
||
| try { | ||
| // Give chokidar a moment to finish initializing before the first write | ||
| await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| await fs.writeFile(path.join(tempDir, 'test.ts'), 'export const x = 1;'); | ||
| // Wait for chokidar to pick up the event (including awaitWriteFinish stabilityThreshold) | ||
| // + debounce window + OS scheduling slack | ||
| await new Promise((resolve) => setTimeout(resolve, debounceMs + 1000)); | ||
| expect(callCount).toBe(1); | ||
| } finally { | ||
| stop(); | ||
| } | ||
| }, 8000); | ||
|
|
||
| it('debounces rapid changes into a single callback', async () => { | ||
| const debounceMs = 300; | ||
| let callCount = 0; | ||
|
|
||
| const stop = startFileWatcher({ | ||
| rootPath: tempDir, | ||
| debounceMs, | ||
| onChanged: () => { callCount++; }, | ||
| }); | ||
|
|
||
| try { | ||
| // Give chokidar a moment to finish initializing before the first write | ||
| await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| // Write 5 files in quick succession — all within the debounce window | ||
| for (let i = 0; i < 5; i++) { | ||
| await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`); | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| } | ||
| // Wait for debounce to settle | ||
| await new Promise((resolve) => setTimeout(resolve, debounceMs + 400)); | ||
| expect(callCount).toBe(1); | ||
| } finally { | ||
| stop(); | ||
| } | ||
| }, 8000); | ||
|
|
||
| it('stop() cancels a pending callback', async () => { | ||
| const debounceMs = 500; | ||
| let callCount = 0; | ||
|
|
||
| const stop = startFileWatcher({ | ||
| rootPath: tempDir, | ||
| debounceMs, | ||
| onChanged: () => { callCount++; }, | ||
| }); | ||
|
|
||
| // Give chokidar a moment to finish initializing before the first write | ||
| await new Promise((resolve) => setTimeout(resolve, 100)); | ||
| await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;'); | ||
| // Let chokidar detect the event (including awaitWriteFinish stabilityThreshold) | ||
| // but stop before the debounce window expires. | ||
| await new Promise((resolve) => setTimeout(resolve, 350)); | ||
| stop(); | ||
|
Comment on lines
+79
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test doesn't actually test debounce cancellation The test waits 150 ms before calling
To actually test cancellation of a live debounce timer, the wait before await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;');
// Wait long enough for chokidar's awaitWriteFinish (200 ms) to fire trigger(),
// but short enough that the debounce (500 ms) hasn't elapsed yet.
await new Promise((resolve) => setTimeout(resolve, 300));
stop();
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
expect(callCount).toBe(0); |
||
| // Wait past where debounce would have fired | ||
| await new Promise((resolve) => setTimeout(resolve, debounceMs + 200)); | ||
| expect(callCount).toBe(0); | ||
| }, 5000); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This branch drops file-change events whenever
indexState.status === 'indexing', so edits made during a long-running index (including the startup full index) can be permanently missed if no later change occurs. In that scenario the watcher never schedules another run, leaving the served index stale relative to disk until a manual refresh or another edit happens; this undermines the new auto-refresh guarantee.Useful? React with 👍 / 👎.