@indigoai-us/hq-cloud 6.14.26 → 6.14.27

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.
@@ -2,11 +2,14 @@ import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
2
2
  import * as fs from "fs";
3
3
  import * as os from "os";
4
4
  import * as path from "path";
5
+ import { watch } from "chokidar";
5
6
  import {
6
7
  FakeClock,
7
8
  WatchPushDriver,
8
9
  TreeWatcher,
10
+ ChokidarWatchBudget,
9
11
  createWatchPathFilter,
12
+ toChokidarIgnored,
10
13
  PushEventEmitter,
11
14
  } from "./watcher.js";
12
15
  import { StaticFlagProvider } from "./sync/feature-flags.js";
@@ -465,6 +468,87 @@ describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
465
468
  });
466
469
  });
467
470
 
471
+ describe("US-002: chokidar descent scope", () => {
472
+ let dir: string;
473
+
474
+ beforeEach(() => {
475
+ dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-scope-")));
476
+ // This is the real HQ allowlist shape. The synthetic heavy directories
477
+ // deliberately sit both under an in-scope company and under the two
478
+ // never-synced top-level buckets from #156.
479
+ fs.writeFileSync(path.join(dir, ".hqinclude"), "companies/*/knowledge/\n");
480
+ fs.mkdirSync(
481
+ path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy"),
482
+ { recursive: true },
483
+ );
484
+ fs.writeFileSync(
485
+ path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy", "index.js"),
486
+ "module.exports = 1;\n",
487
+ );
488
+ fs.mkdirSync(path.join(dir, "repos", "checkout", "heavy"), { recursive: true });
489
+ fs.mkdirSync(
490
+ path.join(dir, "workspace", "worktrees", "repo", "node_modules", "heavy"),
491
+ { recursive: true },
492
+ );
493
+ });
494
+
495
+ afterEach(() => {
496
+ fs.rmSync(dir, { recursive: true, force: true });
497
+ });
498
+
499
+ it("uses the directory verdict, not a file-shaped false positive, for the pre-stat probe", () => {
500
+ const filter = createWatchPathFilter(dir, false);
501
+ const nodeModules = path.join(
502
+ dir,
503
+ "companies",
504
+ "acme",
505
+ "knowledge",
506
+ "node_modules",
507
+ );
508
+ // `node_modules/` is directory-only. Asking the generic emit filter as
509
+ // though it were a file returns true, which is exactly what the old
510
+ // `file || directory` probe used to mistake for permission to descend.
511
+ expect(filter(nodeModules, false)).toBe(true);
512
+ expect(filter(nodeModules, true)).toBe(false);
513
+ const ignored = toChokidarIgnored(filter, dir);
514
+ expect(ignored(nodeModules)).toBe(true);
515
+ // The allowlist's exact ancestor matcher remains the only carve-out: the
516
+ // watcher can still reach companies/<slug>/knowledge without opening every
517
+ // otherwise-file-shaped ignored directory below it.
518
+ expect(ignored(path.join(dir, "companies"))).toBe(false);
519
+ expect(ignored(path.join(dir, "companies", "acme"))).toBe(false);
520
+ });
521
+
522
+ it("prunes ignored directories during chokidar's pre-stat descent probe", async () => {
523
+ const filter = createWatchPathFilter(dir, false);
524
+ const chokidarWatcher = watch(dir, {
525
+ ignored: toChokidarIgnored(filter, dir),
526
+ ignoreInitial: true,
527
+ persistent: false,
528
+ });
529
+ try {
530
+ await new Promise<void>((resolve, reject) => {
531
+ chokidarWatcher.once("ready", resolve);
532
+ chokidarWatcher.once("error", reject);
533
+ });
534
+ const descended = Object.keys(chokidarWatcher.getWatched())
535
+ .map((watchedPath) => path.resolve(watchedPath))
536
+ .sort();
537
+
538
+ expect(descended).toContain(
539
+ path.join(dir, "companies", "acme", "knowledge"),
540
+ );
541
+ expect(descended).not.toContain(
542
+ path.join(dir, "companies", "acme", "knowledge", "node_modules"),
543
+ );
544
+ expect(descended).not.toContain(path.join(dir, "repos"));
545
+ expect(descended).not.toContain(path.join(dir, "workspace", "worktrees"));
546
+ } finally {
547
+ await chokidarWatcher.close();
548
+ }
549
+ });
550
+ });
551
+
468
552
  describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
469
553
  function makeWatcher(opts?: { debounceMs?: number; personalMode?: boolean }) {
470
554
  const clock = new FakeClock();
@@ -555,6 +639,170 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
555
639
  expect(source).not.toContain("startPollingTreeWatch");
556
640
  expect(source).not.toContain("snapshotWatchTree");
557
641
  });
