@indigoai-us/hq-cloud 6.18.21 → 6.18.22-alpha.732d

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.
@@ -1,11 +1,12 @@
1
1
  import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
2
3
  import * as fs from "fs";
3
4
  import * as os from "os";
4
5
  import * as path from "path";
5
6
  import { createHash } from "node:crypto";
6
7
  import { monitorEventLoopDelay } from "node:perf_hooks";
7
8
  import { watch } from "chokidar";
8
- import { FakeClock, WatchPushDriver, TreeWatcher, ChokidarWatchBudget, createWatchPathFilter, toChokidarIgnored, PushEventEmitter, PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES, PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP, resolveEventDebounceConfig, summarizePublishBatchOutcome, DEFAULT_EVENT_DEBOUNCE_MS, DEFAULT_EVENT_MAX_WAIT_MS, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES, DEFAULT_PUBLISH_MIN_INTERVAL_MS, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, EVENT_SMALL_BATCH_MAX_PATHS_ENV, EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, PUBLISH_MIN_INTERVAL_MS_ENV, resolvePublishMinIntervalMs, DurablePublishedContentHashes, DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS, replayDurablePublishedContentHashRecordsForTest, startScopedRecursiveWatch, selectTreeWatchBackend } from "./watcher.js";
9
+ import { FakeClock, WatchPushDriver, TreeWatcher, ChokidarWatchBudget, createWatchPathFilter, toChokidarIgnored, PushEventEmitter, PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES, PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP, resolveEventDebounceConfig, summarizePublishBatchOutcome, DEFAULT_EVENT_DEBOUNCE_MS, DEFAULT_EVENT_MAX_WAIT_MS, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES, DEFAULT_PUBLISH_MIN_INTERVAL_MS, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, EVENT_SMALL_BATCH_MAX_PATHS_ENV, EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, PUBLISH_MIN_INTERVAL_MS_ENV, resolvePublishMinIntervalMs, DurablePublishedContentHashes, DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS, replayDurablePublishedContentHashRecordsForTest, startScopedRecursiveWatch, startNativeRootWatch, startTreeWatch, selectTreeWatchBackend } from "./watcher.js";
9
10
  import { StateStore, StateStoreLockError } from "./sync/state-store.js";
10
11
  import { HttpPushTransport, PushScopeForbiddenError, RealtimeUnavailableError, } from "./sync/push-transport.js";
11
12
  import { computePersonalVaultPaths } from "./personal-vault.js";
@@ -19,6 +20,46 @@ import { computePersonalVaultPaths } from "./personal-vault.js";
19
20
  * serve as regression guards for the contract those stories depend on.
20
21
  */
21
22
  const DEBOUNCE = 2000;
23
+ describe("scoped watcher resource errors", () => {
24
+ let root;
25
+ beforeEach(() => {
26
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-resource-error-")));
27
+ });
28
+ afterEach(() => {
29
+ fs.rmSync(root, { recursive: true, force: true });
30
+ });
31
+ it("does not reopen a directory watch after EMFILE", () => {
32
+ const docs = path.join(root, "docs");
33
+ fs.mkdirSync(docs);
34
+ class FakeWatcher extends EventEmitter {
35
+ close() {
36
+ this.emit("close");
37
+ return this;
38
+ }
39
+ }
40
+ const handles = new Map();
41
+ const watchedPaths = [];
42
+ const backend = startScopedRecursiveWatch(root, createWatchPathFilter(root, false), () => { }, () => { }, undefined, {
43
+ recursiveWatches: false,
44
+ listChildDirs: (dir) => dir === root ? [docs] : [],
45
+ watchDirectory: (dir) => {
46
+ const watchPath = dir.toString();
47
+ watchedPaths.push(watchPath);
48
+ const handle = new FakeWatcher();
49
+ handles.set(watchPath, handle);
50
+ return handle;
51
+ },
52
+ });
53
+ try {
54
+ expect(watchedPaths.filter((dir) => dir === docs)).toHaveLength(1);
55
+ handles.get(docs).emit("error", Object.assign(new Error("too many open files"), { code: "EMFILE" }));
56
+ expect(watchedPaths.filter((dir) => dir === docs)).toHaveLength(1);
57
+ }
58
+ finally {
59
+ backend.close();
60
+ }
61
+ });
62
+ });
22
63
  function publishedHashRecords(count) {
23
64
  const maxPaths = Math.max(1, Math.floor(count / 2));
24
65
  let seed = 0x14c0ffee;
@@ -954,6 +995,92 @@ describe("US-002: chokidar descent scope", () => {
954
995
  }
955
996
  });
956
997
  });
