@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.
@@ -1,45 +1,57 @@
1
- import chokidar, { type FSWatcher as ChokidarWatcher } from "chokidar";
2
- import { existsSync, statSync, watch, type FSWatcher as NodeWatcher } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
3
2
  import type { IncomingMessage, ServerResponse } from "node:http";
4
- import { dirname, isAbsolute, relative, resolve } from "node:path";
5
-
3
+ import { dirname, isAbsolute, relative } from "node:path";
4
+
5
+ import {
6
+ createExactFileWatchListener,
7
+ createFileBrowserWatchRegistry,
8
+ type FileBrowserChangeEvent,
9
+ type FileBrowserWatchRegistry,
10
+ type FileBrowserWatchTarget,
11
+ type WatchEntryHandle,
12
+ } from "../generated/file-browser-watch-core.ts";
6
13
  import { isFileBrowserExcludedPath } from "../generated/reference-common.ts";
7
14
  import { resolveUserPath } from "../generated/resolve-file.ts";
8
15
  import { getGitMetadataWatchPaths } from "../generated/workspace-status.ts";
9
16
  import { json } from "./helpers.ts";
10
17
 
11
- interface FileBrowserChangeEvent {
12
- type: "ready" | "changed";
13
- dirPath: string;
14
- reason: "files" | "git" | "initial";
15
- timestamp: number;
16
- }
17
-
18
- interface WatchEntry {
19
- key: string;
20
- subscribers: Map<ServerResponse, string>;
21
- contentWatcher: ChokidarWatcher | NodeWatcher | null;
22
- gitWatcher: ChokidarWatcher | null;
23
- debounceTimer: ReturnType<typeof setTimeout> | null;
24
- }
25
-
26
- interface WatchTarget {
27
- key: string;
28
- watchPath: string;
29
- clientDirPath: string;
30
- watchGit: boolean;
31
- exactFilePath?: string;
32
- ignored?: (path: string) => boolean;
33
- }
18
+ // The watcher engine (deferred warmup, reconnect grace, native recursive
19
+ // backend) lives in the vendored file-browser-watch-core (#1313). This module
20
+ // keeps only the Pi transport: request parsing, the node:http SSE response,
21
+ // and heartbeats.
34
22
 
35
23
  const HEARTBEAT_MS = 30_000;
36
- const DEBOUNCE_MS = 180;
37
- const watchers = new Map<string, WatchEntry>();
38
24
 
39
25
  function serialize(event: FileBrowserChangeEvent): string {
40
26
  return `data: ${JSON.stringify(event)}\n\n`;
41
27
  }
42
28
 
