@plannotator/pi-extension 0.27.2 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/file-browser-watch-core.ts
2
+ /**
3
+ * Shared engine for the file-browser SSE watchers (Bun and Pi runtimes).
4
+ *
5
+ * #1313: the historical per-runtime implementations built a chokidar watcher
6
+ * over the whole workspace synchronously on the request path. chokidar's
7
+ * directory scan monopolizes the event loop for roughly 40ms per directory
8
+ * under Bun, so a 228-directory repository froze the entire server for about
9
+ * nine seconds, and because teardown was immediate on the last unsubscribe,
10
+ * every EventSource reconnect paid the scan again. This module fixes the
11
+ * class, once, for both runtimes:
12
+ *
13
+ * 1. Watcher construction is deferred off the request path, so the SSE ready
14
+ * event and concurrent API requests are served before any scan starts.
15
+ * 2. Teardown gets a reconnect grace window. A reload or transient disconnect
16
+ * reuses the warm watcher instead of rebuilding it from scratch.
17
+ * 3. On macOS and Windows the content watcher is the platform's native
18
+ * recursive fs.watch (measured at ~0ms for the same tree). chokidar
19
+ * remains the Linux backend (recursive fs.watch is unreliable there) and
20
+ * the runtime fallback whenever native watching fails. That fallback is a
21
+ * correctness fallback, not a performance one: the deferred warmup moves
22
+ * the scan off the first request, but a chokidar scan of a large tree
23
+ * still saturates the event loop while it runs. The reconnect grace keeps
24
+ * that a once-per-session cost instead of a per-reconnect one.
25
+ *
26
+ * The registry is transport-generic: the Bun runtime subscribes
27
+ * ReadableStream controllers, the Pi runtime subscribes node:http responses.
28
+ * Only the `send` callback differs.
29
+ */
30
+
31
+ import chokidar, { type FSWatcher as ChokidarWatcher } from "chokidar";
32
+ import { watch as nodeFsWatch, statSync, type FSWatcher as NodeWatcher } from "node:fs";
33
+ import { resolve } from "node:path";
34
+
35
+ export interface FileBrowserChangeEvent {
36
+ type: "ready" | "changed";
37
+ dirPath: string;
38
+ reason: "files" | "git" | "initial";
39
+ timestamp: number;
40
+ }
41
+
42
+ export interface FileBrowserWatchTarget {
43
+ key: string;
44
+ watchPath: string;
45
+ watchGit: boolean;
46
+ exactFilePath?: string;
47
+ ignored?: (path: string) => boolean;
48
+ }
49
+
50
+ export interface FileBrowserWatchRegistryOptions<S> {
51
+ /** Deliver one serialized event to one subscriber; false drops the subscriber. */
52
+ send: (subscriber: S, event: FileBrowserChangeEvent) => boolean;
53
+ getGitMetadataWatchPaths: (watchPath: string) => string[];
54
+ /** Reconnect grace before an unsubscribed watcher is torn down. */
55
+ teardownGraceMs?: number;
56
+ debounceMs?: number;
57
+ /**
58
+ * Tests force "chokidar" to exercise the fallback backend everywhere, or
59
+ * "native" to exercise the native branch and its fallback paths on
60
+ * platforms where auto would pick chokidar (Linux CI).
61
+ */
62
+ contentWatchBackend?: "auto" | "chokidar" | "native";
63
+ /** Test seam for native-watch failure modes. Defaults to fs.watch. */
64
+ nativeWatch?: typeof nodeFsWatch;
65
+ }
66
+
67
+ export interface WatchEntryHandle {
68
+ readonly key: string;
69
+ }
70
+
71
+ interface WatchEntry<S> extends WatchEntryHandle {
72
+ key: string;
73
+ subscribers: Map<S, string>;
74
+ contentWatcher: ChokidarWatcher | NodeWatcher | null;
75
+ gitWatcher: ChokidarWatcher | null;
76
+ debounceTimer: ReturnType<typeof setTimeout> | null;
77
+ /** Deferred off-request construction (#1313). */
78
+ warmupTimer: ReturnType<typeof setTimeout> | null;
79
+ /** Pending reconnect-grace teardown (#1313). */
80
+ teardownTimer: ReturnType<typeof setTimeout> | null;
81
+ closed: boolean;
82
+ }
83
+
84
+ export interface FileBrowserWatchRegistry<S> {
85
+ ensure(target: FileBrowserWatchTarget): WatchEntryHandle;
86
+ attach(handle: WatchEntryHandle, subscriber: S, clientDirPath: string): void;
87
+ release(handle: WatchEntryHandle, subscriber: S): void;
88
+ /** Immediate teardown of every entry. Server stop and tests. */
89
+ closeAll(): void;
90
+ /**
91
+ * Tests only: entry count and how many content watchers were ever
92
+ * constructed. The grace-period test pins that a reconnect does not
93
+ * rebuild (starts stays flat), which is exactly the #1313 regression.
94
+ */
95
+ diagnostics(): { entries: number; contentWatcherStarts: number };
96
+ /** Tests only: override timing/backend without module-scope mutation. */
97
+ configureForTests(overrides: Partial<Pick<FileBrowserWatchRegistryOptions<S>, "teardownGraceMs" | "debounceMs" | "contentWatchBackend" | "nativeWatch">>): void;
98
+ }
99
+
100
+ const DEFAULT_TEARDOWN_GRACE_MS = 30_000;
101
+ const DEFAULT_DEBOUNCE_MS = 180;
102
+
103
+ function getFileSignature(filePath: string): string {
104
+ try {
105
+ const stats = statSync(filePath, { bigint: true });
106
+ return stats.isDirectory()
107
+ ? "directory"
108
+ : `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
109
+ } catch {
110
+ return "missing";
111
+ }
112
+ }
113
+
114
+ export function createExactFileWatchListener(
115
+ watchPath: string,
116
+ exactFilePath: string,
117
+ onChange: () => void,
118
+ ): (event: unknown, filename: string | Buffer | null | undefined) => void {
119
+ let signature = getFileSignature(exactFilePath);
120
+ return (_event, filename) => {
121
+ try {
122
+ const nextSignature = getFileSignature(exactFilePath);
123
+ // Events on the watched directory itself arrive without a filename, as
124
+ // null on some platforms and undefined on others (Bun on Linux).
125
+ const eventMatches = filename == null
126
+ || resolve(watchPath, filename.toString()) === exactFilePath;
127
+ if (eventMatches || nextSignature !== signature) {
128
+ signature = nextSignature;
129
+ onChange();
130
+ }
131
+ } catch {
132
+ // A watcher event must never take down the server.
133
+ }
134
+ };
135
+ }
136
+
137
+ function nativeRecursiveSupported(): boolean {
138
+ return process.platform === "darwin" || process.platform === "win32";
139
+ }
140
+
141
+ export function createFileBrowserWatchRegistry<S>(
142
+ initialOptions: FileBrowserWatchRegistryOptions<S>,
143
+ ): FileBrowserWatchRegistry<S> {
144
+ const options = { ...initialOptions };
145
+ const watchers = new Map<string, WatchEntry<S>>();
146
+ let contentWatcherStarts = 0;
147
+
148
+ const teardownGraceMs = () => options.teardownGraceMs ?? DEFAULT_TEARDOWN_GRACE_MS;
149
+ const debounceMs = () => options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
150
+
151
+ function broadcast(entry: WatchEntry<S>, reason: "files" | "git"): void {
152
+ const hadSubscribers = entry.subscribers.size > 0;
153
+ for (const [subscriber, clientDirPath] of entry.subscribers) {
154
+ const event: FileBrowserChangeEvent = {
155
+ type: "changed",
156
+ dirPath: clientDirPath,
157
+ reason,
158
+ timestamp: Date.now(),
159
+ };
160
+ let delivered = false;
161
+ try {
162
+ delivered = options.send(subscriber, event);
163
+ } catch {
164
+ delivered = false;
165
+ }
166
+ if (!delivered) entry.subscribers.delete(subscriber);
167
+ }
168
+ // Dead subscribers discovered here never call release(), so an entry
169
+ // emptied by delivery failures must still enter the teardown grace or
170
+ // it lives until closeAll.
171
+ if (hadSubscribers && entry.subscribers.size === 0) scheduleTeardown(entry);
172
+ }
173
+
174
+ function scheduleBroadcast(entry: WatchEntry<S>, reason: "files" | "git"): void {
175
+ if (entry.closed) return;
176
+ if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
177
+ entry.debounceTimer = setTimeout(() => {
178
+ entry.debounceTimer = null;
179
+ broadcast(entry, reason);
180
+ }, debounceMs());
181
+ }
182
+
183
+ function isCurrent(entry: WatchEntry<S>): boolean {
184
+ return !entry.closed && watchers.get(entry.key) === entry;
185
+ }
186
+
187
+ function startChokidarContentWatcher(entry: WatchEntry<S>, target: FileBrowserWatchTarget): ChokidarWatcher {
188
+ const watcher = chokidar.watch(target.watchPath, {
189
+ ignoreInitial: true,
190
+ persistent: true,
191
+ ignored: target.ignored,
192
+ awaitWriteFinish: {
193
+ stabilityThreshold: 120,
194
+ pollInterval: 30,
195
+ },
196
+ });
197
+ watcher.on("all", () => scheduleBroadcast(entry, "files"));
198
+ watcher.on("error", () => scheduleBroadcast(entry, "files"));
199
+ return watcher;
200
+ }
201
+
202
+ function startContentWatcher(entry: WatchEntry<S>, target: FileBrowserWatchTarget): void {
203
+ contentWatcherStarts += 1;
204
+ if (target.exactFilePath) {
205
+ const exactFilePath = target.exactFilePath;
206
+ const watcher = (options.nativeWatch ?? nodeFsWatch)(
207
+ target.watchPath,
208
+ { persistent: true },
209
+ createExactFileWatchListener(target.watchPath, exactFilePath, () => scheduleBroadcast(entry, "files")),
210
+ );
211
+ watcher.on("error", () => scheduleBroadcast(entry, "files"));
212
+ entry.contentWatcher = watcher;
213
+ return;
214
+ }
215
+
216
+ const backend = options.contentWatchBackend ?? "auto";
217
+ if (backend === "native" || (backend === "auto" && nativeRecursiveSupported())) {
218
+ try {
219
+ const nativeWatch = options.nativeWatch ?? nodeFsWatch;
220
+ const watcher = nativeWatch(target.watchPath, { recursive: true, persistent: true }, (_event, filename) => {
221
+ try {
222
+ if (filename != null) {
223
+ const abs = resolve(target.watchPath, filename.toString());
224
+ if (target.ignored?.(abs)) return;
225
+ }
226
+ scheduleBroadcast(entry, "files");
227
+ } catch {
228
+ // A watcher event must never take down the server.
229
+ }
230
+ });
231
+ watcher.on("error", (error) => {
232
+ // Native watching failed at runtime. Swap to the chokidar backend
233
+ // and force one refresh so anything that changed during the swap
234
+ // window is not lost.
235
+ try {
236
+ watcher.close();
237
+ } catch {
238
+ // Already closed.
239
+ }
240
+ if (isCurrent(entry) && entry.contentWatcher === watcher) {
241
+ console.error(
242
+ `[plannotator] Native file watching failed for ${target.watchPath}; switching to the fallback watcher:`,
243
+ error,
244
+ );
245
+ contentWatcherStarts += 1;
246
+ entry.contentWatcher = startChokidarContentWatcher(entry, target);
247
+ scheduleBroadcast(entry, "files");
248
+ }
249
+ });
250
+ entry.contentWatcher = watcher;
251
+ return;
252
+ } catch (error) {
253
+ // Native creation failed. Fall through to chokidar.
254
+ console.error(
255
+ `[plannotator] Native file watching unavailable for ${target.watchPath}; using the fallback watcher:`,
256
+ error,
257
+ );
258
+ }
259
+ }
260
+ entry.contentWatcher = startChokidarContentWatcher(entry, target);
261
+ }
262
+
263
+ function buildWatchers(entry: WatchEntry<S>, target: FileBrowserWatchTarget): void {
264
+ if (!isCurrent(entry)) return;
265
+ try {
266
+ startContentWatcher(entry, target);
267
+ } catch (error) {
268
+ // A watcher that cannot start must not take down the stream, but the
269
+ // subscriber is now living without live refreshes; say so.
270
+ console.error(`[plannotator] File watcher failed to start for ${target.watchPath}:`, error);
271
+ }
272
+ try {
273
+ const gitWatchPaths = target.watchGit
274
+ ? options.getGitMetadataWatchPaths(target.watchPath)
275
+ : [];
276
+ if (gitWatchPaths.length > 0) {
277
+ entry.gitWatcher = chokidar.watch(gitWatchPaths, {
278
+ ignoreInitial: true,
279
+ persistent: true,
280
+ // These are exact metadata files. Keep this non-recursive so a future
281
+ // target cannot make startup walk the repository's entire refs tree.
282
+ depth: 0,
283
+ awaitWriteFinish: {
284
+ stabilityThreshold: 80,
285
+ pollInterval: 30,
286
+ },
287
+ });
288
+ entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git"));
289
+ entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git"));
290
+ }
291
+ } catch (error) {
292
+ // Same containment for the git metadata watcher.
293
+ console.error(`[plannotator] Git metadata watcher failed to start for ${target.watchPath}:`, error);
294
+ }
295
+ }
296
+
297
+ function closeEntry(entry: WatchEntry<S>): void {
298
+ entry.closed = true;
299
+ if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
300
+ if (entry.warmupTimer) clearTimeout(entry.warmupTimer);
301
+ if (entry.teardownTimer) clearTimeout(entry.teardownTimer);
302
+ entry.debounceTimer = null;
303
+ entry.warmupTimer = null;
304
+ entry.teardownTimer = null;
305
+ try {
306
+ void entry.contentWatcher?.close();
307
+ } catch {
308
+ // A throwing close must not block the rest of the teardown.
309
+ }
310
+ try {
311
+ void entry.gitWatcher?.close();
312
+ } catch {
313
+ // Same.
314
+ }
315
+ if (watchers.get(entry.key) === entry) {
316
+ watchers.delete(entry.key);
317
+ }
318
+ }
319
+
320
+ function scheduleTeardown(entry: WatchEntry<S>): void {
321
+ if (entry.closed) return;
322
+ if (entry.teardownTimer) clearTimeout(entry.teardownTimer);
323
+ const timer = setTimeout(() => {
324
+ entry.teardownTimer = null;
325
+ if (entry.subscribers.size === 0) closeEntry(entry);
326
+ }, teardownGraceMs());
327
+ // The grace window must never hold the process open after the server
328
+ // is otherwise done.
329
+ (timer as { unref?: () => void }).unref?.();
330
+ entry.teardownTimer = timer;
331
+ }
332
+
333
+ return {
334
+ ensure(target) {
335
+ const existing = watchers.get(target.key);
336
+ if (existing) {
337
+ // A resubscription inside the grace window reuses the warm entry.
338
+ if (existing.teardownTimer) {
339
+ clearTimeout(existing.teardownTimer);
340
+ existing.teardownTimer = null;
341
+ }
342
+ return existing;
343
+ }
344
+ const entry: WatchEntry<S> = {
345
+ key: target.key,
346
+ subscribers: new Map(),
347
+ contentWatcher: null,
348
+ gitWatcher: null,
349
+ debounceTimer: null,
350
+ warmupTimer: null,
351
+ teardownTimer: null,
352
+ closed: false,
353
+ };
354
+ // Construction is deferred off the request path: the caller's SSE
355
+ // ready event and concurrent API requests are served before any
356
+ // directory scan begins (#1313).
357
+ entry.warmupTimer = setTimeout(() => {
358
+ entry.warmupTimer = null;
359
+ buildWatchers(entry, target);
360
+ }, 0);
361
+ watchers.set(target.key, entry);
362
+ return entry;
363
+ },
364
+ attach(handle, subscriber, clientDirPath) {
365
+ const entry = watchers.get(handle.key);
366
+ if (!entry || entry.closed) return;
367
+ entry.subscribers.set(subscriber, clientDirPath);
368
+ },
369
+ release(handle, subscriber) {
370
+ const entry = watchers.get(handle.key);
371
+ if (!entry) return;
372
+ entry.subscribers.delete(subscriber);
373
+ if (entry.subscribers.size === 0) scheduleTeardown(entry);
374
+ },
375
+ closeAll() {
376
+ for (const entry of [...watchers.values()]) closeEntry(entry);
377
+ },
378
+ diagnostics() {
379
+ return { entries: watchers.size, contentWatcherStarts };
380
+ },
381
+ configureForTests(overrides) {
382
+ Object.assign(options, overrides);
383
+ },
384
+ };
385
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",