998
+ describe("chokidar polling symlink boundaries", () => {
999
+ async function waitFor(check) {
1000
+ const deadline = Date.now() + 6000;
1001
+ while (Date.now() < deadline) {
1002
+ if (check())
1003
+ return;
1004
+ await new Promise((resolve) => setTimeout(resolve, 25));
1005
+ }
1006
+ expect(check()).toBe(true);
1007
+ }
1008
+ async function waitReady(backend) {
1009
+ await new Promise((resolve, reject) => {
1010
+ if (!backend.onReady)
1011
+ return resolve();
1012
+ const timer = setTimeout(() => reject(new Error("watcher did not become ready")), 6000);
1013
+ backend.onReady(() => {
1014
+ clearTimeout(timer);
1015
+ resolve();
1016
+ });
1017
+ });
1018
+ }
1019
+ it("polls ordinary paths without descending or budgeting symlink targets, while reporting the link entry", async () => {
1020
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-link-root-")));
1021
+ const external = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-link-target-")));
1022
+ const link = path.join(root, ".agents");
1023
+ const events = [];
1024
+ const budgetExceeded = vi.fn();
1025
+ const errors = [];
1026
+ try {
1027
+ fs.mkdirSync(path.join(external, "many", "nested", "directories", "under", "the", "alias"), { recursive: true });
1028
+ fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
1029
+ fs.writeFileSync(path.join(root, ".codex", "config.toml"), "model = 'test'\n");
1030
+ fs.mkdirSync(path.join(root, "workspace", "agency", "scratch"), { recursive: true });
1031
+ fs.writeFileSync(path.join(root, "workspace", "agency", "scratch", "notes.md"), "notes\n");
1032
+ fs.symlinkSync(external, link, "dir");
1033
+ const filter = createWatchPathFilter(root, true);
1034
+ expect(filter(path.join(root, ".codex", "config.toml"))).toBe(true);
1035
+ expect(filter(path.join(root, "workspace", "agency", "scratch", "notes.md"))).toBe(true);
1036
+ const backend = startTreeWatch(root, filter, (absolutePath, kind) => events.push({ path: absolutePath, kind }), (error) => errors.push(error), 7, budgetExceeded, undefined, true);
1037
+ try {
1038
+ await waitReady(backend);
1039
+ expect(backend.mode).toBe("polling");
1040
+ // Root + .codex + workspace/agency/scratch; the link target costs no watches.
1041
+ expect(backend.watchedPathCount()).toBe(5);
1042
+ expect(budgetExceeded).not.toHaveBeenCalled();
1043
+ expect(errors).toEqual([]);
1044
+ fs.unlinkSync(link);
1045
+ await waitFor(() => events.some((event) => event.path === link && event.kind === "unlink"));
1046
+ fs.symlinkSync(external, link, "dir");
1047
+ await waitFor(() => events.some((event) => event.path === link && event.kind === "add"));
1048
+ expect(backend.watchedPathCount()).toBe(5);
1049
+ }
1050
+ finally {
1051
+ backend.close();
1052
+ }
1053
+ }
1054
+ finally {
1055
+ fs.rmSync(root, { recursive: true, force: true });
1056
+ fs.rmSync(external, { recursive: true, force: true });
1057
+ }
1058
+ });
1059
+ it("continues watching when the supplied HQ root is itself a symlink", async () => {
1060
+ const realRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-real-root-")));
1061
+ const parent = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-root-link-")));
1062
+ const linkedRoot = path.join(parent, "hq");
1063
+ const events = [];
1064
+ const errors = [];
1065
+ fs.mkdirSync(path.join(realRoot, "workspace", "agency", "scratch"), { recursive: true });
1066
+ fs.symlinkSync(realRoot, linkedRoot, "dir");
1067
+ const filter = createWatchPathFilter(linkedRoot, true);
1068
+ const backend = startTreeWatch(linkedRoot, filter, (absolutePath) => events.push(absolutePath), (error) => errors.push(error), 20, () => { }, undefined, true);
1069
+ try {
1070
+ await waitReady(backend);
1071
+ const file = path.join(linkedRoot, "workspace", "agency", "scratch", "after-ready.md");
1072
+ fs.writeFileSync(file, "observed\n");
1073
+ await waitFor(() => events.includes(file));
1074
+ expect(errors).toEqual([]);
1075
+ }
1076
+ finally {
1077
+ backend.close();
1078
+ fs.rmSync(linkedRoot, { recursive: true, force: true });
1079
+ fs.rmSync(parent, { recursive: true, force: true });
1080
+ fs.rmSync(realRoot, { recursive: true, force: true });
1081
+ }
1082
+ });
1083
+ });
957
1084
  describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
