feat(watcher): forward FS events as workspace/didChangeWatchedFiles

LSP servers maintain their own workspace index built at initialize time
and rely on the client to push file-system events. Previously the daemon
only synced the single file being queried, so externally created/changed
files (codegen, build scripts, git checkout, the agent's own writes from
the perspective of other open files) left the server's index stale until
manual /lsp-destroy.

Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch)
that translates FS events into workspace/didChangeWatchedFiles batches.
Patterns come from the server via client/registerCapability (no
speculative watching). Ignores layer a tiny baseline (.git, .DS_Store)
over the repo's root .gitignore, with a fallback list for non-git
workspaces. Events debounce 50ms quiet / 500ms max wait.

Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather
than relative ones, so compileWatchers() matches each event against both
relative and absolute path forms. Caught by the integration test; unit
regression test added.

Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation.

- src/client.ts: honor register/unregisterCapability for
  workspace/didChangeWatchedFiles; advertise dynamicRegistration;
  expose getFileWatchers/onWatchersChanged/sendNotification
- src/watcher.ts: new WorkspaceWatcher with layered ignores,
  debounce+batch, Created+Deleted coalescing, dual-form glob matching
- src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS,
  LSP_DEBUG-gated pattern/event logging
- test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir
- test/integration/watcher-gopls.test.ts: end-to-end against gopls
- AGENTS.md: new "Workspace File Watching" section
- flake.nix: add go (required by gopls integration test)
This commit is contained in:
2026-05-19 23:43:32 -04:00
parent e143e05758
commit 77876264ee
9 changed files with 869 additions and 5 deletions

View File

@@ -0,0 +1,145 @@
// Watcher Integration Test — proves the FS-watcher → LSP wiring works
// end-to-end against gopls: a stale "undefined symbol" diagnostic clears
// after the missing file is created externally (no LSP query touches it).
//
// This is the canonical staleness scenario from `_scratch/plan-fs-watching.md`.
import { describe, it, before, after } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
setTestSocket,
stopTestDaemon,
runCliJson,
requireServer,
} from "../helpers.ts";
const skip = requireServer("gopls");
// Poll Until - Runs `fn` repeatedly until `predicate(result)` is true or
// the timeout elapses. We need this because gopls reanalysis after a
// workspace/didChangeWatchedFiles is asynchronous; diagnostics arrive on
// a `textDocument/publishDiagnostics` push that we then re-fetch.
async function pollUntil<T>(
fn: () => Promise<T>,
predicate: (v: T) => boolean,
timeoutMs: number,
intervalMs = 250,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
let last: T = await fn();
while (Date.now() < deadline) {
if (predicate(last)) return last;
await new Promise((r) => setTimeout(r, intervalMs));
last = await fn();
}
return last;
}
interface DiagResult {
[serverId: string]: { diagnostics?: { message: string }[] };
}
describe("watcher: gopls picks up externally-created files", { skip: skip ?? undefined }, () => {
let tmpDir: string;
let mainFile: string;
let helperFile: string;
const env = { ...process.env };
let cleanup: () => void;
before(async () => {
cleanup = setTestSocket(env);
await stopTestDaemon(env);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-gopls-watch-"));
// Minimal Go Module - go.mod is gopls's primary root marker.
fs.writeFileSync(path.join(tmpDir, "go.mod"), "module example.com/wtest\n\ngo 1.21\n");
mainFile = path.join(tmpDir, "main.go");
helperFile = path.join(tmpDir, "helper.go");
// main.go references Helper() which doesn't exist yet \u2014 should produce
// an "undefined: Helper" diagnostic.
fs.writeFileSync(
mainFile,
"package main\n\nfunc main() {\n\tHelper()\n}\n",
);
});
after(async () => {
await stopTestDaemon(env);
cleanup();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("initially reports undefined symbol", async () => {
// Poll - gopls's workspace load on a fresh tmp dir takes a beat; the
// first diagnostics call can return "No active builds contain ..."
// before the real analysis lands. Wait up to 15s for it to settle.
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return diags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
},
15000,
500,
);
const diags = result["gopls"]?.diagnostics ?? [];
const hasUndefined = diags.some((d) =>
d.message.toLowerCase().includes("undefined") || d.message.includes("Helper"),
);
assert.ok(
hasUndefined,
`Expected undefined-symbol diagnostic, got: ${JSON.stringify(diags)}`,
);
});
it("clears the diagnostic after helper.go is created externally", async () => {
// Create The Missing File Without Touching It Via LSP - This is the
// whole point: only chokidar + workspace/didChangeWatchedFiles can tell
// gopls about helper.go's existence.
fs.writeFileSync(
helperFile,
"package main\n\nfunc Helper() {}\n",
);
// Poll - Allow up to 15s for the watcher to fire, gopls to reanalyze,
// and the diagnostic to clear. Re-query the same file (main.go) only.
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return !diags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
},
15000,
500,
);
const finalDiags = result["gopls"]?.diagnostics ?? [];
const stillUndefined = finalDiags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
assert.ok(
!stillUndefined,
`Expected diagnostic to clear after creating helper.go, still got: ${JSON.stringify(finalDiags)}`,
);
});
});

249
test/unit/watcher.test.ts Normal file
View File