29
+ const registry: FileBrowserWatchRegistry<ServerResponse> = createFileBrowserWatchRegistry<ServerResponse>({
30
+ send: (subscriber, event) => {
31
+ try {
32
+ subscriber.write(serialize(event));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ },
38
+ getGitMetadataWatchPaths,
39
+ });
40
+
41
+ export { createExactFileWatchListener };
42
+
43
+ /** Immediate teardown of every live watcher. Server stop and tests. */
44
+ export function closeAllFileBrowserWatchers(): void {
45
+ registry.closeAll();
46
+ }
47
+
48
+ /** Tests only. See FileBrowserWatchRegistry.diagnostics/configureForTests. */
49
+ export const __fileBrowserWatchTestHooks = {
50
+ diagnostics: () => registry.diagnostics(),
51
+ configure: (overrides: Parameters<FileBrowserWatchRegistry<ServerResponse>["configureForTests"]>[0]) =>
52
+ registry.configureForTests(overrides),
53
+ };
54
+
43
55
  export function isFileBrowserWatchIgnoredPath(path: string, root: string): boolean {
44
56
  const rel = relative(root, path).replace(/\\/g, "/");
45
57
  if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
@@ -54,136 +66,6 @@ function isValidDirectory(dirPath: string): boolean {
54
66
  }
55
67
  }
56
68
 
57
- function getFileSignature(filePath: string): string {
58
- try {
59
- const stats = statSync(filePath, { bigint: true });
60
- return stats.isDirectory()
61
- ? "directory"
62
- : `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
63
- } catch {
64
- return "missing";
65
- }
66
- }
67
-
68
- export function createExactFileWatchListener(
69
- watchPath: string,
70
- exactFilePath: string,
71
- onChange: () => void,
72
- ): (event: unknown, filename: string | Buffer | null | undefined) => void {
73
- let signature = getFileSignature(exactFilePath);
74
- return (_event, filename) => {
75
- try {
76
- const nextSignature = getFileSignature(exactFilePath);
77
- // Events on the watched directory itself arrive without a filename, as
78
- // null on some platforms and undefined on others (Bun on Linux).
79
- const eventMatches = filename == null
80
- || resolve(watchPath, filename.toString()) === exactFilePath;
81
- if (eventMatches || nextSignature !== signature) {
82
- signature = nextSignature;
83
- onChange();
84
- }
85
- } catch {
86
- // A watcher event must never take down the server.
87
- }
88
- };
89
- }
90
-
91
- function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void {
92
- for (const [res, clientDirPath] of entry.subscribers) {
93
- const payload = serialize({
94
- type: "changed",
95
- dirPath: clientDirPath,
96
- reason,
97
- timestamp: Date.now(),
98
- });
99
- try {
100
- res.write(payload);
101
- } catch {
102
- entry.subscribers.delete(res);
103
- }
104
- }
105
- }
106
-
107
- function scheduleBroadcast(entry: WatchEntry, reason: "files" | "git"): void {
108
- if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
109
- entry.debounceTimer = setTimeout(() => {
110
- entry.debounceTimer = null;
111
- broadcast(entry, reason);
112
- }, DEBOUNCE_MS);
113
- }
114
-
115
- function closeWatcher(entry: WatchEntry): void {
116
- if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
117
- void entry.contentWatcher?.close();
118
- void entry.gitWatcher?.close();
119
- if (watchers.get(entry.key) === entry) {
120
- watchers.delete(entry.key);
121
- }
122
- }
123
-
124
- function releaseSubscriber(entry: WatchEntry, res: ServerResponse): void {
125
- entry.subscribers.delete(res);
126
- if (entry.subscribers.size === 0) closeWatcher(entry);
127
- }
128
-
129
- function ensureWatcher(target: WatchTarget): WatchEntry {
130
- const existing = watchers.get(target.key);
131
- if (existing) return existing;
132
-
133
- const entry: WatchEntry = {
134
- key: target.key,
135
- subscribers: new Map(),
136
- contentWatcher: null,
137
- gitWatcher: null,
138
- debounceTimer: null,
139
- };
140
-
141
- if (target.exactFilePath) {
142
- entry.contentWatcher = watch(
143
- target.watchPath,
144
- { persistent: true },
145
- createExactFileWatchListener(target.watchPath, target.exactFilePath, () => scheduleBroadcast(entry, "files")),
146
- );
147
- entry.contentWatcher.on("error", () => scheduleBroadcast(entry, "files"));
148
- } else {
149
- entry.contentWatcher = chokidar.watch(target.watchPath, {
150
- ignoreInitial: true,
151
- persistent: true,
152
- ignored: target.ignored,
153
- awaitWriteFinish: {
154
- stabilityThreshold: 120,
155
- pollInterval: 30,
156
- },
157
- });
158
- entry.contentWatcher.on("all", () => {
159
- scheduleBroadcast(entry, "files");
160
- });
161
- entry.contentWatcher.on("error", () => scheduleBroadcast(entry, "files"));
162
- }
163
-
164
- const gitWatchPaths = target.watchGit
165
- ? getGitMetadataWatchPaths(target.watchPath)
166
- : [];
167
- if (gitWatchPaths.length > 0) {
168
- entry.gitWatcher = chokidar.watch(gitWatchPaths, {
169
- ignoreInitial: true,
170
- persistent: true,
171
- // These are exact metadata files. Keep this non-recursive so a future
172
- // target cannot make startup walk the repository's entire refs tree.
173
- depth: 0,
174
- awaitWriteFinish: {
175
- stabilityThreshold: 80,
176
- pollInterval: 30,
177
- },
178
- });
179
- entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git"));
180
- entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git"));
181
- }
182
-
183
- watchers.set(target.key, entry);
184
- return entry;
185
- }
186
-
187
69
  function isValidFileTarget(filePath: string): boolean {
188
70
  if (!filePath) return false;
189
71
  try {
@@ -204,7 +86,7 @@ export function handleFileBrowserStreamRequest(req: IncomingMessage, res: Server
204
86
  return true;
205
87
  }
206
88
 
207
- const targets = new Map<string, WatchTarget>();
89
+ const targets = new Map<string, FileBrowserWatchTarget & { clientDirPath: string }>();
208
90
  if (rawDirPaths.length > 0) {
209
91
  for (const rawDirPath of rawDirPaths) {
210
92
  const dirPath = resolveUserPath(rawDirPath);
@@ -244,8 +126,8 @@ export function handleFileBrowserStreamRequest(req: IncomingMessage, res: Server
244
126
  }
245
127
  }
246
128
 
247
- const subscriptions = [...targets.values()].map((target) => ({
248
- entry: ensureWatcher(target),
129
+ const subscriptions: Array<{ handle: WatchEntryHandle; clientDirPath: string }> = [...targets.values()].map((target) => ({
130
+ handle: registry.ensure(target),
249
131
  clientDirPath: target.clientDirPath,
250
132
  }));
251
133
  res.writeHead(200, {
@@ -254,28 +136,28 @@ export function handleFileBrowserStreamRequest(req: IncomingMessage, res: Server
254
136
  Connection: "keep-alive",
255
137
  });
256
138
  res.setTimeout(0);
257
- for (const { entry, clientDirPath } of subscriptions) {
139
+ for (const { handle, clientDirPath } of subscriptions) {
258
140
  res.write(serialize({
259
141
  type: "ready",
260
142
  dirPath: clientDirPath,
261
143
  reason: "initial",
262
144
  timestamp: Date.now(),
263
145
  }));
264
- entry.subscribers.set(res, clientDirPath);
146
+ registry.attach(handle, res, clientDirPath);
265
147
  }
266
148
 
267
149
  const heartbeat = setInterval(() => {
268
150
  try {
269
151
  res.write(": heartbeat\n\n");
270
152
  } catch {
271
- for (const { entry } of subscriptions) releaseSubscriber(entry, res);
153
+ for (const { handle } of subscriptions) registry.release(handle, res);
272
154
  clearInterval(heartbeat);
273
155
  }
274
156
  }, HEARTBEAT_MS);
275
157
 
276
158
  res.on("close", () => {
277
159
  clearInterval(heartbeat);
278
- for (const { entry } of subscriptions) releaseSubscriber(entry, res);
160
+ for (const { handle } of subscriptions) registry.release(handle, res);
279
161
  });
280
162
  return true;
281
163
  }
@@ -49,7 +49,7 @@ import {
49
49
  resolveAllowedDocPath,
50
50
  type FolderAnnotateHistory,
51
51
  } from "./reference.ts";
52
- import { handleFileBrowserStreamRequest } from "./file-browser-watch.ts";
52
+ import { closeAllFileBrowserWatchers, handleFileBrowserStreamRequest } from "./file-browser-watch.ts";
53
53
  import { getExtraMarkdownExtensions, resolveUserPath, warmFileListCache } from "../generated/resolve-file.ts";
54
54
  import { createExternalAnnotationHandler } from "./external-annotations.ts";
55
55
  import { createNodeAgentTerminalBridge } from "./agent-terminal.ts";
@@ -288,10 +288,11 @@ export async function startAnnotateServer(options: {
288
288
  // when rendering HTML. Only single local files (not URLs/folders/messages).
289
289
  const annotateProjectName = options.project ?? "_unknown";
290
290
  const annotateHistoryEnabled = resolveAnnotateHistory(loadConfig());
291
- // Single local file sessions are the only ones the annotate-history contract
292
- // covers: URL / folder / agent-message sessions never write session content
293
- // to the data dir. Both the version history below and the durable submit
294
- // records share this gate.
291
+ // Single local file sessions are the only ones this eager gate covers.
292
+ // URL and agent-message sessions never write session content to the data
293
+ // dir. Folder sessions do participate in per-file version history, but
294
+ // lazily through /api/doc (see computeFolderAnnotateHistory below), not
295
+ // here. The durable submit records stay single-local-file only.
295
296
  const singleFileLocalAnnotate =
296
297
  (options.mode || "annotate") === "annotate" && !/^https?:\/\//i.test(options.filePath);
297
298
  let annotateHistory: AnnotateHistoryResult | null = null;
@@ -344,10 +345,11 @@ export async function startAnnotateServer(options: {
344
345
  // stopped reading), so there is no narrower condition to key off.
345
346
  //
346
347
  // Scope: identical to the version-history gate above — single local files
347
- // only. annotate-last / URL / folder sessions were stateless before this
348
- // record existed and STAY stateless: their submissions quote agent messages
349
- // or fetched pages, which the documented annotateHistory contract never
350
- // covered writing to disk.
348
+ // only. annotate-last / URL / folder sessions never wrote submit
349
+ // records and still do not: their submissions quote agent messages or
350
+ // fetched pages, which this record was never meant to persist. (Folder
351
+ // sessions do write lazy per-file version history via /api/doc; that is
352
+ // a separate, documented pipeline with its own gate.)
351
353
  //
352
354
  // Returns whether the draft delete may proceed: true when the record was
353
355
  // written, when there was no user content to lose, or when the session
@@ -933,6 +935,9 @@ export async function startAnnotateServer(options: {
933
935
  stop: () => {
934
936
  // try/finally: a throwing dispose must never leave the listener bound.
935
937
  try {
938
+ // First: watchers hold the embedded host process alive, and a
939
+ // throwing disposal below must not strand them.
940
+ closeAllFileBrowserWatchers();
936
941
  clientLease.cancel();
937
942
  // Long-lived host process: an unclosed lease stream would keep its
938
943
  // heartbeat timer and socket alive past the session, and would keep
@@ -53,7 +53,7 @@ import {
53
53
  handleObsidianFilesRequest,
54
54
  handleObsidianVaultsRequest,
55
55
  } from "./reference.ts";
56
- import { handleFileBrowserStreamRequest } from "./file-browser-watch.ts";
56
+ import { closeAllFileBrowserWatchers, handleFileBrowserStreamRequest } from "./file-browser-watch.ts";
57
57
  import { warmFileListCache } from "../generated/resolve-file.ts";
58
58
  import { isArchiveDocumentMutation } from "../generated/archive-mode.ts";
59
59
 
@@ -485,6 +485,7 @@ export async function startPlanReviewServer(options: {
485
485
  stop: () => {
486
486
  // try/finally: a throwing dispose must never leave the listener bound.
487
487
  try {
488
+ closeAllFileBrowserWatchers();
488
489
  aiRuntime?.dispose();
489
490
  } finally {
490
491
  server.close();