@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.
- package/generated/file-browser-watch-core.ts +385 -0
- package/package.json +1 -1
- package/plannotator.html +2 -2
- package/review-editor.html +2 -2
- package/server/file-browser-watch.ts +48 -166
- package/server/serverAnnotate.ts +14 -9
- package/server/serverPlan.ts +2 -1
|
@@ -1,45 +1,57 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
160
|
+
for (const { handle } of subscriptions) registry.release(handle, res);
|
|
279
161
|
});
|
|
280
162
|
return true;
|
|
281
163
|
}
|
package/server/serverAnnotate.ts
CHANGED
|
@@ -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
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
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
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
package/server/serverPlan.ts
CHANGED
|
@@ -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();
|