diff --git a/src/index.ts b/src/index.ts index e3bb4e5..aa597fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,20 @@ export function apply(ctx: Context, config: PluginConfig): () => void { // autoIndex. Errors are logged, never thrown (the plugin stays usable — the // lazy first-search path will rebuild if the boot failed). let booted = false + let bootComplete = false + let refreshPending = false + let disposed = false + const refresh = (): void => { + if (disposed) return + if (!bootComplete) { + refreshPending = true + return + } + refreshPending = false + void index.build('refresh').catch((error) => { + logger.warn(`semantic-search refresh failed: ${error instanceof Error ? error.message : String(error)}`) + }) + } const boot = async (): Promise => { if (booted) return booted = true @@ -62,25 +76,28 @@ export function apply(ctx: Context, config: PluginConfig): () => void { } } catch (error) { logger.error(`semantic-search boot failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { + bootComplete = true + if (!disposed && refreshPending) { + refreshPending = false + refresh() + } } } void boot() let watcher: ReturnType | undefined if (resolved.watch) { - watcher = createDirWatcher(resolved.root, () => { - if (!index.ready) return - void index.build('refresh').catch((error) => { - logger.warn(`semantic-search refresh failed: ${error instanceof Error ? error.message : String(error)}`) - }) - }, { + const dataDirName = index.config.dataDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '.sema' + watcher = createDirWatcher(resolved.root, refresh, { debounceMs: resolved.watchDebounceMs, // never let our own index writes feed back into a rebuild - exclude: [index.config.dataDir.split(/[\\/]/).pop() ?? '.sema'], + exclude: [dataDirName], }) } return () => { + disposed = true watcher?.close() for (const dispose of disposers) { try { diff --git a/src/watcher.ts b/src/watcher.ts index 036f9d2..22977da 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -9,6 +9,8 @@ */ import { watch, type FSWatcher } from 'node:fs' +import { readdir, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' export interface WatchHandle { close(): void @@ -35,6 +37,8 @@ export function createDirWatcher( let closed = false let debounce: NodeJS.Timeout | undefined let polling: NodeJS.Timeout | undefined + let pollingRun: Promise | undefined + let pollingSnapshot: Map | undefined let watcher: FSWatcher | undefined const isExcluded = (name: string | null): boolean => { @@ -54,11 +58,60 @@ export function createDirWatcher( }, debounceMs) } + const takePollingSnapshot = async (): Promise> => { + const entries = await readdir(root, { recursive: true, withFileTypes: true }) + const snapshot = new Map() + for (const entry of entries) { + const filePath = join(entry.parentPath, entry.name) + const name = relative(root, filePath).replace(/\\/g, '/') + if (entry.isDirectory() || isExcluded(name)) continue + try { + const info = await stat(filePath) + if (!info.isFile()) continue + snapshot.set(name, `${info.size}:${info.mtimeMs}:${info.ctimeMs}`) + } catch { + // Ignore files that disappear while the snapshot is being collected. + } + } + return snapshot + } + + const snapshotsEqual = (left: Map, right: Map): boolean => { + if (left.size !== right.size) return false + for (const [name, signature] of left) { + if (right.get(name) !== signature) return false + } + return true + } + + const poll = async (): Promise => { + if (closed || pollingRun) return + pollingRun = (async () => { + try { + const next = await takePollingSnapshot() + if (closed) return + const previous = pollingSnapshot + pollingSnapshot = next + if (!previous || !snapshotsEqual(previous, next)) { + fire(null) + } + } catch { + // A transient scan failure should not create a refresh loop. + } finally { + pollingRun = undefined + } + })() + await pollingRun + } + const startPolling = (): void => { if (polling) return - polling = setInterval(() => fire(null), pollingMs) + polling = setInterval(() => { + void poll() + }, pollingMs) // keep the event loop alive only while an index is being watched polling.unref?.() + void poll() } try { diff --git a/tests/index-startup-watcher.test.ts b/tests/index-startup-watcher.test.ts new file mode 100644 index 0000000..a825144 --- /dev/null +++ b/tests/index-startup-watcher.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import { mock, test } from 'node:test' + +let watcherCallback: (() => void) | undefined +let releaseInit!: () => void +let markInitStarted!: () => void +const initStarted = new Promise((resolve) => { + markInitStarted = resolve +}) +const initGate = new Promise((resolve) => { + releaseInit = resolve +}) +const buildModes: string[] = [] + +class FakeSearchIndex { + readonly config: any + readonly providerId = 'test-provider' + private isReady = false + + constructor(config: any) { + this.config = config + } + + get ready(): boolean { + return this.isReady + } + + async init(): Promise<{ status: 'empty'; meta: null }> { + markInitStarted() + await initGate + return { status: 'empty', meta: null } + } + + build(mode: 'full' | 'refresh'): Promise> { + buildModes.push(mode) + return Promise.resolve({ mode, files: 0, chunks: 0, truncated: false }) + } +} + +mock.module('../src/engine/search.ts', { + namedExports: { SearchIndex: FakeSearchIndex }, +}) +mock.module('../src/watcher.ts', { + namedExports: { + createDirWatcher: (_root: string, onChange: () => void) => { + watcherCallback = onChange + return { close(): void {} } + }, + }, +}) + +const { apply } = await import('../src/index.ts') + +test('entry: drains queued reconciliation for an initially empty index', async () => { + const ctx: any = { + tools: { register: () => () => undefined }, + logger: () => ({ info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }), + } + const dispose = apply(ctx, { autoIndex: false, autosave: false, watch: true }) + + try { + await initStarted + const callback = watcherCallback + assert.ok(callback) + callback() + assert.deepEqual(buildModes, []) + + releaseInit() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(buildModes, ['refresh']) + + callback() + assert.deepEqual(buildModes, ['refresh', 'refresh']) + } finally { + releaseInit() + dispose() + } +}) diff --git a/tests/index-watcher.test.ts b/tests/index-watcher.test.ts new file mode 100644 index 0000000..c66860b --- /dev/null +++ b/tests/index-watcher.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { mock, test } from 'node:test' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const watcherOptions: Array<{ exclude?: string[] }> = [] + +mock.module('../src/watcher.ts', { + namedExports: { + createDirWatcher: (...args: unknown[]) => { + watcherOptions.push(args[2] as { exclude?: string[] }) + return { close(): void {} } + }, + }, +}) + +const { apply } = await import('../src/index.ts') + +test('entry: trims trailing separators before deriving the watcher exclusion name', async () => { + const root = await mkdtemp(join(tmpdir(), 'sema-index-watcher-')) + const registered: unknown[] = [] + const ctx: any = { + tools: { + register: (tool: unknown) => { + registered.push(tool) + return () => undefined + }, + }, + logger: () => ({ info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }), + } + + try { + const dispose = apply(ctx, { + root, + dataDir: `${join(root, '.sema')}/`, + provider: { kind: 'lexical', dimension: 64 }, + autoIndex: false, + autosave: false, + watch: true, + }) + + try { + assert.deepEqual(watcherOptions.at(-1)?.exclude, ['.sema']) + } finally { + dispose() + } + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/tests/watcher.test.ts b/tests/watcher.test.ts index ea0d5fd..f2b7be0 100644 --- a/tests/watcher.test.ts +++ b/tests/watcher.test.ts @@ -3,7 +3,10 @@ */ import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { mock, test } from 'node:test' +import { join } from 'node:path' +import { tmpdir } from 'node:os' type WatchCallback = (event: string, filename: string | Buffer | null) => void interface FakeWatcher { @@ -12,6 +15,7 @@ interface FakeWatcher { } const callbacks: WatchCallback[] = [] +let forcePolling = false const fakeWatcher: FakeWatcher = { on(): typeof fakeWatcher { return fakeWatcher @@ -22,6 +26,7 @@ const fakeWatcher: FakeWatcher = { mock.module('node:fs', { namedExports: { watch: (...args: unknown[]) => { + if (forcePolling) throw new Error('recursive watch unavailable') callbacks.push(args[2] as WatchCallback) return fakeWatcher }, @@ -50,3 +55,33 @@ test('watcher: excludes matching directory names at any depth', async () => { mock.reset() } }) + +test('watcher: polling reconciles once, then detects included changes without reacting to excluded changes', async () => { + forcePolling = true + const root = await mkdtemp(join(tmpdir(), 'sema-watcher-')) + await mkdir(join(root, '.sema')) + await writeFile(join(root, 'source.ts'), 'initial source') + await writeFile(join(root, '.sema', 'index.json'), 'initial index') + + let changes = 0 + const handle = createDirWatcher(root, () => { + changes += 1 + }, { debounceMs: 0, pollingMs: 250, exclude: ['.sema'] }) + + try { + await new Promise((resolve) => setTimeout(resolve, 350)) + assert.equal(changes, 1) + + await writeFile(join(root, '.sema', 'index.json'), 'updated index') + await new Promise((resolve) => setTimeout(resolve, 350)) + assert.equal(changes, 1) + + await writeFile(join(root, 'source.ts'), 'updated source') + await new Promise((resolve) => setTimeout(resolve, 350)) + assert.equal(changes, 2) + } finally { + handle.close() + forcePolling = false + await rm(root, { recursive: true, force: true }) + } +})