@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/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 watcher: FSWatcher | null = null;
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 chokidar instance, no leaked fds).
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.watcher) return;
515
-
516
- this.watcher = watch(this.hqRoot, {
517
- // `ignored` returns true to SKIP. chokidar probes a candidate for the
518
- // descent decision BEFORE stat()ing it (stats undefined on that call),
519
- // so a naive `!shouldEmit(path, stats?.isDirectory())` treats the
520
- // statless probe as a file and prunes intermediate dirs (`companies/`)
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 chokidar reported
546
- * it. Applies the emit filter then arms the debounce. Real chokidar events
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
- // Defensive: chokidar's `ignored` already filtered, but a path that slips
552
- // through (or a synthetic test event) is re-checked here.
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 chokidar watcher is active. */
665
+ /** True while the watch backend is active. */
591
666
  isWatching(): boolean {
592
- return this.watcher !== null;
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 chokidar watcher (releasing fds) and cancel any
602
- * pending debounce timer. Idempotent. The instance can be restarted with
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.watcher) {
612
- void this.watcher.close();
613
- this.watcher = null;
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
+ });