@indigoai-us/hq-cloud 6.18.22-alpha.702 → 6.18.22

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/watcher.js CHANGED
@@ -355,9 +355,9 @@ function errorPath(err) {
355
355
  /**
356
356
  * Which backend a platform gets.
357
357
  *
358
- * - macOS (FSEvents) and Windows (ReadDirectoryChangesW) watch a directory
359
- * recursively with ONE OS handle, so the scoped planner places a few
360
- * recursive watches over the in-scope tree.
358
+ * - macOS (FSEvents) uses one recursive root handle and filters events
359
+ * before they reach TreeWatcher. Windows uses the exact scoped recursive
360
+ * plan over ReadDirectoryChangesW handles.
361
361
  * - Linux has no recursive handle: a directory watch reports only its own
362
362
  * direct contents. The same scoped backend runs with one shallow inotify
363
363
  * watch per in-scope directory instead — and NONE per file, because the
@@ -370,7 +370,9 @@ function errorPath(err) {
370
370
  export function selectTreeWatchBackend(platform, forcePolling) {
371
371
  if (forcePolling)
372
372
  return "polling";
373
- if (platform === "darwin" || platform === "win32")
373
+ if (platform === "darwin")
374
+ return "native-root";
375
+ if (platform === "win32")
374
376
  return "native-recursive";
375
377
  if (platform === "linux")
376
378
  return "native-shallow";
@@ -390,6 +392,12 @@ function startChokidarTreeWatch(hqRoot, shouldEmit, onEvent, onError, maxWatched
390
392
  const readyListeners = new Set();
391
393
  try {
392
394
  cw = watch(hqRoot, {
395
+ // Push walkers record a directory symlink as a link entry and never
396
+ // traverse its target. Keep polling discovery on the same boundary so
397
+ // aliased trees do not consume the path budget or produce events for
398
+ // content that cannot upload. Chokidar still reports changes to the
399
+ // symlink entry itself.
400
+ followSymlinks: false,
393
401
  // On Linux chokidar adds one fs.watch/inotify watch per descended dir.
394
402
  // The predicate therefore enforces both scope and the hard per-runner cap.
395
403
  ignored: toChokidarIgnored(shouldEmit, hqRoot, budget),
@@ -642,7 +650,7 @@ export function startScopedRecursiveWatch(hqRoot, shouldEmit, onEvent, onError,
642
650
  const openedOn = directoryIdentity(dir);
643
651
  let handle;
644
652
  try {
645
- handle = fs.watch(dir, { recursive, persistent: true }, (eventType, filename) => {
653
+ handle = (opts.watchDirectory ?? fs.watch)(dir, { recursive, persistent: true }, (eventType, filename) => {
646
654
  // `filename` is relative to THIS watch's directory (or null/empty if
647
655
  // the platform couldn't provide it — nothing actionable then).
648
656
  // `String()` coerces both the string and (older @types/node) Buffer
@@ -680,6 +688,11 @@ export function startScopedRecursiveWatch(hqRoot, shouldEmit, onEvent, onError,
680
688
  }
681
689
  handle.on("error", (err) => {
682
690
  onError(err);
691
+ // TreeWatcher handles systemic watch exhaustion by closing this backend
692
+ // and moving to polling. Reopening the failed path before that transition
693
+ // runs only repeats the same EMFILE/ENFILE failure.
694
+ if (isSystemicWatchError(err))
695
+ return;
683
696
  // A handle a re-plan already replaced (retiring) must not tear down its
684
697
  // replacement at the same path.
685
698
  if (closed || watchers.get(dir) !== handle)
@@ -730,6 +743,7 @@ export function startScopedRecursiveWatch(hqRoot, shouldEmit, onEvent, onError,
730
743
  }
731
744
  : onDirectorySeen,
732
745
  recursiveWatches,
746
+ maxDepth: opts.maxDepth,
733
747
  listChildDirs: opts.listChildDirs,
734
748
  }));
735
749
  }
@@ -982,17 +996,101 @@ export function startScopedRecursiveWatch(hqRoot, shouldEmit, onEvent, onError,
982
996
  }),
983
997
  };
984
998
  }
999
+ /** Start one recursive watch on the HQ root and filter every event at ingress. */
1000
+ export function startNativeRootWatch(hqRoot, shouldEmit, onEvent, onError, opts = {}) {
1001
+ const root = path.resolve(hqRoot);
1002
+ const watchDirectory = opts.watchDirectory ?? fs.watch;
1003
+ let handle = null;
1004
+ let closed = false;
1005
+ let attachFailures = 0;
1006
+ const identity = new Map();
1007
+ const closeHandle = () => {
1008
+ const current = handle;
1009
+ handle = null;
1010
+ if (!current)
1011
+ return;
1012
+ try {
1013
+ current.close();
1014
+ }
1015
+ catch {
1016
+ // Already closed by the OS.
1017
+ }
1018
+ };
1019
+ const attach = () => {
1020
+ if (closed)
1021
+ return;
1022
+ try {
1023
+ const openedOn = directoryIdentityAt(root);
1024
+ const opened = watchDirectory(root, { recursive: true, persistent: true }, (eventType, filename) => {
1025
+ if (closed || filename == null)
1026
+ return;
1027
+ const absolutePath = path.resolve(root, String(filename));
1028
+ const relative = path.relative(root, absolutePath);
1029
+ if (relative === ".." ||
1030
+ relative.startsWith(`..${path.sep}`) ||
1031
+ path.isAbsolute(relative)) {
1032
+ return;
1033
+ }
1034
+ let isDir;
1035
+ try {
1036
+ isDir = fs.lstatSync(absolutePath).isDirectory();
1037
+ }
1038
+ catch {
1039
+ // A rename event can describe a path that has already vanished.
1040
+ }
1041
+ const admitted = isDir === undefined
1042
+ ? shouldEmit(absolutePath, false) && shouldEmit(absolutePath, true)
1043
+ : shouldEmit(absolutePath, isDir);
1044
+ if (!admitted)
1045
+ return;
1046
+ onEvent(absolutePath, eventType === "change" ? "change" : "rename");
1047
+ });
1048
+ handle = opened;
1049
+ if (openedOn !== undefined)
1050
+ identity.set(root, openedOn);
1051
+ opened.on("error", (err) => {
1052
+ onError(err);
1053
+ // Systemic errors are handled by TreeWatcher, which closes this
1054
+ // backend and switches to polling. Reopening would repeat exhaustion.
1055
+ if (closed || isSystemicWatchError(err) || handle !== opened)
1056
+ return;
1057
+ closeHandle();
1058
+ attach();
1059
+ });
1060
+ }
1061
+ catch (err) {
1062
+ attachFailures += 1;
1063
+ onError(err);
1064
+ }
1065
+ };
1066
+ attach();
1067
+ return {
1068
+ close: () => {
1069
+ if (closed)
1070
+ return;
1071
+ closed = true;
1072
+ closeHandle();
1073
+ },
1074
+ mode: "native-root",
1075
+ needsKnownKinds: true,
1076
+ attachFailures,
1077
+ watchedPathCount: () => (handle ? 1 : 0),
1078
+ probeCoverage: () => probeScopedWatchCoverage({
1079
+ root,
1080
+ identities: identity,
1081
+ directoryIdentity: directoryIdentityAt,
1082
+ }),
1083
+ };
1084
+ }
985
1085
  /**
986
1086
  * Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
987
1087
  *
988
1088
  * Backend selection (the whole point of this helper):
989
- * - macOS / Windows → scoped recursive `fs.watch` handles over the in-scope
990
- * tree only ({@link startScopedRecursiveWatch}). chokidar 4 dropped its
991
- * `fsevents` backend, so on macOS it watches via kqueue, which costs ~1
992
- * open fd PER watched path — ~11k fds over a real HQ tree, which EMFILEs
993
- * under the default soft `ulimit -n` (256) and silently kills the watcher
994
- * (instant sync then falls back to the poll).
995
- * - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
1089
+ * - macOS → one recursive root `fs.watch`, with the scope filter applied
1090
+ * before an event reaches TreeWatcher.
1091
+ * - Windows → scoped recursive `fs.watch` handles over the in-scope tree.
1092
+ * - Linux → one shallow native watch per in-scope directory; other platforms
1093
+ * use chokidar (recursive `fs.watch` is unsupported there).
996
1094
  *
997
1095
  * The recursive backend does no descent-time filtering WITHIN a watched
998
1096
  * subtree, so changes there are funneled to `onEvent`, which re-applies the
@@ -1002,11 +1100,41 @@ export function startScopedRecursiveWatch(hqRoot, shouldEmit, onEvent, onError,
1002
1100
  * ancestor-descent pruning that the chokidar backend needs
1003
1101
  * {@link toChokidarIgnored} to avoid.
1004
1102
  */
1005
- function startTreeWatch(hqRoot, shouldEmit, onEvent, onError, maxWatchedPaths, onWatchBudgetExceeded, onDirectorySeen, forcePolling = false) {
1103
+ /** @internal Exported for backend-level regression tests. */
1104
+ export function startTreeWatch(hqRoot, shouldEmit, onEvent, onError, maxWatchedPaths, onWatchBudgetExceeded, onDirectorySeen, forcePolling = false) {
1105
+ // Resolve the root once before the backend starts. Chokidar with
1106
+ // followSymlinks:false treats descendant links as leaf entries, but an
1107
+ // explicitly supplied root may itself be a symlink. Watch its real tree and
1108
+ // map observed paths back to the caller's root spelling so the caller's
1109
+ // filter and path routing retain their existing contract.
1110
+ const displayRoot = path.resolve(hqRoot);
1111
+ const root = fs.realpathSync(displayRoot);
1112
+ const displayPath = (absolutePath) => root === displayRoot
1113
+ ? absolutePath
1114
+ : path.join(displayRoot, path.relative(root, absolutePath));
1115
+ const watchFilter = root === displayRoot
1116
+ ? shouldEmit
1117
+ : (absolutePath, isDir) => shouldEmit(displayPath(absolutePath), isDir);
1118
+ const watchEvent = root === displayRoot
1119
+ ? onEvent
1120
+ : (absolutePath, kind) => onEvent(displayPath(absolutePath), kind);
1121
+ const watchDirectorySeen = onDirectorySeen
1122
+ ? root === displayRoot
1123
+ ? onDirectorySeen
1124
+ : (absolutePath) => onDirectorySeen(displayPath(absolutePath))
1125
+ : undefined;
1006
1126
  const choice = selectTreeWatchBackend(process.platform, forcePolling);
1007
- if (choice === "native-recursive" || choice === "native-shallow") {
1127
+ if (choice === "native-root") {
1128
+ const nativeRoot = startNativeRootWatch(root, watchFilter, watchEvent, onError);
1129
+ if (nativeRoot.attachFailures === 0 && nativeRoot.watchedPathCount() === 1) {
1130
+ return nativeRoot;
1131
+ }
1132
+ nativeRoot.close();
1133
+ }
1134
+ if (choice === "native-recursive" ||
1135
+ choice === "native-shallow") {
1008
1136
  try {
1009
- const scoped = startScopedRecursiveWatch(hqRoot, shouldEmit, onEvent, onError, onDirectorySeen, choice === "native-shallow"
1137
+ const scoped = startScopedRecursiveWatch(root, watchFilter, watchEvent, onError, watchDirectorySeen, choice === "native-shallow"
1010
1138
  ? { recursiveWatches: false, maxWatchedPaths, onWatchBudgetExceeded }
1011
1139
  : {});
1012
1140
  // Partial coverage is worse than a different backend: a planned root we
@@ -1024,7 +1152,7 @@ function startTreeWatch(hqRoot, shouldEmit, onEvent, onError, maxWatchedPaths, o
1024
1152
  onError(err);
1025
1153
  }
1026
1154
  }
1027
- return startChokidarTreeWatch(hqRoot, shouldEmit, onEvent, onError, maxWatchedPaths, onWatchBudgetExceeded, forcePolling);
1155
+ return startChokidarTreeWatch(root, watchFilter, watchEvent, onError, maxWatchedPaths, onWatchBudgetExceeded, forcePolling);
1028
1156
  }
1029
1157
  /**
1030
1158
  * Build the composite emit-decision predicate. Returns true when a change to
@@ -2333,7 +2461,10 @@ export class TreeWatcher {
2333
2461
  }
2334
2462
  /** Active backend mode, including the no-inotify polling fallback. */
2335
2463
  watchMode() {
2336
- return this.backend?.mode ?? (this.backend ? "unknown" : "inactive");
2464
+ const mode = this.backend?.mode;
2465
+ if (mode === "native-root")
2466
+ return "native";
2467
+ return mode ?? (this.backend ? "unknown" : "inactive");
2337
2468
  }
2338
2469
  /**
2339
2470
  * Check that the HQ root and every live watch still resolve to the inode