@indigoai-us/hq-cloud 5.27.0 → 5.29.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.
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +6 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +19 -2
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.d.ts +10 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/watcher.d.ts +14 -9
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +99 -41
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +19 -2
- package/src/bin/sync-runner.ts +6 -0
- package/src/cli/sync.ts +10 -0
- package/src/watcher.ts +118 -43
- package/test/e2e/watcher-recursive-backend.test.ts +115 -0
package/src/watcher.ts
CHANGED
|
@@ -381,6 +381,96 @@ function toChokidarIgnored(
|
|
|
381
381
|
};
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/** A started watch backend. `close()` releases its OS handle(s); idempotent. */
|
|
385
|
+
interface WatchBackend {
|
|
386
|
+
close(): void;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Platforms where Node's `fs.watch(dir, { recursive: true })` is natively
|
|
391
|
+
* supported by a SINGLE OS handle: macOS (FSEvents) and Windows
|
|
392
|
+
* (ReadDirectoryChangesW). On Linux, recursive `fs.watch` is NOT implemented
|
|
393
|
+
* (it throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`), so we fall back to
|
|
394
|
+
* chokidar there.
|
|
395
|
+
*/
|
|
396
|
+
function supportsRecursiveWatch(): boolean {
|
|
397
|
+
return process.platform === "darwin" || process.platform === "win32";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
|
|
402
|
+
*
|
|
403
|
+
* Backend selection (the whole point of this helper):
|
|
404
|
+
* - macOS / Windows → ONE recursive `fs.watch` handle for the entire tree.
|
|
405
|
+
* chokidar 4 dropped its `fsevents` backend, so on macOS it watches via
|
|
406
|
+
* kqueue, which costs ~1 open fd PER watched path — ~11k fds over a real
|
|
407
|
+
* HQ tree, which EMFILEs under the default soft `ulimit -n` (256) and
|
|
408
|
+
* silently kills the watcher (instant sync then falls back to the poll).
|
|
409
|
+
* A single recursive watch is 1 handle regardless of tree size.
|
|
410
|
+
* - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
|
|
411
|
+
*
|
|
412
|
+
* The recursive backend does NO descent-time filtering (it can't — the OS
|
|
413
|
+
* reports the whole subtree), so EVERY change is funneled to `onEvent`, which
|
|
414
|
+
* re-applies the emit filter via {@link TreeWatcher.handleEvent}. Excluded
|
|
415
|
+
* paths (`repos/`, `node_modules/`, out-of-scope companies) are cheaply
|
|
416
|
+
* dropped there before they arm the debounce. This is also why the recursive
|
|
417
|
+
* backend is immune to the `.hqinclude` ancestor-descent pruning that the
|
|
418
|
+
* chokidar backend needs {@link toChokidarIgnored} to avoid.
|
|
419
|
+
*/
|
|
420
|
+
function startTreeWatch(
|
|
421
|
+
hqRoot: string,
|
|
422
|
+
shouldEmit: WatchPathFilter,
|
|
423
|
+
onEvent: (absolutePath: string) => void,
|
|
424
|
+
onError: (err: unknown) => void,
|
|
425
|
+
): WatchBackend {
|
|
426
|
+
if (supportsRecursiveWatch()) {
|
|
427
|
+
try {
|
|
428
|
+
const native = fs.watch(
|
|
429
|
+
hqRoot,
|
|
430
|
+
{ recursive: true, persistent: true },
|
|
431
|
+
(_eventType, filename) => {
|
|
432
|
+
// `filename` is relative to hqRoot (or null/empty if the platform
|
|
433
|
+
// couldn't provide it — nothing actionable then). `String()` coerces
|
|
434
|
+
// both the string and (older @types/node) Buffer shapes.
|
|
435
|
+
if (filename == null) return;
|
|
436
|
+
const rel = String(filename);
|
|
437
|
+
if (rel === "") return;
|
|
438
|
+
onEvent(path.resolve(hqRoot, rel));
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
native.on("error", onError);
|
|
442
|
+
return { close: () => native.close() };
|
|
443
|
+
} catch (err) {
|
|
444
|
+
// Recursive watch unexpectedly unavailable — fall back to chokidar
|
|
445
|
+
// rather than leaving the daemon with no watcher at all.
|
|
446
|
+
onError(err);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const cw = watch(hqRoot, {
|
|
451
|
+
// chokidar fallback (Linux): see toChokidarIgnored for why the descent
|
|
452
|
+
// probe must not prune ancestor dirs of allowlisted leaves.
|
|
453
|
+
ignored: toChokidarIgnored(shouldEmit, hqRoot),
|
|
454
|
+
persistent: true,
|
|
455
|
+
ignoreInitial: true,
|
|
456
|
+
awaitWriteFinish: {
|
|
457
|
+
stabilityThreshold: 500,
|
|
458
|
+
pollInterval: 100,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
cw.on("add", onEvent)
|
|
462
|
+
.on("change", onEvent)
|
|
463
|
+
.on("unlink", onEvent)
|
|
464
|
+
.on("addDir", onEvent)
|
|
465
|
+
.on("unlinkDir", onEvent)
|
|
466
|
+
.on("error", onError);
|
|
467
|
+
return {
|
|
468
|
+
close: () => {
|
|
469
|
+
void cw.close();
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
384
474
|
/**
|
|
385
475
|
* Build the composite emit-decision predicate. Returns true when a change to
|
|
386
476
|
* `absolutePath` SHOULD wake the watcher (i.e. it survives every exclusion
|
|
@@ -478,7 +568,7 @@ export class TreeWatcher {
|
|
|
478
568
|
private readonly debounceMs: number;
|
|
479
569
|
private readonly clock: Clock;
|
|
480
570
|
private readonly shouldEmit: WatchPathFilter;
|
|
481
|
-
private
|
|
571
|
+
private backend: WatchBackend | null = null;
|
|
482
572
|
private timer: unknown = null;
|
|
483
573
|
private listeners = new Set<TreeChangeListener>();
|
|
484
574
|
/** Paths accumulated for the current (in-flight) debounce window. */
|
|
@@ -508,48 +598,33 @@ export class TreeWatcher {
|
|
|
508
598
|
|
|
509
599
|
/**
|
|
510
600
|
* Begin watching. Idempotent — a second call while already running is a
|
|
511
|
-
* no-op (no second
|
|
601
|
+
* no-op (no second watch backend, no leaked handles).
|
|
602
|
+
*
|
|
603
|
+
* Uses a SINGLE recursive `fs.watch` on macOS/Windows (1 OS handle for the
|
|
604
|
+
* whole tree) and falls back to chokidar on Linux. See {@link startTreeWatch}
|
|
605
|
+
* for why per-path watching is avoided (kqueue fd exhaustion → EMFILE).
|
|
512
606
|
*/
|
|
513
607
|
start(): void {
|
|
514
|
-
if (this.disposed || this.
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// that only survive the allowlist filter as directories — starving the
|
|
522
|
-
// in-scope leaves below them. toChokidarIgnored keeps a statless probe
|
|
523
|
-
// when EITHER the file or the directory reading would; the accurate
|
|
524
|
-
// isDir verdict is reapplied at event time in handleEvent.
|
|
525
|
-
ignored: toChokidarIgnored(this.shouldEmit, this.hqRoot),
|
|
526
|
-
persistent: true,
|
|
527
|
-
ignoreInitial: true,
|
|
528
|
-
awaitWriteFinish: {
|
|
529
|
-
stabilityThreshold: 500,
|
|
530
|
-
pollInterval: 100,
|
|
531
|
-
},
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
const onEvent = (absolutePath: string) => this.handleEvent(absolutePath);
|
|
535
|
-
this.watcher
|
|
536
|
-
.on("add", onEvent)
|
|
537
|
-
.on("change", onEvent)
|
|
538
|
-
.on("unlink", onEvent)
|
|
539
|
-
.on("addDir", onEvent)
|
|
540
|
-
.on("unlinkDir", onEvent)
|
|
541
|
-
.on("error", (err) => console.error("TreeWatcher error:", err));
|
|
608
|
+
if (this.disposed || this.backend) return;
|
|
609
|
+
this.backend = startTreeWatch(
|
|
610
|
+
this.hqRoot,
|
|
611
|
+
this.shouldEmit,
|
|
612
|
+
(absolutePath) => this.handleEvent(absolutePath),
|
|
613
|
+
(err) => console.error("TreeWatcher error:", err),
|
|
614
|
+
);
|
|
542
615
|
}
|
|
543
616
|
|
|
544
617
|
/**
|
|
545
|
-
* Test/seam entry point: feed a raw filesystem path as if
|
|
546
|
-
* it. Applies the emit filter then arms the debounce. Real
|
|
547
|
-
* route through here too
|
|
618
|
+
* Test/seam entry point: feed a raw filesystem path as if the backend
|
|
619
|
+
* reported it. Applies the emit filter then arms the debounce. Real watch
|
|
620
|
+
* events route through here too — and for the recursive backend this is the
|
|
621
|
+
* ONLY place filtering happens, so out-of-scope paths are dropped here.
|
|
548
622
|
*/
|
|
549
623
|
handleEvent(absolutePath: string): void {
|
|
550
624
|
if (this.disposed) return;
|
|
551
|
-
//
|
|
552
|
-
//
|
|
625
|
+
// The recursive backend does no descent-time filtering, so this is the
|
|
626
|
+
// authoritative emit gate; the chokidar backend's `ignored` pre-filters
|
|
627
|
+
// but a slipped-through (or synthetic test) path is re-checked here too.
|
|
553
628
|
if (!this.shouldEmit(absolutePath, false)) return;
|
|
554
629
|
const abs = path.resolve(absolutePath);
|
|
555
630
|
const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
|
|
@@ -587,9 +662,9 @@ export class TreeWatcher {
|
|
|
587
662
|
}
|
|
588
663
|
}
|
|
589
664
|
|
|
590
|
-
/** True while the
|
|
665
|
+
/** True while the watch backend is active. */
|
|
591
666
|
isWatching(): boolean {
|
|
592
|
-
return this.
|
|
667
|
+
return this.backend !== null;
|
|
593
668
|
}
|
|
594
669
|
|
|
595
670
|
/** Number of pending debounce timers — a leak check for tests. */
|
|
@@ -598,9 +673,9 @@ export class TreeWatcher {
|
|
|
598
673
|
}
|
|
599
674
|
|
|
600
675
|
/**
|
|
601
|
-
* Stop watching: close the
|
|
602
|
-
* pending debounce timer. Idempotent. The instance can be
|
|
603
|
-
* {@link start} unless {@link dispose} was called.
|
|
676
|
+
* Stop watching: close the watch backend (releasing its OS handle) and
|
|
677
|
+
* cancel any pending debounce timer. Idempotent. The instance can be
|
|
678
|
+
* restarted with {@link start} unless {@link dispose} was called.
|
|
604
679
|
*/
|
|
605
680
|
stop(): void {
|
|
606
681
|
if (this.timer !== null) {
|
|
@@ -608,9 +683,9 @@ export class TreeWatcher {
|
|
|
608
683
|
this.timer = null;
|
|
609
684
|
}
|
|
610
685
|
this.pending.clear();
|
|
611
|
-
if (this.
|
|
612
|
-
|
|
613
|
-
this.
|
|
686
|
+
if (this.backend) {
|
|
687
|
+
this.backend.close();
|
|
688
|
+
this.backend = null;
|
|
614
689
|
}
|
|
615
690
|
}
|
|
616
691
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REAL-fs regression E2E for the single-recursive-watch backend.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* chokidar 4 dropped its `fsevents` backend, so on macOS it watches via kqueue
|
|
6
|
+
* — ~1 open fd PER watched path. Over a real HQ tree (~11k files+dirs) that
|
|
7
|
+
* exhausts the default soft `ulimit -n` (256) with EMFILE, silently killing the
|
|
8
|
+
* watcher so instant sync degrades to the 10-min poll. The fix replaces the
|
|
9
|
+
* per-path chokidar watch with a SINGLE recursive `fs.watch` on macOS/Windows
|
|
10
|
+
* (1 OS handle for the whole tree), filtering events per-path at emit time.
|
|
11
|
+
*
|
|
12
|
+
* These tests pin the two load-bearing behaviors of that backend (both also
|
|
13
|
+
* hold for the chokidar Linux fallback, so the suite is backend-agnostic):
|
|
14
|
+
* 1. A file created under a subtree that DID NOT EXIST when the watcher
|
|
15
|
+
* started still fires — i.e. coverage doesn't depend on per-directory
|
|
16
|
+
* watch registration at start time.
|
|
17
|
+
* 2. An out-of-scope edit (`repos/`, excluded by DEFAULT_IGNORES) does NOT
|
|
18
|
+
* fire — the OS reports it under a recursive watch, and it must be dropped
|
|
19
|
+
* by the emit filter, not woken on.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
27
|
+
|
|
28
|
+
import { TreeWatcher } from "../../src/watcher.js";
|
|
29
|
+
|
|
30
|
+
let hqRoot: string;
|
|
31
|
+
let watcher: TreeWatcher | null = null;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
hqRoot = await mkdtemp(path.join(tmpdir(), "hqcloud-recwatch-e2e-"));
|
|
35
|
+
await writeFile(path.join(hqRoot, ".hqinclude"), "companies/*/knowledge/\n");
|
|
36
|
+
// Only the `acme` company exists at start; `newco` is created later to prove
|
|
37
|
+
// the recursive watch covers subtrees born after start().
|
|
38
|
+
await mkdir(path.join(hqRoot, "companies", "acme", "knowledge"), {
|
|
39
|
+
recursive: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
watcher?.dispose();
|
|
45
|
+
watcher = null;
|
|
46
|
+
await rm(hqRoot, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Resolve true if the watcher emits a debounced change within `ms`, else false. */
|
|
50
|
+
function waitForEmit(w: TreeWatcher, ms: number): Promise<boolean> {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let settled = false;
|
|
53
|
+
const off = w.onChange(() => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
settled = true;
|
|
56
|
+
off();
|
|
57
|
+
clearTimeout(t);
|
|
58
|
+
resolve(true);
|
|
59
|
+
});
|
|
60
|
+
const t = setTimeout(() => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
off();
|
|
64
|
+
resolve(false);
|
|
65
|
+
}, ms);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("event-push watcher -- single recursive watch backend", () => {
|
|
70
|
+
it(
|
|
71
|
+
"FIRES for an in-scope file created under a subtree that did not exist at start",
|
|
72
|
+
async () => {
|
|
73
|
+
watcher = new TreeWatcher({ hqRoot, debounceMs: 250, personalMode: false });
|
|
74
|
+
watcher.start();
|
|
75
|
+
// Let the recursive watch establish before any mutation.
|
|
76
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
77
|
+
|
|
78
|
+
// Create a brand-new company subtree AFTER the watcher started. A
|
|
79
|
+
// per-directory watcher would only catch this if it dynamically
|
|
80
|
+
// registered the new dirs; the recursive watch covers it inherently.
|
|
81
|
+
await mkdir(path.join(hqRoot, "companies", "newco", "knowledge"), {
|
|
82
|
+
recursive: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const emitted = waitForEmit(watcher, 4000);
|
|
86
|
+
await writeFile(
|
|
87
|
+
path.join(hqRoot, "companies", "newco", "knowledge", "fresh.md"),
|
|
88
|
+
"# fresh\n",
|
|
89
|
+
);
|
|
90
|
+
expect(await emitted).toBe(true);
|
|
91
|
+
},
|
|
92
|
+
8000,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it(
|
|
96
|
+
"does NOT fire for an out-of-scope edit the OS reports under the recursive watch (repos/)",
|
|
97
|
+
async () => {
|
|
98
|
+
watcher = new TreeWatcher({ hqRoot, debounceMs: 250, personalMode: false });
|
|
99
|
+
watcher.start();
|
|
100
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
101
|
+
|
|
102
|
+
// `repos/` is excluded by DEFAULT_IGNORES. Under a recursive OS watch the
|
|
103
|
+
// event is still delivered, so the emit filter — not watch-time pruning —
|
|
104
|
+
// is what must drop it.
|
|
105
|
+
await mkdir(path.join(hqRoot, "repos", "some-repo"), { recursive: true });
|
|
106
|
+
const emitted = waitForEmit(watcher, 2500);
|
|
107
|
+
await writeFile(
|
|
108
|
+
path.join(hqRoot, "repos", "some-repo", "code.ts"),
|
|
109
|
+
"export const x = 1;\n",
|
|
110
|
+
);
|
|
111
|
+
expect(await emitted).toBe(false);
|
|
112
|
+
},
|
|
113
|
+
8000,
|
|
114
|
+
);
|
|
115
|
+
});
|