958
1085
  function makeWatcher(opts) {
959
1086
  const clock = new FakeClock();
@@ -4018,14 +4145,85 @@ describe("Linux folder-only native backend (startScopedRecursiveWatch, recursive
4018
4145
  backend.close();
4019
4146
  }
4020
4147
  });
4021
- it("selects the folder-only native backend on linux and leaves darwin/win32 on recursive native", () => {
4148
+ it("selects the single-root backend on darwin and leaves linux and windows unchanged", () => {
4022
4149
  expect(selectTreeWatchBackend("linux", false)).toBe("native-shallow");
4023
- expect(selectTreeWatchBackend("darwin", false)).toBe("native-recursive");
4150
+ expect(selectTreeWatchBackend("darwin", false)).toBe("native-root");
4024
4151
  expect(selectTreeWatchBackend("win32", false)).toBe("native-recursive");
4025
4152
  expect(selectTreeWatchBackend("freebsd", false)).toBe("chokidar");
4026
4153
  expect(selectTreeWatchBackend("linux", true)).toBe("polling");
4027
4154
  expect(selectTreeWatchBackend("darwin", true)).toBe("polling");
4028
4155
  });
4156
+ it("filters native-root callbacks before forwarding them to onEvent", () => {
4157
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "native-root-filter-")));
4158
+ const excludedDir = path.join(dir, "node_modules");
4159
+ fs.mkdirSync(path.join(excludedDir, "pkg"), { recursive: true });
4160
+ fs.writeFileSync(path.join(excludedDir, "pkg", "index.js"), "x");
4161
+ const callback = vi.fn();
4162
+ const handle = new EventEmitter();
4163
+ handle.close = vi.fn();
4164
+ let listener;
4165
+ const backend = startNativeRootWatch(dir, createWatchPathFilter(dir, false), callback, vi.fn(), {
4166
+ watchDirectory: ((root, options, onChange) => {
4167
+ expect(root).toBe(dir);
4168
+ expect(options).toEqual({ recursive: true, persistent: true });
4169
+ listener = onChange;
4170
+ return handle;
4171
+ }),
4172
+ });
4173
+ try {
4174
+ expect(backend.mode).toBe("native-root");
4175
+ expect(backend.watchedPathCount()).toBe(1);
4176
+ listener?.("rename", "companies/indigo/knowledge/note.md");
4177
+ listener?.("rename", "node_modules");
4178
+ listener?.("change", "workspace/node_modules/pkg/index.js");
4179
+ expect(callback).toHaveBeenCalledTimes(1);
4180
+ expect(callback).toHaveBeenCalledWith(path.join(dir, "companies/indigo/knowledge/note.md"), "rename");
4181
+ }
4182
+ finally {
4183
+ backend.close();
4184
+ fs.rmSync(dir, { recursive: true, force: true });
4185
+ }
4186
+ });
4187
+ it("switches a native-root watcher to polling after a systemic watch error", async () => {
4188
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "native-root-fallback-")));
4189
+ const closed = [];
4190
+ const backend = new TreeWatcher({
4191
+ hqRoot: dir,
4192
+ debounceMs: 50,
4193
+ backendFactory: ({ forcePolling, onError }) => {
4194
+ if (forcePolling) {
4195
+ return {
4196
+ close: () => closed.push("polling"),
4197
+ mode: "polling",
4198
+ needsKnownKinds: false,
4199
+ watchedPathCount: () => 1,
4200
+ };
4201
+ }
4202
+ const handle = new EventEmitter();
4203
+ handle.close = vi.fn(() => closed.push("native-root"));
4204
+ const native = startNativeRootWatch(dir, () => true, vi.fn(), onError, {
4205
+ watchDirectory: (() => {
4206
+ queueMicrotask(() => handle.emit("error", Object.assign(new Error("too many open files"), { code: "EMFILE" })));
4207
+ return handle;
4208
+ }),
4209
+ });
4210
+ return native;
4211
+ },
4212
+ });
4213
+ const log = vi.spyOn(console, "error").mockImplementation(() => { });
4214
+ backend.start();
4215
+ try {
4216
+ await new Promise((resolve) => setTimeout(resolve, 0));
4217
+ expect(backend.watchMode()).toBe("polling");
4218
+ expect(backend.watchedPathCount()).toBe(1);
4219
+ expect(closed).toContain("native-root");
4220
+ }
4221
+ finally {
4222
+ backend.dispose();
4223
+ log.mockRestore();
4224
+ fs.rmSync(dir, { recursive: true, force: true });
4225
+ }
4226
+ });
4029
4227
  it("TreeWatcher on linux starts natively with one watch per directory (no chokidar, no file watches)", async () => {
4030
4228
  if (!isLinux)
4031
4229
  return;