Skip to content
Merged
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
31 changes: 24 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
if (booted) return
booted = true
Expand All @@ -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<typeof createDirWatcher> | 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 {
Expand Down
55 changes: 54 additions & 1 deletion src/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +37,8 @@ export function createDirWatcher(
let closed = false
let debounce: NodeJS.Timeout | undefined
let polling: NodeJS.Timeout | undefined
let pollingRun: Promise<void> | undefined
let pollingSnapshot: Map<string, string> | undefined
let watcher: FSWatcher | undefined

const isExcluded = (name: string | null): boolean => {
Expand All @@ -54,11 +58,60 @@ export function createDirWatcher(
}, debounceMs)
}

const takePollingSnapshot = async (): Promise<Map<string, string>> => {
const entries = await readdir(root, { recursive: true, withFileTypes: true })
const snapshot = new Map<string, string>()
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<string, string>, right: Map<string, string>): 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<void> => {
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 {
Expand Down
78 changes: 78 additions & 0 deletions tests/index-startup-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
markInitStarted = resolve
})
const initGate = new Promise<void>((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<Record<string, unknown>> {
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<void>((resolve) => setImmediate(resolve))
assert.deepEqual(buildModes, ['refresh'])

callback()
assert.deepEqual(buildModes, ['refresh', 'refresh'])
} finally {
releaseInit()
dispose()
}
})
51 changes: 51 additions & 0 deletions tests/index-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
35 changes: 35 additions & 0 deletions tests/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,6 +15,7 @@ interface FakeWatcher {
}

const callbacks: WatchCallback[] = []
let forcePolling = false
const fakeWatcher: FakeWatcher = {
on(): typeof fakeWatcher {
return fakeWatcher
Expand All @@ -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
},
Expand Down Expand Up @@ -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 })
}
})