642
+
643
+ it("stops descending at the hard watch budget, reports, and degrades to polling", () => {
644
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-cap-")));
645
+ fs.mkdirSync(path.join(dir, "personal", "a", "deep"), { recursive: true });
646
+ fs.mkdirSync(path.join(dir, "personal", "b", "deep"), { recursive: true });
647
+ const clock = new FakeClock();
648
+ const degraded = vi.fn();
649
+ const changed = vi.fn();
650
+ const descended: string[] = [];
651
+ let backendClosed = false;
652
+ try {
653
+ const watcher = new TreeWatcher({
654
+ hqRoot: dir,
655
+ clock,
656
+ debounceMs: DEBOUNCE,
657
+ maxWatchedPaths: 3,
658
+ onDegraded: degraded,
659
+ // This faked backend drives chokidar's real ignored predicate through
660
+ // its pre-stat and post-stat calls, without needing an inotify slot on
661
+ // a host already exhausted by another process.
662
+ backendFactory: ({
663
+ hqRoot,
664
+ shouldEmit,
665
+ maxWatchedPaths,
666
+ onWatchBudgetExceeded,
667
+ }) => {
668
+ const budget = new ChokidarWatchBudget(
669
+ maxWatchedPaths,
670
+ onWatchBudgetExceeded,
671
+ );
672
+ const ignored = toChokidarIgnored(shouldEmit, hqRoot, budget);
673
+ const visit = (candidate: string): void => {
674
+ if (ignored(candidate)) return;
675
+ const stats = fs.lstatSync(candidate);
676
+ if (ignored(candidate, stats)) return;
677
+ if (!stats.isDirectory()) return;
678
+ descended.push(path.resolve(candidate));
679
+ for (const entry of fs.readdirSync(candidate)) {
680
+ visit(path.join(candidate, entry));
681
+ }
682
+ };
683
+ visit(hqRoot);
684
+ return {
685
+ close: () => {
686
+ backendClosed = true;
687
+ },
688
+ needsKnownKinds: false,
689
+ watchedPathCount: () => descended.length,
690
+ };
691
+ },
692
+ });
693
+ watcher.onChange(changed);
694
+ watcher.start();
695
+
696
+ expect(descended).toHaveLength(3);
697
+ expect(descended).not.toContain(path.join(dir, "personal", "a", "deep"));
698
+ expect(degraded).toHaveBeenCalledWith(
699
+ expect.objectContaining({
700
+ reason: "watch_budget_exhausted",
701
+ maxWatchedPaths: 3,
702
+ watchedPaths: 3,
703
+ offendingPath: expect.stringContaining(path.join("personal", "a", "deep")),
704
+ }),
705
+ );
706
+ expect(backendClosed).toBe(true);
707
+ expect(watcher.isWatching()).toBe(false);
708
+
709
+ clock.advance(DEBOUNCE);
710
+ expect(changed).toHaveBeenCalledWith(
711
+ undefined,
712
+ expect.objectContaining({ overflowed: true }),
713
+ );
714
+ } finally {
715
+ fs.rmSync(dir, { recursive: true, force: true });
716
+ }
717
+ });
718
+
719
+ it("degrades instead of dying when chokidar emits runtime ENOSPC", () => {
720
+ const clock = new FakeClock();
721
+ const degraded = vi.fn();
722
+ const changed = vi.fn();
723
+ const telemetry: TelemetryEventsBatch[] = [];
724
+ const telemetryClient = {
725
+ postTelemetryEvents: vi.fn(async (batch: TelemetryEventsBatch) => {
726
+ telemetry.push(batch);
727
+ return { ok: true, written: batch.events.length, skipped: [] };
728
+ }),
729
+ };
730
+ let closeCalls = 0;
731
+ const watcher = new TreeWatcher({
732
+ hqRoot: ROOT,
733
+ clock,
734
+ debounceMs: DEBOUNCE,
735
+ onDegraded: degraded,
736
+ telemetryClient,
737
+ backendFactory: ({ onError }) => {
738
+ const backend = {
739
+ close: () => {
740
+ closeCalls += 1;
741
+ },
742
+ needsKnownKinds: false,
743
+ watchedPathCount: () => 0,
744
+ };
745
+ queueMicrotask(() =>
746
+ onError(
747
+ Object.assign(new Error("inotify limit"), {
748
+ code: "ENOSPC",
749
+ path: path.join(ROOT, "companies", "acme", "knowledge"),
750
+ }),
751
+ ),
752
+ );
753
+ return backend;
754
+ },
755
+ });
756
+ watcher.onChange(changed);
757
+ watcher.start();
758
+
759
+ return new Promise<void>((resolve) => queueMicrotask(resolve)).then(() => {
760
+ expect(closeCalls).toBe(1);
761
+ expect(watcher.isWatching()).toBe(false);
762
+ expect(degraded).toHaveBeenCalledWith(
763
+ expect.objectContaining({
764
+ reason: "inotify_enospc",
765
+ errorCode: "ENOSPC",
766
+ offendingPath: path.join(ROOT, "companies", "acme", "knowledge"),
767
+ }),
768
+ );
769
+ expect(telemetry).toEqual([
770
+ expect.objectContaining({
771
+ events: [
772
+ expect.objectContaining({
773
+ eventName: "watcher_degraded",
774
+ source: "watcher",
775
+ properties: expect.objectContaining({
776
+ reason: "inotify_enospc",
777
+ offendingTopLevel: "companies",
778
+ }),
779
+ }),
780
+ ],
781
+ }),
782
+ ]);
783
+ clock.advance(DEBOUNCE);
784
+ expect(changed).toHaveBeenCalledWith(
785
+ undefined,
786
+ expect.objectContaining({ overflowed: true }),
787
+ );
788
+ });
789
+ });
790
+
791
+ it("does not retain one rename-kind entry for every changed file", () => {
792
+ const clock = new FakeClock();
793
+ const watcher = new TreeWatcher({
794
+ hqRoot: ROOT,
795
+ clock,
796
+ pathFilter: () => true,
797
+ maxPendingPaths: 20_000,
798
+ maxPendingBytes: 20_000_000,
799
+ });
800
+ for (let i = 0; i < 10_000; i++) {
801
+ watcher.handleEvent(path.join(ROOT, "personal", `file-${i}.md`));
802
+ }
803
+ expect(watcher.knownDirectoryCount()).toBe(0);
804
+ watcher.stop();
805
+ });
558
806
  });
559
807
 
560
808
  describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {