ace-wake 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +96 -0
- data/handbook/extensions/ace-wake/index.js +457 -0
- data/handbook/extensions/ace-wake/package.json +3 -0
- data/handbook/extensions/ace-wake/wake/ask-bridge.js +102 -0
- data/handbook/extensions/ace-wake/wake/eyes-runtime.js +147 -0
- data/handbook/extensions/ace-wake/wake/eyes.js +152 -0
- data/handbook/extensions/ace-wake/wake/inbox-discovery.js +111 -0
- data/handbook/extensions/ace-wake/wake/loop-subscription.js +62 -0
- data/handbook/extensions/ace-wake/wake/types.js +112 -0
- data/handbook/extensions/ace-wake/wake/wake-command-parser.js +169 -0
- data/handbook/extensions/ace-wake/wake/wake-dispatcher.js +86 -0
- data/handbook/extensions/ace-wake/wake/wake-registry.js +772 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +984 -0
- data/lib/ace/wake/version.rb +7 -0
- data/lib/ace/wake.rb +25 -0
- metadata +62 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import * as nodePath from "node:path";
|
|
4
|
+
import { WakeError } from "./types.js";
|
|
5
|
+
import { expandInboxWatchTargets } from "./inbox-discovery.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a configured watch path to the filesystem target: relative paths
|
|
9
|
+
* resolve against `baseDir`, and an existing symlinked path resolves to its
|
|
10
|
+
* target so directory events match the watched file's real name.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} baseDir
|
|
13
|
+
* @param {string} path
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function canonicalizeWatchPath(baseDir, path) {
|
|
17
|
+
const resolved = nodePath.resolve(baseDir, path);
|
|
18
|
+
try {
|
|
19
|
+
return nodeFs.realpathSync(resolved);
|
|
20
|
+
} catch {
|
|
21
|
+
// A nonexistent path cannot be canonicalized; stat validation in start()
|
|
22
|
+
// reports the visible error instead.
|
|
23
|
+
return resolved;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Watch subscription port owning native filesystem watcher handles.
|
|
29
|
+
*
|
|
30
|
+
* start() resolves the configured path to a canonical absolute target (relative
|
|
31
|
+
* paths resolve against `baseDir`; symlinks resolve to their target), validates
|
|
32
|
+
* readable metadata, and records a baseline fingerprint. An injected
|
|
33
|
+
* `resolveTargets` port may expand the configured path to several targets —
|
|
34
|
+
* the ace-hitl inbox watch expands `.ace-local/hitl` to every linked
|
|
35
|
+
* worktree's inbox, because `ace-hitl create` writes reports inside the
|
|
36
|
+
* coworker's worktree, not the main checkout. Discovery re-runs on every
|
|
37
|
+
* change/reconcile pass, so worktrees linked AFTER registration are
|
|
38
|
+
* picked up without a re-registration or session reload. The fingerprint
|
|
39
|
+
* aggregates all
|
|
40
|
+
* targets; a target that does not exist yet is a stable "absent" state, so
|
|
41
|
+
* the first report into a freshly created inbox still wakes. Only later fingerprint
|
|
42
|
+
* changes wake the agent, so registering a watch never produces an initial
|
|
43
|
+
* wake. When a wake coalesces into one already queued, the fingerprint stays
|
|
44
|
+
* at the previously delivered state: the queued wake covers the change, and a
|
|
45
|
+
* further change queues a fresh follow-up after settlement.
|
|
46
|
+
*
|
|
47
|
+
* A readable target's signature is its content hash, so a rewrite with
|
|
48
|
+
* identical bytes (fresh mtime, same size) is not a change and never wakes;
|
|
49
|
+
* targets whose content is not readable (directories, denied files) keep the
|
|
50
|
+
* `mtimeMs:size` stat-only signature. Every wake callback receives a delta of
|
|
51
|
+
* the changed targets — watched path, change kind (created/modified/deleted),
|
|
52
|
+
* BEFORE→AFTER signatures, and a short capped content excerpt when the file is
|
|
53
|
+
* readable — so the woken agent can identify the case without a full reload.
|
|
54
|
+
*
|
|
55
|
+
* A watcher runtime error on the configured (primary) path closes the
|
|
56
|
+
* handle, marks the subscription inactive with a visible error via the
|
|
57
|
+
* injected `onDeactivate` hook, and never retries: the user removes and
|
|
58
|
+
* re-adds the watch explicitly. A runtime error on a DISCOVERED extra
|
|
59
|
+
* target (e.g. `ace-overseer prune` removed a watched worktree) drops only
|
|
60
|
+
* that target's watcher and keeps the subscription alive for every other
|
|
61
|
+
* worktree; discovery re-attaches the target if it reappears. `fail()`
|
|
62
|
+
* records the same inactive-error state without a native handle, which
|
|
63
|
+
* reconciliation uses for subscriptions that could not be restarted.
|
|
64
|
+
*
|
|
65
|
+
* Mount-aware poll fallback: on mounts where inotify events are unreliable
|
|
66
|
+
* (FUSE/virtiofs — the HITL transport folder mounts report no child events,
|
|
67
|
+
* so a registered watch would never fire), the port classifies each target
|
|
68
|
+
* through the injected `statfsFn` port and runs a 2.5 s reconcile timer
|
|
69
|
+
* that re-stats the fingerprint and wakes on change. fs.watch stays primary
|
|
70
|
+
* everywhere else; a failing or unsupported statfs keeps fs.watch primary
|
|
71
|
+
* rather than failing the watch.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} ports
|
|
74
|
+
* @param {(path: string, handlers: {onChange: () => void, onError: (error: Error) => void}) => {close: () => void}} ports.watchFactory
|
|
75
|
+
* @param {(path: string) => {mtimeMs: number, size: number}} ports.statFn
|
|
76
|
+
* @param {(path: string) => Buffer | undefined} [ports.readFn] Content reader for
|
|
77
|
+
* signatures and excerpts; returns undefined when the content is not readable
|
|
78
|
+
* (missing target, directory, denied content) and never throws. Defaults to
|
|
79
|
+
* a node:fs read of the whole file.
|
|
80
|
+
* @param {string} [ports.baseDir] Resolution base for relative watch paths.
|
|
81
|
+
* @param {(baseDir: string, path: string) => string} [ports.canonicalizeFn] Path canonicalization; defaults to resolve + realpath.
|
|
82
|
+
* @param {(reconcile: () => void) => unknown} [ports.scheduleReconcile] Schedules
|
|
83
|
+
* the post-registration reconcile pass. Native watcher initialization is
|
|
84
|
+
* asynchronous on some platforms, so a change landing in the registration
|
|
85
|
+
* gap may produce no callback; the reconcile re-stats once and wakes on a
|
|
86
|
+
* missed change. Defaults to a delayed macrotask.
|
|
87
|
+
* @param {(path: string) => {type: string | number}} [ports.statfsFn] Mount
|
|
88
|
+
* classifier for the poll fallback; `type` is the filesystem type (string
|
|
89
|
+
* where the platform reports one, Linux f_type magic number otherwise).
|
|
90
|
+
* Throws when the mount type is unavailable; the watch then keeps fs.watch
|
|
91
|
+
* primary. Defaults to node:fs.statfsSync.
|
|
92
|
+
* @param {number} [ports.pollIntervalMs] Reconcile cadence for targets on
|
|
93
|
+
* mounts with unreliable inotify events. Defaults to 2500.
|
|
94
|
+
* @param {() => void} [ports.onDeactivate] Invoked when a subscription leaves
|
|
95
|
+
* the active state so callers can refresh visible status.
|
|
96
|
+
* @returns {{
|
|
97
|
+
* start: (definition: import("./types.js").WatchDefinition, onWake: (definition: import("./types.js").WatchDefinition, delta: Array<import("./types.js").WatchDelta>) => {delivered: boolean, reason?: string} | undefined) => string,
|
|
98
|
+
* stop: (name: string) => void,
|
|
99
|
+
* stopAll: () => void,
|
|
100
|
+
* fail: (name: string, message: string) => void,
|
|
101
|
+
* errorOf: (name: string) => string | undefined,
|
|
102
|
+
* activeCount: () => number,
|
|
103
|
+
* }}
|
|
104
|
+
*/
|
|
105
|
+
const RECONCILE_DELAY_MS = 500;
|
|
106
|
+
// Reconcile cadence for targets on mounts whose inotify events are
|
|
107
|
+
// unreliable: fast enough that an ask+wait turn feels responsive, slow
|
|
108
|
+
// enough that a fingerprint re-stat per subscription stays cheap.
|
|
109
|
+
const POLL_INTERVAL_MS = 2500;
|
|
110
|
+
// Filesystem types whose event delivery cannot be trusted (the HITL
|
|
111
|
+
// transport folder is a virtiofs mount and is deaf to fs.watch).
|
|
112
|
+
const UNRELIABLE_FSTYPES = new Set(["virtiofs", "fuse", "fuseblk", "fusectl"]);
|
|
113
|
+
// Linux f_type magic numbers for the same unreliable family (node:fs.statfs
|
|
114
|
+
// reports the numeric f_type on Linux): VIRTIOFS, FUSE, FUSECTL.
|
|
115
|
+
const UNRELIABLE_FSTYPE_MAGIC = new Set([0x49475649, 0x65735546, 0x65735543]);
|
|
116
|
+
// How far above a not-yet-existing target an ancestor watch may climb
|
|
117
|
+
// (enough for <worktree>/.ace-local/hitl inside any sane worktree layout).
|
|
118
|
+
const MAX_ANCESTOR_CLIMB = 6;
|
|
119
|
+
// Content excerpt capped into a wake delta, in bytes.
|
|
120
|
+
const EXCERPT_BYTES = 200;
|
|
121
|
+
// Fingerprint segment of a target that does not (yet) exist.
|
|
122
|
+
const ABSENT_SEGMENT = "absent";
|
|
123
|
+
|
|
124
|
+
export function createWatchPort({ watchFactory, statFn, readFn = nodeReadFn, baseDir = process.cwd(), canonicalizeFn = canonicalizeWatchPath, resolveTargets = expandInboxWatchTargets, scheduleReconcile = defaultScheduleReconcile, statfsFn = nodeStatfsFn, pollIntervalMs = POLL_INTERVAL_MS, onDeactivate }) {
|
|
125
|
+
/** @type {Map<string, {watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, definition: import("./types.js").WatchDefinition, fingerprint: string, previousFingerprint: string, dirty: boolean, poll: boolean, onWake: Function}>} */
|
|
126
|
+
const active = new Map();
|
|
127
|
+
/** @type {Map<string, string>} */
|
|
128
|
+
const errors = new Map();
|
|
129
|
+
/** Cached mount classification per target path (mount types do not flip
|
|
130
|
+
* at interactive timescales; a failed classification is not cached). */
|
|
131
|
+
const mountClassCache = new Map();
|
|
132
|
+
/** @type {import("node:timers").NodeJS.Timeout | null} */
|
|
133
|
+
let pollTimer = null;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
/**
|
|
137
|
+
* @returns {string} The canonical absolute path being watched.
|
|
138
|
+
*/
|
|
139
|
+
start(definition, onWake) {
|
|
140
|
+
stopExisting(definition.name);
|
|
141
|
+
errors.delete(definition.name);
|
|
142
|
+
|
|
143
|
+
const canonicalPath = canonicalizeFn(baseDir, definition.path);
|
|
144
|
+
// The configured path itself must be readable; expansion targets may
|
|
145
|
+
// legitimately not exist yet (an inbox in a worktree that never
|
|
146
|
+
// received a report), so only the primary is validated strictly.
|
|
147
|
+
readBaseline(canonicalPath);
|
|
148
|
+
const targetPaths = resolveTargetsFor(resolveTargets, baseDir, definition.path, canonicalPath);
|
|
149
|
+
const watchers = targetPaths.flatMap((target) => attachWatcher(target, definition, onWake));
|
|
150
|
+
const paths = watchers.map((entry) => entry.path);
|
|
151
|
+
// A target watched through an ancestor was PROVEN absent just now;
|
|
152
|
+
// its baseline records that fact rather than a re-stat, so a report
|
|
153
|
+
// created between the proof and this recomputation stays a change
|
|
154
|
+
// (the scheduled reconcile below delivers it).
|
|
155
|
+
const provenAbsent = new Set(watchers.filter((each) => each.watchPath !== each.path).map((each) => each.path));
|
|
156
|
+
const fingerprint = aggregateFingerprint(paths, canonicalPath, provenAbsent);
|
|
157
|
+
|
|
158
|
+
active.set(definition.name, {
|
|
159
|
+
watchers,
|
|
160
|
+
paths,
|
|
161
|
+
primaryPath: canonicalPath,
|
|
162
|
+
definition,
|
|
163
|
+
fingerprint,
|
|
164
|
+
previousFingerprint: fingerprint,
|
|
165
|
+
dirty: false,
|
|
166
|
+
poll: paths.some((path) => mountIsUnreliable(path)),
|
|
167
|
+
onWake,
|
|
168
|
+
});
|
|
169
|
+
// Close the registration gap: a change between baseline capture and
|
|
170
|
+
// watcher effectiveness produces no callback, so re-stat once.
|
|
171
|
+
scheduleReconcile(() => handleChange(definition, onWake));
|
|
172
|
+
ensurePollTimer();
|
|
173
|
+
return canonicalPath;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
stop(name) {
|
|
177
|
+
stopExisting(name);
|
|
178
|
+
errors.delete(name);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
stopAll() {
|
|
182
|
+
for (const name of [...active.keys()]) {
|
|
183
|
+
stopExisting(name);
|
|
184
|
+
}
|
|
185
|
+
errors.clear();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Re-check subscriptions after the agent settles (pending markers
|
|
190
|
+
* cleared). Target discovery refreshes for EVERY active subscription:
|
|
191
|
+
* a worktree linked while the session was watching whose first report
|
|
192
|
+
* landed in its own (then-unwatched) inbox produced no watched-path
|
|
193
|
+
* event, so this settlement boundary is the channel that notices it.
|
|
194
|
+
* A subscription whose current state still differs from its last
|
|
195
|
+
* delivered fingerprint fires a fresh wake — no new filesystem event
|
|
196
|
+
* required.
|
|
197
|
+
*
|
|
198
|
+
* @param {(definition: import("./types.js").WatchDefinition) => {delivered: boolean, reason?: string} | undefined} onWake
|
|
199
|
+
*/
|
|
200
|
+
flushDirty(onWake) {
|
|
201
|
+
for (const entry of active.values()) {
|
|
202
|
+
// Settlement boundaries refresh target discovery for EVERY active
|
|
203
|
+
// subscription, not only dirty ones: a worktree linked long after
|
|
204
|
+
// start whose first report lands in its own (not yet watched)
|
|
205
|
+
// inbox produces no watched-path event, so this pass is the only
|
|
206
|
+
// channel that can notice the new target and wake.
|
|
207
|
+
refreshAttachments(entry, entry.definition, onWake);
|
|
208
|
+
if (active.get(entry.definition.name)?.definition !== entry.definition) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const next = currentFingerprint(entry, entry.definition.name);
|
|
212
|
+
if (next === undefined) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (next === entry.fingerprint) {
|
|
216
|
+
entry.dirty = false;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const outcome = onWake(entry.definition, deltaOf(entry.fingerprint, next));
|
|
220
|
+
// The fingerprint advances only when the wake actually crossed —
|
|
221
|
+
// a deferred outcome stays dirty for the next settlement boundary.
|
|
222
|
+
if (outcome && outcome.delivered === false) {
|
|
223
|
+
entry.dirty = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
entry.previousFingerprint = entry.fingerprint;
|
|
227
|
+
entry.fingerprint = next;
|
|
228
|
+
entry.dirty = false;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Mark a subscription's latest change as undelivered so flushDirty()
|
|
234
|
+
* re-checks it at the next settlement boundary. Used when a wake is
|
|
235
|
+
* deferred by retention or the dispatch serialization window.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} name
|
|
238
|
+
*/
|
|
239
|
+
markDirty(name) {
|
|
240
|
+
const entry = active.get(name);
|
|
241
|
+
if (entry) {
|
|
242
|
+
entry.dirty = true;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A dispatch attempt was never acknowledged (Pi rejected it in async
|
|
248
|
+
* preflight without any lifecycle event): revert each subscription to
|
|
249
|
+
* its last delivered state and mark it dirty, so flushDirty() re-fires
|
|
250
|
+
* the change at the next settlement — no loss, and no duplicate for
|
|
251
|
+
* wakes that were genuinely consumed.
|
|
252
|
+
*/
|
|
253
|
+
revertToDelivered(names) {
|
|
254
|
+
for (const name of names) {
|
|
255
|
+
const entry = active.get(name);
|
|
256
|
+
if (!entry) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
entry.fingerprint = entry.previousFingerprint;
|
|
260
|
+
entry.dirty = true;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Record a visible inactive-error state for a subscription without a
|
|
266
|
+
* native handle (used by reconciliation for watches that cannot restart).
|
|
267
|
+
*
|
|
268
|
+
* @param {string} name
|
|
269
|
+
* @param {string} message
|
|
270
|
+
*/
|
|
271
|
+
fail(name, message) {
|
|
272
|
+
stopExisting(name);
|
|
273
|
+
errors.set(name, message);
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
errorOf(name) {
|
|
277
|
+
return errors.get(name);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
activeCount() {
|
|
281
|
+
return active.size;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
function stopExisting(name) {
|
|
286
|
+
const entry = active.get(name);
|
|
287
|
+
if (entry !== undefined) {
|
|
288
|
+
for (const { watcher } of entry.watchers) {
|
|
289
|
+
try {
|
|
290
|
+
watcher.close();
|
|
291
|
+
} catch {
|
|
292
|
+
// Closing an already-broken watcher must not mask the original error.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
active.delete(name);
|
|
296
|
+
}
|
|
297
|
+
if (pollTimer !== null && !anyUnreliableActive()) {
|
|
298
|
+
stopPollTimer();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @returns {boolean} true when any active subscription watches an unreliable mount. */
|
|
303
|
+
function anyUnreliableActive() {
|
|
304
|
+
for (const entry of active.values()) {
|
|
305
|
+
if (entry.poll) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Arm the reconcile timer exactly while an unreliable-mount subscription exists. */
|
|
313
|
+
function ensurePollTimer() {
|
|
314
|
+
if (pollTimer !== null || !anyUnreliableActive()) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
pollTimer = setInterval(() => {
|
|
318
|
+
// One reconcile pass re-stats every active subscription's fingerprint;
|
|
319
|
+
// reliable-mount subscriptions compare identical and never wake.
|
|
320
|
+
for (const entry of [...active.values()]) {
|
|
321
|
+
handleChange(entry.definition, entry.onWake);
|
|
322
|
+
}
|
|
323
|
+
if (!anyUnreliableActive()) {
|
|
324
|
+
stopPollTimer();
|
|
325
|
+
}
|
|
326
|
+
}, pollIntervalMs);
|
|
327
|
+
// Never hold the host process open on the extension's behalf.
|
|
328
|
+
pollTimer.unref?.();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function stopPollTimer() {
|
|
332
|
+
if (pollTimer !== null) {
|
|
333
|
+
clearInterval(pollTimer);
|
|
334
|
+
pollTimer = null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Classify one target path's mount for the poll fallback. Best-effort:
|
|
340
|
+
* an unsupported or failing statfs keeps fs.watch primary instead of
|
|
341
|
+
* failing the watch, and such failures are not cached so a transient
|
|
342
|
+
* error retries on the next pass.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} path
|
|
345
|
+
* @returns {boolean}
|
|
346
|
+
*/
|
|
347
|
+
function mountIsUnreliable(path) {
|
|
348
|
+
const cached = mountClassCache.get(path);
|
|
349
|
+
if (cached !== undefined) {
|
|
350
|
+
return cached;
|
|
351
|
+
}
|
|
352
|
+
let unreliable = false;
|
|
353
|
+
try {
|
|
354
|
+
unreliable = mountTypeIsUnreliable(statfsFn(path));
|
|
355
|
+
} catch {
|
|
356
|
+
unreliable = false;
|
|
357
|
+
}
|
|
358
|
+
mountClassCache.set(path, unreliable);
|
|
359
|
+
return unreliable;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Shared change path for watcher callbacks and the reconcile pass. */
|
|
363
|
+
function handleChange(definition, onWake) {
|
|
364
|
+
const entry = active.get(definition.name);
|
|
365
|
+
// A callback from a removed registration must never touch its
|
|
366
|
+
// replacement: ignore obsolete subscriptions entirely.
|
|
367
|
+
if (entry === undefined || entry.definition !== definition) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
refreshAttachments(entry, definition, onWake);
|
|
371
|
+
// Re-attachment failure deactivates the subscription; a deactivated
|
|
372
|
+
// definition must not deliver a wake from the same pass.
|
|
373
|
+
if (active.get(definition.name)?.definition !== definition) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const next = currentFingerprint(entry, definition.name);
|
|
377
|
+
if (next === undefined) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (next === entry.fingerprint) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const outcome = onWake(definition, deltaOf(entry.fingerprint, next));
|
|
384
|
+
if (outcome && outcome.delivered === false) {
|
|
385
|
+
// Coalesced (a queued wake covers this change) or serialized (held back
|
|
386
|
+
// by the dispatch transition window): the change itself is undelivered,
|
|
387
|
+
// so mark the subscription dirty — flushDirty() re-checks it once the
|
|
388
|
+
// agent settles, without needing another filesystem event.
|
|
389
|
+
entry.dirty = true;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
entry.previousFingerprint = entry.fingerprint;
|
|
393
|
+
entry.fingerprint = next;
|
|
394
|
+
entry.dirty = false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function deactivate(name, message) {
|
|
398
|
+
stopExisting(name);
|
|
399
|
+
errors.set(name, message);
|
|
400
|
+
try {
|
|
401
|
+
onDeactivate?.();
|
|
402
|
+
} catch {
|
|
403
|
+
// Status rendering must never break the watcher callback.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function readBaseline(path) {
|
|
408
|
+
let stats;
|
|
409
|
+
try {
|
|
410
|
+
stats = statFn(path);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
throw new WakeError(`path is not readable: ${path} (${describeError(error)})`);
|
|
413
|
+
}
|
|
414
|
+
return stats;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function readBaselineOrError(path, name) {
|
|
418
|
+
try {
|
|
419
|
+
return statFn(path);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
deactivate(name, describeError(error));
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolve the watch targets for one configured path. An injected
|
|
428
|
+
* `resolveTargets` port (the ace-hitl inbox expansion) may return several
|
|
429
|
+
* directories; the validated primary canonical path is always watched,
|
|
430
|
+
* whatever the port returns.
|
|
431
|
+
*
|
|
432
|
+
* @param {((baseDir: string, path: string) => string[]) | undefined} resolveTargets
|
|
433
|
+
* @param {string} baseDir
|
|
434
|
+
* @param {string} configuredPath
|
|
435
|
+
* @param {string} canonicalPath
|
|
436
|
+
* @returns {string[]}
|
|
437
|
+
*/
|
|
438
|
+
function resolveTargetsFor(resolveTargets, baseDir, configuredPath, canonicalPath) {
|
|
439
|
+
if (!resolveTargets) {
|
|
440
|
+
return [canonicalPath];
|
|
441
|
+
}
|
|
442
|
+
const expanded = resolveTargets(baseDir, configuredPath)
|
|
443
|
+
.filter((path) => typeof path === "string" && path.length > 0)
|
|
444
|
+
.map((path) => nodePath.resolve(path));
|
|
445
|
+
return [...new Set([canonicalPath, ...expanded])];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Attach one native watcher for a target. A target that does not exist
|
|
450
|
+
* yet (a worktree inbox with no report so far) is watched through its
|
|
451
|
+
* nearest EXISTING ancestor directory: the ancestor's events re-stat the
|
|
452
|
+
* target, so the first report — which creates the inbox — still wakes.
|
|
453
|
+
* When no ancestor is readable the target is skipped until the next
|
|
454
|
+
* re-registration.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} target
|
|
457
|
+
* @param {import("./types.js").WatchDefinition} definition
|
|
458
|
+
* @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
|
|
459
|
+
* @returns {Array<{path: string, watchPath: string, watcher: {close: () => void}}>}
|
|
460
|
+
*/
|
|
461
|
+
function attachWatcher(target, definition, onWake) {
|
|
462
|
+
const handlers = {
|
|
463
|
+
onChange: () => handleChange(definition, onWake),
|
|
464
|
+
onError: (error) => {
|
|
465
|
+
// A callback from a removed registration must never touch its
|
|
466
|
+
// replacement.
|
|
467
|
+
const entry = active.get(definition.name);
|
|
468
|
+
if (entry === undefined || entry.definition !== definition) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (target !== entry.primaryPath) {
|
|
472
|
+
// A discovered extra target died (the worktree was pruned):
|
|
473
|
+
// drop only this target's watcher, keep the subscription.
|
|
474
|
+
dropTarget(entry, target);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
deactivate(definition.name, describeError(error));
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
let watchPath = target;
|
|
481
|
+
let attempts = 0;
|
|
482
|
+
for (;;) {
|
|
483
|
+
try {
|
|
484
|
+
statFn(watchPath);
|
|
485
|
+
break;
|
|
486
|
+
} catch {
|
|
487
|
+
if (attempts >= MAX_ANCESTOR_CLIMB || watchPath === nodePath.dirname(watchPath)) {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
attempts += 1;
|
|
491
|
+
watchPath = nodePath.dirname(watchPath);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return [{ path: target, watchPath, watcher: watchFactory(watchPath, handlers) }];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Re-run target discovery and re-attach targets that were watched
|
|
499
|
+
* through an ancestor because they did not exist yet. Runs on every
|
|
500
|
+
* change/reconcile pass — exactly when a formerly-absent target may
|
|
501
|
+
* have appeared, and when a worktree linked AFTER registration may have
|
|
502
|
+
* become discoverable: discovery (the ace-hitl inbox expansion over
|
|
503
|
+
* `git worktree list`) no longer runs only at registration, so a task
|
|
504
|
+
* worktree created while the session is already watching gets its
|
|
505
|
+
* inbox watched without any re-registration or reload.
|
|
506
|
+
*
|
|
507
|
+
* Linux inotify is non-recursive, so an ancestor's watch never sees a
|
|
508
|
+
* SECOND report created inside the now-existing target; once the target
|
|
509
|
+
* exists, close the ancestor watch and watch the real target directly.
|
|
510
|
+
*
|
|
511
|
+
* A newly discovered target that does not exist yet joins the baseline
|
|
512
|
+
* fingerprint as its PROVEN "absent" state (never a re-stat, so a report
|
|
513
|
+
* created between the discovery stat and the baseline recomputation stays
|
|
514
|
+
* a pending change) and a follow-up reconcile pass — mirroring start() —
|
|
515
|
+
* delivers any change that landed in the attach gap; its later appearance
|
|
516
|
+
* (the first report) is the change that wakes, not its discovery.
|
|
517
|
+
*
|
|
518
|
+
* @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
|
|
519
|
+
* @param {import("./types.js").WatchDefinition} definition
|
|
520
|
+
* @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
|
|
521
|
+
*/
|
|
522
|
+
function refreshAttachments(entry, definition, onWake) {
|
|
523
|
+
refreshDiscoveredTargets(entry, definition, onWake);
|
|
524
|
+
if (active.get(definition.name)?.definition !== definition) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
for (let index = 0; index < entry.watchers.length; index += 1) {
|
|
528
|
+
const attached = entry.watchers[index];
|
|
529
|
+
if (attached.watchPath === attached.path) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
statFn(attached.path);
|
|
534
|
+
} catch {
|
|
535
|
+
// Still absent: the ancestor watch remains the only channel.
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
// Attach the replacement BEFORE closing the old watcher:
|
|
540
|
+
// watchFactory can throw synchronously (EMFILE, EPERM, stat race),
|
|
541
|
+
// and handleChange runs inside native watcher callbacks — an
|
|
542
|
+
// uncaught throw here would crash the extension host. On failure
|
|
543
|
+
// the documented runtime-error posture applies: deactivate and
|
|
544
|
+
// never retry.
|
|
545
|
+
const replacement = attachWatcher(attached.path, definition, onWake);
|
|
546
|
+
if (replacement.length > 0) {
|
|
547
|
+
try {
|
|
548
|
+
attached.watcher.close();
|
|
549
|
+
} catch {
|
|
550
|
+
// Closing an already-broken watcher must not block re-attachment.
|
|
551
|
+
}
|
|
552
|
+
entry.watchers[index] = replacement[0];
|
|
553
|
+
}
|
|
554
|
+
} catch (error) {
|
|
555
|
+
deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Re-run target discovery for one subscription and attach watchers for
|
|
563
|
+
* targets that appeared since the last pass (a worktree linked after
|
|
564
|
+
* registration, whose inbox the registration-time expansion could not
|
|
565
|
+
* have seen). A new target that already exists keeps the OLD baseline
|
|
566
|
+
* fingerprint, so the pass's fingerprint comparison delivers the wake
|
|
567
|
+
* the missed event never could; a new target that is still absent joins
|
|
568
|
+
* the baseline as its stable "absent" state, so only its later
|
|
569
|
+
* appearance (the first report) wakes.
|
|
570
|
+
*
|
|
571
|
+
* @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
|
|
572
|
+
* @param {import("./types.js").WatchDefinition} definition
|
|
573
|
+
* @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
|
|
574
|
+
*/
|
|
575
|
+
function refreshDiscoveredTargets(entry, definition, onWake) {
|
|
576
|
+
if (!resolveTargets) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const targetPaths = resolveTargetsFor(resolveTargets, baseDir, definition.path, entry.primaryPath);
|
|
580
|
+
let attachedAny = false;
|
|
581
|
+
for (const target of targetPaths) {
|
|
582
|
+
if (entry.watchers.some((attached) => attached.path === target)) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
let attachedWatcher;
|
|
586
|
+
try {
|
|
587
|
+
attachedWatcher = attachWatcher(target, definition, onWake);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (attachedWatcher.length === 0) {
|
|
593
|
+
// No readable ancestor yet: retried on the next pass.
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
entry.watchers.push(attachedWatcher[0]);
|
|
597
|
+
entry.paths = entry.watchers.map((each) => each.path);
|
|
598
|
+
attachedAny = true;
|
|
599
|
+
if (mountIsUnreliable(target) && !entry.poll) {
|
|
600
|
+
// A newly discovered target sits on an unreliable mount: the whole
|
|
601
|
+
// subscription joins the poll fallback.
|
|
602
|
+
entry.poll = true;
|
|
603
|
+
ensurePollTimer();
|
|
604
|
+
}
|
|
605
|
+
if (attachedWatcher[0].watchPath !== target) {
|
|
606
|
+
// The baseline records the "absent" state PROVEN by the ancestor
|
|
607
|
+
// climb, never a re-stat: a report created between the two must
|
|
608
|
+
// remain a pending change. The follow-up reconcile scheduled
|
|
609
|
+
// below delivers it without waiting for another event.
|
|
610
|
+
// A primary deleted moments earlier (the throwaway project of a
|
|
611
|
+
// finished session torn down, a pruned worktree root) makes this
|
|
612
|
+
// computation throw: this pass runs inside watcher event
|
|
613
|
+
// callbacks, so the throw is routed through the documented
|
|
614
|
+
// deactivation path instead of escaping into the event loop as
|
|
615
|
+
// an uncaught ENOENT.
|
|
616
|
+
try {
|
|
617
|
+
const baseline = aggregateFingerprint(entry.paths, entry.primaryPath, new Set([target]));
|
|
618
|
+
entry.previousFingerprint = baseline;
|
|
619
|
+
entry.fingerprint = baseline;
|
|
620
|
+
} catch (error) {
|
|
621
|
+
deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (attachedAny) {
|
|
627
|
+
// Mirror start(): a change landing in the attach gap (the race
|
|
628
|
+
// above, or watcher initialization latency on the new target)
|
|
629
|
+
// produces no callback, so re-stat once.
|
|
630
|
+
scheduleReconcile(() => handleChange(definition, onWake));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Drop the watcher of one discovered extra target after a runtime error
|
|
636
|
+
* (typically `ace-overseer prune` removing the worktree): only that
|
|
637
|
+
* target's handle closes; the subscription stays active for the primary
|
|
638
|
+
* and every other worktree. The target's disappearance is absorbed into
|
|
639
|
+
* the baseline — a pruned worktree is not a report and must not wake.
|
|
640
|
+
*
|
|
641
|
+
* The baselines are adjusted by removing ONLY the dropped target's
|
|
642
|
+
* segment from the recorded fingerprint strings. Re-statting every
|
|
643
|
+
* remaining target would fold an undelivered change (a deferred wake
|
|
644
|
+
* left the fingerprint at its pre-change state) into the baseline,
|
|
645
|
+
* silently losing the pending report; the segment removal keeps every
|
|
646
|
+
* remaining target at its recorded state, so the settlement boundary
|
|
647
|
+
* still delivers it. It also never touches the filesystem at all, so a
|
|
648
|
+
* primary deleted moments earlier cannot throw from inside the native
|
|
649
|
+
* onError callback (the primary's own watcher error deactivates the
|
|
650
|
+
* subscription through the documented path).
|
|
651
|
+
*
|
|
652
|
+
* @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
|
|
653
|
+
* @param {string} target
|
|
654
|
+
*/
|
|
655
|
+
function dropTarget(entry, target) {
|
|
656
|
+
const remaining = [];
|
|
657
|
+
for (const attached of entry.watchers) {
|
|
658
|
+
if (attached.path === target) {
|
|
659
|
+
try {
|
|
660
|
+
attached.watcher.close();
|
|
661
|
+
} catch {
|
|
662
|
+
// Closing an already-broken watcher must not mask the original error.
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
remaining.push(attached);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (remaining.length === entry.watchers.length) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
entry.watchers = remaining;
|
|
672
|
+
entry.paths = remaining.map((each) => each.path);
|
|
673
|
+
// The `=` terminator makes the prefix match the segment of exactly this
|
|
674
|
+
// target, never a target whose path merely extends it.
|
|
675
|
+
const droppedPrefix = `${target}=`;
|
|
676
|
+
entry.fingerprint = withoutSegment(entry.fingerprint, droppedPrefix);
|
|
677
|
+
entry.previousFingerprint = withoutSegment(entry.previousFingerprint, droppedPrefix);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Remove one target's `path=fingerprint` segment from an aggregate
|
|
682
|
+
* fingerprint string, keeping every other segment verbatim.
|
|
683
|
+
*
|
|
684
|
+
* @param {string} fingerprint
|
|
685
|
+
* @param {string} segmentPrefix The exact `path=` prefix of the removed target.
|
|
686
|
+
* @returns {string}
|
|
687
|
+
*/
|
|
688
|
+
function withoutSegment(fingerprint, segmentPrefix) {
|
|
689
|
+
return fingerprint
|
|
690
|
+
.split("|")
|
|
691
|
+
.filter((segment) => !segment.startsWith(segmentPrefix))
|
|
692
|
+
.join("|");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Current aggregate fingerprint of a subscription. An unreadable
|
|
697
|
+
* PRIMARY deactivates the subscription (the configured path itself is
|
|
698
|
+
* the contract); an unreadable extra target is a stable "absent" state
|
|
699
|
+
* that changes to a real fingerprint once the target appears.
|
|
700
|
+
*
|
|
701
|
+
* @param {{paths: string[], primaryPath: string}} entry
|
|
702
|
+
* @param {string} name
|
|
703
|
+
* @returns {string | undefined}
|
|
704
|
+
*/
|
|
705
|
+
function currentFingerprint(entry, name) {
|
|
706
|
+
try {
|
|
707
|
+
return aggregateFingerprint(entry.paths, entry.primaryPath);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
deactivate(name, describeError(error));
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* @param {string[]} paths sorted target paths
|
|
716
|
+
* @param {string} primaryPath
|
|
717
|
+
* @param {Set<string>} [forcedAbsent] Targets recorded as "absent" without
|
|
718
|
+
* re-statting — the proven-at-discovery baseline of a newly attached
|
|
719
|
+
* target, so a report created in the stat/recompute gap stays a change.
|
|
720
|
+
* @returns {string}
|
|
721
|
+
*/
|
|
722
|
+
function aggregateFingerprint(paths, primaryPath, forcedAbsent) {
|
|
723
|
+
return [...paths]
|
|
724
|
+
.sort()
|
|
725
|
+
.map((path) => {
|
|
726
|
+
if (forcedAbsent?.has(path)) {
|
|
727
|
+
return `${path}=${ABSENT_SEGMENT}`;
|
|
728
|
+
}
|
|
729
|
+
if (path === primaryPath) {
|
|
730
|
+
return `${path}=${fingerprintOf(statFn(path), readFn(path))}`;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
return `${path}=${fingerprintOf(statFn(path), readFn(path))}`;
|
|
734
|
+
} catch {
|
|
735
|
+
return `${path}=${ABSENT_SEGMENT}`;
|
|
736
|
+
}
|
|
737
|
+
})
|
|
738
|
+
.join("|");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Build the wake delta for one delivered-fingerprint transition: every
|
|
743
|
+
* target whose signature changed, described by path, change kind, and
|
|
744
|
+
* BEFORE→AFTER signatures, plus a capped sanitized content excerpt when the
|
|
745
|
+
* target's content is readable.
|
|
746
|
+
*
|
|
747
|
+
* @param {string} before aggregate fingerprint of the last delivered state
|
|
748
|
+
* @param {string} after current aggregate fingerprint
|
|
749
|
+
* @returns {Array<import("./types.js").WatchDelta>}
|
|
750
|
+
*/
|
|
751
|
+
function deltaOf(before, after) {
|
|
752
|
+
const previous = segmentMap(before);
|
|
753
|
+
const deltas = [];
|
|
754
|
+
for (const [path, next] of segmentMap(after)) {
|
|
755
|
+
const prior = previous.get(path);
|
|
756
|
+
if (prior === next) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const delta = {
|
|
760
|
+
path,
|
|
761
|
+
kind: changeKind(prior, next),
|
|
762
|
+
before: prior ?? ABSENT_SEGMENT,
|
|
763
|
+
after: next,
|
|
764
|
+
};
|
|
765
|
+
if (next !== ABSENT_SEGMENT) {
|
|
766
|
+
const content = readFn(path);
|
|
767
|
+
if (content !== undefined) {
|
|
768
|
+
delta.excerpt = excerptOf(content);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
deltas.push(delta);
|
|
772
|
+
}
|
|
773
|
+
return deltas;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Default reconcile scheduler: a delayed macrotask after registration, timed
|
|
779
|
+
* to land once the native watcher is effective. Never holds the process open.
|
|
780
|
+
*
|
|
781
|
+
* @param {() => void} reconcile
|
|
782
|
+
*/
|
|
783
|
+
export function defaultScheduleReconcile(reconcile) {
|
|
784
|
+
const timer = setTimeout(reconcile, RECONCILE_DELAY_MS);
|
|
785
|
+
timer.unref?.();
|
|
786
|
+
return timer;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* @param {{type: string | number}} statsfs
|
|
791
|
+
* @returns {boolean}
|
|
792
|
+
*/
|
|
793
|
+
function mountTypeIsUnreliable(statsfs) {
|
|
794
|
+
const type = statsfs?.type;
|
|
795
|
+
if (typeof type === "string") {
|
|
796
|
+
return UNRELIABLE_FSTYPES.has(type.toLowerCase());
|
|
797
|
+
}
|
|
798
|
+
if (typeof type === "number") {
|
|
799
|
+
return UNRELIABLE_FSTYPE_MAGIC.has(type);
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Default statfsFn over node:fs. Reports the mount's filesystem type for the
|
|
806
|
+
* poll-fallback classification; throws when the platform has no statfs (the
|
|
807
|
+
* watch then keeps fs.watch primary).
|
|
808
|
+
*
|
|
809
|
+
* @param {string} path
|
|
810
|
+
* @returns {{type: string | number}}
|
|
811
|
+
*/
|
|
812
|
+
export function nodeStatfsFn(path) {
|
|
813
|
+
const stats = nodeFs.statfsSync(path);
|
|
814
|
+
return { type: stats.type };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Split an aggregate fingerprint into its per-target `path=signature` map.
|
|
819
|
+
*
|
|
820
|
+
* @param {string} fingerprint
|
|
821
|
+
* @returns {Map<string, string>}
|
|
822
|
+
*/
|
|
823
|
+
function segmentMap(fingerprint) {
|
|
824
|
+
return new Map(fingerprint.split("|").map((segment) => {
|
|
825
|
+
const at = segment.indexOf("=");
|
|
826
|
+
return [segment.slice(0, at), segment.slice(at + 1)];
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* @param {string | undefined} before previous signature (undefined = never seen)
|
|
832
|
+
* @param {string} after current signature
|
|
833
|
+
* @returns {"created" | "modified" | "deleted"}
|
|
834
|
+
*/
|
|
835
|
+
function changeKind(before, after) {
|
|
836
|
+
if (after === ABSENT_SEGMENT) {
|
|
837
|
+
return "deleted";
|
|
838
|
+
}
|
|
839
|
+
return before === undefined || before === ABSENT_SEGMENT ? "created" : "modified";
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* First bytes of the content as one sanitized single line: control characters
|
|
844
|
+
* are dropped and whitespace runs (newlines, tabs) collapse to single spaces.
|
|
845
|
+
*
|
|
846
|
+
* @param {Buffer} content
|
|
847
|
+
* @returns {string}
|
|
848
|
+
*/
|
|
849
|
+
function excerptOf(content) {
|
|
850
|
+
const answer = answerExcerpt(content);
|
|
851
|
+
if (answer !== undefined) {
|
|
852
|
+
return answer;
|
|
853
|
+
}
|
|
854
|
+
const head = content.subarray(0, EXCERPT_BYTES).toString("utf8");
|
|
855
|
+
return head
|
|
856
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
857
|
+
.replace(/\s+/g, " ")
|
|
858
|
+
.trim();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* The `## Answer` section of an answered HITL case, sanitized to one line:
|
|
863
|
+
* an ask wake must carry the answer content, not the file's frontmatter
|
|
864
|
+
* head. Returns undefined when the content carries no answer section (the
|
|
865
|
+
* head excerpt applies).
|
|
866
|
+
*
|
|
867
|
+
* @param {Buffer} content
|
|
868
|
+
* @returns {string | undefined}
|
|
869
|
+
*/
|
|
870
|
+
function answerExcerpt(content) {
|
|
871
|
+
const match = content.toString("utf8").match(/^## Answer\s*\n([\s\S]*?)(?=\n## |\s*$)/m);
|
|
872
|
+
const text = match?.[1]?.trim();
|
|
873
|
+
if (!text) {
|
|
874
|
+
return undefined;
|
|
875
|
+
}
|
|
876
|
+
const head = text.slice(0, EXCERPT_BYTES);
|
|
877
|
+
return `answer: ${head
|
|
878
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
879
|
+
.replace(/\s+/g, " ")
|
|
880
|
+
.trim()}`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Change signature of one target. Readable content signs a hash of the bytes,
|
|
885
|
+
* so a content-only rewrite (identical bytes, fresh mtime) is not a change;
|
|
886
|
+
* when the content is not readable (directory, denied file) the stat pair
|
|
887
|
+
* remains the signature.
|
|
888
|
+
*
|
|
889
|
+
* @param {{mtimeMs: number, size: number}} stats
|
|
890
|
+
* @param {Buffer | undefined} content
|
|
891
|
+
* @returns {string}
|
|
892
|
+
*/
|
|
893
|
+
function fingerprintOf(stats, content) {
|
|
894
|
+
if (content !== undefined) {
|
|
895
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
896
|
+
}
|
|
897
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* @param {unknown} error
|
|
902
|
+
* @returns {string}
|
|
903
|
+
*/
|
|
904
|
+
function describeError(error) {
|
|
905
|
+
return error instanceof Error ? error.message : String(error);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Default watchFactory over node:fs.watch.
|
|
910
|
+
*
|
|
911
|
+
* Directory targets are watched directly and accept any child event: on
|
|
912
|
+
* Linux (inotify) a child file's creation is delivered only to a watch on
|
|
913
|
+
* the directory itself, never to a watch on its parent. The mtime/size
|
|
914
|
+
* fingerprint comparison filters noise, so accepting every event is safe.
|
|
915
|
+
*
|
|
916
|
+
* File targets keep watching the parent directory and filter events to the
|
|
917
|
+
* target's basename: watching the file itself keeps following the original
|
|
918
|
+
* inode, so atomic replacement (write-to-temporary + rename) would silently
|
|
919
|
+
* detach the subscription after one change.
|
|
920
|
+
*
|
|
921
|
+
* @param {string} path Canonical path of the watched file or directory.
|
|
922
|
+
* @param {{onChange: () => void, onError: (error: Error) => void}} handlers
|
|
923
|
+
* @returns {{close: () => void}}
|
|
924
|
+
*/
|
|
925
|
+
export function nodeWatchFactory(path, handlers) {
|
|
926
|
+
if (nodeFs.statSync(path).isDirectory()) {
|
|
927
|
+
const watcher = nodeFs.watch(path, { persistent: false }, () => {
|
|
928
|
+
// Any child event is checked: the fingerprint comparison filters
|
|
929
|
+
// non-changes, including nested relative paths some platforms report.
|
|
930
|
+
handlers.onChange();
|
|
931
|
+
});
|
|
932
|
+
watcher.on("error", (error) => handlers.onError(error));
|
|
933
|
+
watcher.unref?.();
|
|
934
|
+
return watcher;
|
|
935
|
+
}
|
|
936
|
+
const directory = nodePath.dirname(path);
|
|
937
|
+
const target = nodePath.basename(path);
|
|
938
|
+
const watcher = nodeFs.watch(
|
|
939
|
+
directory,
|
|
940
|
+
{ persistent: false },
|
|
941
|
+
(_eventType, filename) => {
|
|
942
|
+
// filename is null on some platforms; the fingerprint comparison
|
|
943
|
+
// filters non-changes, so a null event is checked rather than trusted.
|
|
944
|
+
if (filename !== null && filename !== target) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
handlers.onChange();
|
|
948
|
+
},
|
|
949
|
+
);
|
|
950
|
+
watcher.on("error", (error) => handlers.onError(error));
|
|
951
|
+
// Never hold the host process open on the extension's behalf.
|
|
952
|
+
watcher.unref?.();
|
|
953
|
+
return watcher;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Default statFn over node:fs. Checks read access explicitly: stat metadata
|
|
958
|
+
* alone is readable for files whose contents are not (mode 000), and a watch
|
|
959
|
+
* on an unreadable file could never deliver meaningful wakes.
|
|
960
|
+
*
|
|
961
|
+
* @param {string} path
|
|
962
|
+
* @returns {{mtimeMs: number, size: number}}
|
|
963
|
+
*/
|
|
964
|
+
export function nodeStatFn(path) {
|
|
965
|
+
nodeFs.accessSync(path, nodeFs.constants.R_OK);
|
|
966
|
+
const stats = nodeFs.statSync(path);
|
|
967
|
+
return { mtimeMs: stats.mtimeMs, size: stats.size };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Default readFn over node:fs. Returns the file's bytes, or undefined when
|
|
972
|
+
* the content is not readable (missing target, directory, denied content):
|
|
973
|
+
* signature and excerpt reads must never fail a watch that stat validates.
|
|
974
|
+
*
|
|
975
|
+
* @param {string} path
|
|
976
|
+
* @returns {Buffer | undefined}
|
|
977
|
+
*/
|
|
978
|
+
export function nodeReadFn(path) {
|
|
979
|
+
try {
|
|
980
|
+
return nodeFs.readFileSync(path);
|
|
981
|
+
} catch {
|
|
982
|
+
return undefined;
|
|
983
|
+
}
|
|
984
|
+
}
|