@@ -0,0 +1,249 @@
// Watcher Unit Tests — exercises WorkspaceWatcher against a temp dir with
// real chokidar. We use real FS because mocking it would test the mock,
// not the actual behavior under inotify.
import { describe, it, before, after, beforeEach } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts";
import { pathToUri } from "../../src/root.ts";
// Wait For - Polls a predicate up to timeoutMs. Returns true if it became
// true, false if it timed out. Used to wait on async chokidar events
// without arbitrary sleeps.
async function waitFor(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 25));
}
return predicate();
}
// Wait Quiet - Waits for the debounce window to elapse so any pending
// events flush. Slightly longer than DEBOUNCE_MAX_WAIT_MS.
const FLUSH_WAIT_MS = 700;
describe("WorkspaceWatcher", () => {
let tmpDir: string;
let received: FileEvent[][] = [];
let watcher: WorkspaceWatcher | null = null;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-watcher-"));
});
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(async () => {
if (watcher) {
await watcher.dispose();
watcher = null;
}
received = [];
// Reset Dir - Wipe contents between tests so file lists are predictable.
for (const entry of fs.readdirSync(tmpDir)) {
fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
}
});
it("emits Created event for new file matching pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
// Settle - chokidar's `ready` is implicit; give it a moment.
await new Promise((r) => setTimeout(r, 200));
fs.writeFileSync(path.join(tmpDir, "foo.ts"), "x");
const ok = await waitFor(() => received.length > 0);
assert.ok(ok, "Expected at least one batch");
const all = received.flat();
const match = all.find((e) => e.uri === pathToUri(path.join(tmpDir, "foo.ts")));
assert.ok(match, `Expected event for foo.ts, got ${JSON.stringify(all)}`);
assert.strictEqual(match.type, 1);
});
it("emits Deleted event when file removed", async () => {
const file = path.join(tmpDir, "del.ts");
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
fs.unlinkSync(file);
const ok = await waitFor(() =>
received.flat().some((e) => e.type === 3 && e.uri === pathToUri(file)),
);
assert.ok(ok, `Expected delete event, got ${JSON.stringify(received)}`);
});
it("emits Changed event when file content changes", async () => {
const file = path.join(tmpDir, "chg.ts");
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
fs.writeFileSync(file, "y");
const ok = await waitFor(() =>
received.flat().some((e) => e.type === 2 && e.uri === pathToUri(file)),
);
assert.ok(ok, `Expected change event, got ${JSON.stringify(received)}`);
});
it("skips files not matching the pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
fs.writeFileSync(path.join(tmpDir, "ignored.txt"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(
all.length,
0,
`Expected no events for .txt, got ${JSON.stringify(all)}`,
);
});
it("honors .gitignore at root", async () => {
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n");
fs.mkdirSync(path.join(tmpDir, "ignored-dir"));
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "x.ts"), "x");
fs.writeFileSync(path.join(tmpDir, "watched.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
const uris = all.map((e) => e.uri);
assert.ok(
uris.includes(pathToUri(path.join(tmpDir, "watched.ts"))),
"watched.ts should fire",
);
assert.ok(
!uris.some((u) => u.includes("ignored-dir")),
`ignored-dir/x.ts should be filtered, got ${JSON.stringify(uris)}`,
);
});
it("respects WatchKind to filter event types", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
// Kind 1 = Create only - delete events should be suppressed.
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
await new Promise((r) => setTimeout(r, 200));
const file = path.join(tmpDir, "only-create.ts");
fs.writeFileSync(file, "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
fs.unlinkSync(file);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
all.some((e) => e.type === 1),
"Expected create event",
);
assert.ok(
!all.some((e) => e.type === 3),
`Did not expect delete events, got ${JSON.stringify(all)}`,
);
});
it("batches multiple rapid events into one onEvents call", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
// Write Several Files Quickly - within the 50ms debounce window they
// should all land in one batch, capped at 500ms max wait.
for (let i = 0; i < 5; i++) {
fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x");
}
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`);
// The batches should be << 5 (debounce coalesces). Allow up to 2 in
// case the loop straddled a flush boundary.
assert.ok(
received.length <= 2,
`Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`,
);
});
it("ignores .git/ even when not in gitignore", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*" }]);
await new Promise((r) => setTimeout(r, 200));
fs.mkdirSync(path.join(tmpDir, ".git"));
fs.writeFileSync(path.join(tmpDir, ".git", "HEAD"), "ref: foo");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
!all.some((e) => e.uri.includes("/.git/")),
`.git contents should be ignored, got ${JSON.stringify(all)}`,
);
});
it("stops emitting after dispose", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await new Promise((r) => setTimeout(r, 200));
await watcher.dispose();
watcher = null;
fs.writeFileSync(path.join(tmpDir, "post-dispose.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(all.length, 0, `Expected no events after dispose`);
});
it("matches absolute-path glob patterns (gopls-style)", async () => {
// Regression - gopls registers e.g. `/tmp/.../**/*.{go,mod}` rather
// than a bare relative `**/*.go`. The matcher must accept both.
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
]);
await new Promise((r) => setTimeout(r, 200));
fs.writeFileSync(path.join(tmpDir, "helper.go"), "package x\n");
const ok = await waitFor(() =>
received
.flat()
.some((e) => e.uri === pathToUri(path.join(tmpDir, "helper.go"))),
);
assert.ok(ok, `Expected event for absolute glob, got ${JSON.stringify(received)}`);
});
it("empty pattern set means no watching", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([]);
await new Promise((r) => setTimeout(r, 200));
fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
assert.strictEqual(received.length, 0);
});
});