@indigoai-us/hq-cloud 6.11.11 → 6.11.13

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.
Files changed (220) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -49,6 +49,10 @@ function makeHarness(opts?: { debounceMs?: number }) {
49
49
  };
50
50
  }
51
51
 
52
+ async function flushImmediate(): Promise<void> {
53
+ await new Promise<void>((resolve) => setImmediate(resolve));
54
+ }
55
+
52
56
  describe("FakeClock", () => {
53
57
  it("fires a timer exactly when its deadline is reached", () => {
54
58
  const clock = new FakeClock();
@@ -166,6 +170,34 @@ describe("US-001: WatchPushDriver — debounced push seam", () => {
166
170
  driver.dispose();
167
171
  });
168
172
 
173
+ it("F17: rejected watch push is caught instead of surfacing as an unhandled rejection", async () => {
174
+ const clock = new FakeClock();
175
+ const rejection = new Error("push failed");
176
+ const push = vi.fn(async () => {
177
+ throw rejection;
178
+ });
179
+ const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
180
+ const unhandled: unknown[] = [];
181
+ const onUnhandled = (reason: unknown) => {
182
+ unhandled.push(reason);
183
+ };
184
+
185
+ process.prependListener("unhandledRejection", onUnhandled);
186
+ try {
187
+ driver.notifyChange();
188
+ clock.advance(DEBOUNCE);
189
+ await Promise.resolve();
190
+ await flushImmediate();
191
+
192
+ expect(push).toHaveBeenCalledTimes(1);
193
+ expect(driver.isPushing()).toBe(false);
194
+ expect(unhandled).toEqual([]);
195
+ } finally {
196
+ process.removeListener("unhandledRejection", onUnhandled);
197
+ driver.dispose();
198
+ }
199
+ });
200
+
169
201
  it("respects a custom debounce window", async () => {
170
202
  const h = makeHarness({ debounceMs: 500 });
171
203
  h.emitChange();
@@ -374,6 +406,42 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
374
406
  clock.advance(DEBOUNCE);
375
407
  expect(changed).toHaveBeenCalledTimes(2);
376
408
  });
409
+
410
+ it("R-F13: caps watcher backlog and keeps EMFILE polling fallback removed", () => {
411
+ const clock = new FakeClock();
412
+ const changed = vi.fn();
413
+ const overflow = vi.fn();
414
+ const watcher = new TreeWatcher({
415
+ hqRoot: ROOT,
416
+ debounceMs: DEBOUNCE,
417
+ clock,
418
+ pathFilter: () => true,
419
+ maxPendingPaths: 3,
420
+ maxPendingBytes: 10_000,
421
+ onBacklogOverflow: overflow,
422
+ });
423
+ watcher.onChange(changed);
424
+
425
+ for (let i = 0; i < 10; i++) {
426
+ watcher.handleEvent(path.join(ROOT, `bulk-${i}.md`));
427
+ }
428
+
429
+ expect(overflow).toHaveBeenCalledTimes(1);
430
+ clock.advance(DEBOUNCE);
431
+ expect(changed).toHaveBeenCalledTimes(1);
432
+ const batch = changed.mock.calls[0][1] as {
433
+ paths: Map<string, string>;
434
+ overflowed?: boolean;
435
+ droppedPaths?: number;
436
+ };
437
+ expect(batch.paths.size).toBe(3);
438
+ expect(batch.overflowed).toBe(true);
439
+ expect(batch.droppedPaths).toBe(7);
440
+
441
+ const source = fs.readFileSync(path.join(process.cwd(), "src/watcher.ts"), "utf8");
442
+ expect(source).not.toContain("startPollingTreeWatch");
443
+ expect(source).not.toContain("snapshotWatchTree");
444
+ });
377
445
  });
378
446
 
379
447
  describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {
@@ -468,6 +536,55 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
468
536
  });
469
537
  }
470
538
 
539
+ it("F13: watcher batch event publishing applies bounded backpressure", async () => {
540
+ const paths = new Map<string, string>();
541
+ for (let i = 0; i < 32; i++) {
542
+ const rel = `bulk-${i}.md`;
543
+ const abs = path.join(dir, rel);
544
+ fs.writeFileSync(abs, `file ${i}`);
545
+ paths.set(abs, rel);
546
+ }
547
+
548
+ let inFlight = 0;
549
+ let maxInFlight = 0;
550
+ let releaseGate!: () => void;
551
+ const gate = new Promise<void>((resolve) => {
552
+ releaseGate = resolve;
553
+ });
554
+ const transport: PushTransport = {
555
+ start: async () => {},
556
+ dispose: async () => {},
557
+ connected: true,
558
+ publish: async () => {
559
+ inFlight += 1;
560
+ maxInFlight = Math.max(maxInFlight, inFlight);
561
+ await gate;
562
+ inFlight -= 1;
563
+ },
564
+ };
565
+ const emitter = new PushEventEmitter({
566
+ originTenantId: "tenant-indigo",
567
+ originDeviceId: "device-a",
568
+ transport,
569
+ flagProvider: new StaticFlagProvider(["tenant-indigo"]),
570
+ now: () => new Date("2026-06-18T12:00:00.000Z"),
571
+ });
572
+
573
+ const run = emitter.emitForBatch({ paths });
574
+ try {
575
+ for (let i = 0; i < 200 && maxInFlight <= 16; i++) {
576
+ await flushImmediate();
577
+ }
578
+ releaseGate();
579
+ await run;
580
+
581
+ expect(maxInFlight).toBeLessThanOrEqual(16);
582
+ } finally {
583
+ releaseGate();
584
+ await run.catch(() => {});
585
+ }
586
+ });
587
+
471
588
  it("skips directory changes silently without publishing or reporting an error", async () => {
472
589
  const published: PushEvent[] = [];
473
590
  const onError = vi.fn();
package/src/watcher.ts CHANGED
@@ -2,9 +2,8 @@
2
2
  * File watcher — monitors HQ directory for changes
3
3
  * Uses chokidar with debounced batching
4
4
  *
5
- * Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
6
- * When re-enabled, the constructor will need an EntityContext (or a context resolver)
7
- * to be passed in for entity-aware S3 operations.
5
+ * Active watcher path: TreeWatcher detects local changes, WatchPushDriver
6
+ * schedules pushes, and PushEventEmitter publishes typed push events.
8
7
  */
9
8
 
10
9
  import * as fs from "fs";
@@ -12,12 +11,7 @@ import { createHash } from "node:crypto";
12
11
  import { readFile, stat } from "node:fs/promises";
13
12
  import * as path from "path";
14
13
  import { watch } from "chokidar";
15
- import type { FSWatcher } from "chokidar";
16
- import type { EntityContext } from "./types.js";
17
- import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
18
- import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
19
- import { uploadFile, deleteRemoteFile, toPosixKey } from "./s3.js";
20
- import type { UploadAuthor } from "./s3.js";
14
+ import { createIgnoreFilter } from "./ignore.js";
21
15
  import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
22
16
  import {
23
17
  CONTINUITY_POINTER_REL,
@@ -163,7 +157,9 @@ export class WatchPushDriver {
163
157
  }
164
158
  this.timer = this.clock.setTimeout(() => {
165
159
  this.timer = null;
166
- void this.fire();
160
+ void this.fire().catch((err) => {
161
+ console.error("WatchPushDriver push failed:", err);
162
+ });
167
163
  }, this.debounceMs);
168
164
  }
169
165
 
@@ -203,141 +199,12 @@ export class WatchPushDriver {
203
199
  }
204
200
  }
205
201
 
206
- interface PendingChange {
207
- type: "add" | "change" | "unlink";
208
- absolutePath: string;
209
- relativePath: string;
210
- }
211
-
212
- export class SyncWatcher {
213
- private watcher: FSWatcher | null = null;
214
- private hqRoot: string;
215
- private ctx: EntityContext;
216
- private author?: UploadAuthor;
217
- private shouldSync: (filePath: string, isDir?: boolean) => boolean;
218
- private pendingChanges = new Map<string, PendingChange>();
219
- private debounceTimer: ReturnType<typeof setTimeout> | null = null;
220
- private processing = false;
221
-
222
- constructor(hqRoot: string, ctx: EntityContext, author?: UploadAuthor) {
223
- this.hqRoot = hqRoot;
224
- this.ctx = ctx;
225
- this.author = author;
226
- this.shouldSync = createIgnoreFilter(hqRoot);
227
- }
228
-
229
- start(): void {
230
- if (this.watcher) return;
231
-
232
- this.watcher = watch(this.hqRoot, {
233
- // See toChokidarIgnored: chokidar's pre-stat descent probe has no stats
234
- // hint, so a naive file-verdict prunes intermediate allowlist dirs
235
- // before descending to their in-scope leaves. Keep a statless probe
236
- // when EITHER the file or directory reading would survive the filter.
237
- ignored: toChokidarIgnored(this.shouldSync, this.hqRoot),
238
- persistent: true,
239
- ignoreInitial: true,
240
- awaitWriteFinish: {
241
- stabilityThreshold: 500,
242
- pollInterval: 100,
243
- },
244
- });
245
-
246
- this.watcher
247
- .on("add", (p) => this.queueChange("add", p))
248
- .on("change", (p) => this.queueChange("change", p))
249
- .on("unlink", (p) => this.queueChange("unlink", p))
250
- .on("error", (err) => console.error("Watcher error:", err));
251
- }
252
-
253
- stop(): void {
254
- if (this.watcher) {
255
- this.watcher.close();
256
- this.watcher = null;
257
- }
258
- if (this.debounceTimer) {
259
- clearTimeout(this.debounceTimer);
260
- this.debounceTimer = null;
261
- }
262
- }
263
-
264
- private queueChange(type: "add" | "change" | "unlink", absolutePath: string): void {
265
- const relativePath = toPosixKey(path.relative(this.hqRoot, absolutePath));
266
-
267
- // Skip files that exceed size limit
268
- if (type !== "unlink" && !isWithinSizeLimit(absolutePath)) {
269
- return;
270
- }
271
-
272
- this.pendingChanges.set(relativePath, {
273
- type,
274
- absolutePath,
275
- relativePath,
276
- });
277
-
278
- // Debounce: wait for DEBOUNCE_MS of quiet before processing
279
- if (this.debounceTimer) {
280
- clearTimeout(this.debounceTimer);
281
- }
282
- this.debounceTimer = setTimeout(() => this.flush(), DEBOUNCE_MS);
283
- }
284
-
285
- private async flush(): Promise<void> {
286
- if (this.processing || this.pendingChanges.size === 0) return;
287
- this.processing = true;
288
-
289
- const batch = new Map(this.pendingChanges);
290
- this.pendingChanges.clear();
291
-
292
- const journal = readJournal(this.ctx.slug);
293
-
294
- for (const [relativePath, change] of batch) {
295
- try {
296
- if (change.type === "unlink") {
297
- await deleteRemoteFile(this.ctx, relativePath);
298
- delete journal.files[relativePath];
299
- } else {
300
- const hash = hashFile(change.absolutePath);
301
- const stat = fs.statSync(change.absolutePath);
302
-
303
- // Skip if unchanged from last sync
304
- const existing = journal.files[relativePath];
305
- if (existing && existing.hash === hash) continue;
306
-
307
- const { etag } = this.author
308
- ? await uploadFile(this.ctx, change.absolutePath, relativePath, this.author)
309
- : await uploadFile(this.ctx, change.absolutePath, relativePath);
310
- updateEntry(journal, relativePath, hash, stat.size, "up", etag);
311
- }
312
- } catch (err) {
313
- console.error(
314
- `Sync error [${relativePath}]:`,
315
- err instanceof Error ? err.message : err
316
- );
317
- // Re-queue failed changes
318
- this.pendingChanges.set(relativePath, change);
319
- }
320
- }
321
-
322
- // See cli/sync.ts: stamp lastSync on every flush so the indicator
323
- // ticks even when all changes were re-queued or no-op.
324
- journal.lastSync = new Date().toISOString();
325
- writeJournal(this.ctx.slug, journal);
326
- this.processing = false;
327
-
328
- // Process any changes that came in while we were flushing
329
- if (this.pendingChanges.size > 0) {
330
- this.debounceTimer = setTimeout(() => this.flush(), DEBOUNCE_MS);
331
- }
332
- }
333
- }
334
-
335
202
  // ---------------------------------------------------------------------------
336
203
  // US-002 — debounced, ignore-aware, exclusion-aware tree watcher.
337
204
  //
338
- // Unlike SyncWatcher (which does S3 work inline), TreeWatcher is a pure change
339
- // detector: it emits a single debounced `changed` callback after a quiet window
340
- // and never touches S3 itself. US-003 wires that callback to a targeted push.
205
+ // TreeWatcher is a pure change detector: it emits a single debounced `changed`
206
+ // callback after a quiet window and never touches S3 itself. US-003 wires that
207
+ // callback to a targeted push.
341
208
  //
342
209
  // The emit decision composes the SAME filter stack the push walk uses, so a
343
210
  // path that the push would skip never wakes the watcher:
@@ -400,6 +267,36 @@ function supportsRecursiveWatch(): boolean {
400
267
  return process.platform === "darwin" || process.platform === "win32";
401
268
  }
402
269
 
270
+ function startChokidarTreeWatch(
271
+ hqRoot: string,
272
+ shouldEmit: WatchPathFilter,
273
+ onEvent: (absolutePath: string) => void,
274
+ onError: (err: unknown) => void,
275
+ ): WatchBackend {
276
+ const cw = watch(hqRoot, {
277
+ // chokidar fallback (Linux): see toChokidarIgnored for why the descent
278
+ // probe must not prune ancestor dirs of allowlisted leaves.
279
+ ignored: toChokidarIgnored(shouldEmit, hqRoot),
280
+ persistent: true,
281
+ ignoreInitial: true,
282
+ awaitWriteFinish: {
283
+ stabilityThreshold: 500,
284
+ pollInterval: 100,
285
+ },
286
+ });
287
+ cw.on("add", onEvent)
288
+ .on("change", onEvent)
289
+ .on("unlink", onEvent)
290
+ .on("addDir", onEvent)
291
+ .on("unlinkDir", onEvent)
292
+ .on("error", onError);
293
+ return {
294
+ close: () => {
295
+ void cw.close();
296
+ },
297
+ };
298
+ }
299
+
403
300
  /**
404
301
  * Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
405
302
  *
@@ -441,8 +338,39 @@ function startTreeWatch(
441
338
  onEvent(path.resolve(hqRoot, rel));
442
339
  },
443
340
  );
444
- native.on("error", onError);
445
- return { close: () => native.close() };
341
+ let closed = false;
342
+ let fallback: WatchBackend | null = null;
343
+ native.on("error", (err) => {
344
+ onError(err);
345
+ if (closed || fallback !== null) return;
346
+ try {
347
+ native.close();
348
+ } catch {
349
+ /* already closed */
350
+ }
351
+ try {
352
+ fallback = startChokidarTreeWatch(
353
+ hqRoot,
354
+ shouldEmit,
355
+ onEvent,
356
+ onError,
357
+ );
358
+ } catch (fallbackErr) {
359
+ onError(fallbackErr);
360
+ }
361
+ });
362
+ return {
363
+ close: () => {
364
+ closed = true;
365
+ try {
366
+ native.close();
367
+ } catch {
368
+ /* already closed */
369
+ }
370
+ fallback?.close();
371
+ fallback = null;
372
+ },
373
+ };
446
374
  } catch (err) {
447
375
  // Recursive watch unexpectedly unavailable — fall back to chokidar
448
376
  // rather than leaving the daemon with no watcher at all.
@@ -450,28 +378,7 @@ function startTreeWatch(
450
378
  }
451
379
  }
452
380
 
453
- const cw = watch(hqRoot, {
454
- // chokidar fallback (Linux): see toChokidarIgnored for why the descent
455
- // probe must not prune ancestor dirs of allowlisted leaves.
456
- ignored: toChokidarIgnored(shouldEmit, hqRoot),
457
- persistent: true,
458
- ignoreInitial: true,
459
- awaitWriteFinish: {
460
- stabilityThreshold: 500,
461
- pollInterval: 100,
462
- },
463
- });
464
- cw.on("add", onEvent)
465
- .on("change", onEvent)
466
- .on("unlink", onEvent)
467
- .on("addDir", onEvent)
468
- .on("unlinkDir", onEvent)
469
- .on("error", onError);
470
- return {
471
- close: () => {
472
- void cw.close();
473
- },
474
- };
381
+ return startChokidarTreeWatch(hqRoot, shouldEmit, onEvent, onError);
475
382
  }
476
383
 
477
384
  /**
@@ -533,6 +440,25 @@ export function createWatchPathFilter(
533
440
  };
534
441
  }
535
442
 
443
+ export const DEFAULT_TREE_WATCHER_MAX_PENDING_PATHS = 4096;
444
+ export const DEFAULT_TREE_WATCHER_MAX_PENDING_BYTES = 1024 * 1024;
445
+
446
+ export interface TreeWatcherBacklogOverflow {
447
+ pendingPaths: number;
448
+ pendingBytes: number;
449
+ maxPendingPaths: number;
450
+ maxPendingBytes: number;
451
+ droppedPaths: number;
452
+ droppedBytes: number;
453
+ }
454
+
455
+ function estimatePendingEntryBytes(
456
+ absolutePath: string,
457
+ relativePath: string,
458
+ ): number {
459
+ return Buffer.byteLength(absolutePath) + Buffer.byteLength(relativePath) + 64;
460
+ }
461
+
536
462
  export interface TreeWatcherOptions {
537
463
  /** Sync root to watch (== personal-vault root in personalMode). */
538
464
  hqRoot: string;
@@ -547,6 +473,12 @@ export interface TreeWatcherOptions {
547
473
  * from {@link createWatchPathFilter}.
548
474
  */
549
475
  pathFilter?: WatchPathFilter;
476
+ /** Maximum distinct paths retained in one debounce window. */
477
+ maxPendingPaths?: number;
478
+ /** Approximate maximum path-string bytes retained in one debounce window. */
479
+ maxPendingBytes?: number;
480
+ /** Backlog overflow signal. Defaults to a console warning. */
481
+ onBacklogOverflow?: (info: TreeWatcherBacklogOverflow) => void;
550
482
  }
551
483
 
552
484
  /**
@@ -569,6 +501,12 @@ export interface TreeWatcherOptions {
569
501
  export interface TreeChangeBatch {
570
502
  /** Map of absolutePath → relativePath for every path in the settled burst. */
571
503
  paths: Map<string, string>;
504
+ /** True when path detail was dropped after the watcher backlog cap was hit. */
505
+ overflowed?: boolean;
506
+ /** Count of paths dropped from the detailed batch after overflow. */
507
+ droppedPaths?: number;
508
+ /** Approximate path-string bytes dropped from the detailed batch. */
509
+ droppedBytes?: number;
572
510
  }
573
511
 
574
512
  /**
@@ -591,11 +529,19 @@ export class TreeWatcher {
591
529
  private readonly debounceMs: number;
592
530
  private readonly clock: Clock;
593
531
  private readonly shouldEmit: WatchPathFilter;
532
+ private readonly maxPendingPaths: number;
533
+ private readonly maxPendingBytes: number;
534
+ private readonly onBacklogOverflow: (info: TreeWatcherBacklogOverflow) => void;
594
535
  private backend: WatchBackend | null = null;
595
536
  private timer: unknown = null;
596
537
  private listeners = new Set<TreeChangeListener>();
597
538
  /** Paths accumulated for the current (in-flight) debounce window. */
598
539
  private pending = new Map<string, string>();
540
+ private pendingBytes = 0;
541
+ private overflowed = false;
542
+ private overflowLogged = false;
543
+ private droppedPaths = 0;
544
+ private droppedBytes = 0;
599
545
  private disposed = false;
600
546
 
601
547
  constructor(opts: TreeWatcherOptions) {
@@ -604,6 +550,21 @@ export class TreeWatcher {
604
550
  this.clock = opts.clock ?? systemClock;
605
551
  this.shouldEmit =
606
552
  opts.pathFilter ?? createWatchPathFilter(opts.hqRoot, opts.personalMode ?? false);
553
+ this.maxPendingPaths = Math.max(
554
+ 1,
555
+ opts.maxPendingPaths ?? DEFAULT_TREE_WATCHER_MAX_PENDING_PATHS,
556
+ );
557
+ this.maxPendingBytes = Math.max(
558
+ 1,
559
+ opts.maxPendingBytes ?? DEFAULT_TREE_WATCHER_MAX_PENDING_BYTES,
560
+ );
561
+ this.onBacklogOverflow =
562
+ opts.onBacklogOverflow ??
563
+ ((info) => {
564
+ console.warn(
565
+ `TreeWatcher backlog cap exceeded; dropping ${info.droppedPaths} path(s) until the next resync`,
566
+ );
567
+ });
607
568
  }
608
569
 
609
570
  /**
@@ -633,10 +594,19 @@ export class TreeWatcher {
633
594
  this.hqRoot,
634
595
  this.shouldEmit,
635
596
  (absolutePath) => this.handleEvent(absolutePath),
636
- (err) => console.error("TreeWatcher error:", err),
597
+ (err) => {
598
+ console.error("TreeWatcher error:", err);
599
+ this.signalBackendResync();
600
+ },
637
601
  );
638
602
  }
639
603
 
604
+ private signalBackendResync(): void {
605
+ if (this.disposed) return;
606
+ this.overflowed = true;
607
+ this.arm();
608
+ }
609
+
640
610
  /**
641
611
  * Test/seam entry point: feed a raw filesystem path as if the backend
642
612
  * reported it. Applies the emit filter then arms the debounce. Real watch
@@ -651,10 +621,42 @@ export class TreeWatcher {
651
621
  if (!this.shouldEmit(absolutePath, false)) return;
652
622
  const abs = path.resolve(absolutePath);
653
623
  const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
624
+ if (!this.pending.has(abs)) {
625
+ const entryBytes = estimatePendingEntryBytes(abs, rel);
626
+ if (
627
+ this.pending.size >= this.maxPendingPaths ||
628
+ this.pendingBytes + entryBytes > this.maxPendingBytes
629
+ ) {
630
+ this.recordBacklogOverflow(entryBytes);
631
+ this.arm();
632
+ return;
633
+ }
634
+ this.pendingBytes += entryBytes;
635
+ }
654
636
  this.pending.set(abs, rel);
655
637
  this.arm();
656
638
  }
657
639
 
640
+ private recordBacklogOverflow(entryBytes: number): void {
641
+ this.overflowed = true;
642
+ this.droppedPaths += 1;
643
+ this.droppedBytes += entryBytes;
644
+ if (this.overflowLogged) return;
645
+ this.overflowLogged = true;
646
+ try {
647
+ this.onBacklogOverflow({
648
+ pendingPaths: this.pending.size,
649
+ pendingBytes: this.pendingBytes,
650
+ maxPendingPaths: this.maxPendingPaths,
651
+ maxPendingBytes: this.maxPendingBytes,
652
+ droppedPaths: this.droppedPaths,
653
+ droppedBytes: this.droppedBytes,
654
+ });
655
+ } catch (err) {
656
+ console.error("TreeWatcher backlog overflow logger error:", err);
657
+ }
658
+ }
659
+
658
660
  private arm(): void {
659
661
  if (this.timer !== null) {
660
662
  this.clock.clearTimeout(this.timer);
@@ -671,7 +673,12 @@ export class TreeWatcher {
671
673
  // Snapshot + clear the accumulated paths so the next window starts fresh
672
674
  // even if a listener re-enters synchronously.
673
675
  const batch: TreeChangeBatch = { paths: new Map(this.pending) };
674
- this.pending.clear();
676
+ if (this.overflowed) {
677
+ batch.overflowed = true;
678
+ batch.droppedPaths = this.droppedPaths;
679
+ batch.droppedBytes = this.droppedBytes;
680
+ }
681
+ this.clearPending();
675
682
  // First changed relative path of the burst — the US-003 routing argument.
676
683
  // undefined when the window settled with no captured path (e.g. a synthetic
677
684
  // arm() with no handleEvent).
@@ -685,6 +692,15 @@ export class TreeWatcher {
685
692
  }
686
693
  }
687
694
 
695
+ private clearPending(): void {
696
+ this.pending.clear();
697
+ this.pendingBytes = 0;
698
+ this.overflowed = false;
699
+ this.overflowLogged = false;
700
+ this.droppedPaths = 0;
701
+ this.droppedBytes = 0;
702
+ }
703
+
688
704
  /** True while the watch backend is active. */
689
705
  isWatching(): boolean {
690
706
  return this.backend !== null;
@@ -705,7 +721,7 @@ export class TreeWatcher {
705
721
  this.clock.clearTimeout(this.timer);
706
722
  this.timer = null;
707
723
  }
708
- this.pending.clear();
724
+ this.clearPending();
709
725
  if (this.backend) {
710
726
  this.backend.close();
711
727
  this.backend = null;
@@ -792,6 +808,7 @@ export interface EmitterLogger {
792
808
  * running TreeWatcher (returns an unsubscribe fn). Flag-gated + failure-safe.
793
809
  */
794
810
  export class PushEventEmitter {
811
+ private static readonly MAX_CONCURRENT_PUBLISHES = 16;
795
812
  private readonly originTenantId: string;
796
813
  private readonly originDeviceId: string;
797
814
  private readonly transport: PushTransport;
@@ -801,6 +818,7 @@ export class PushEventEmitter {
801
818
  private readonly logger: EmitterLogger | undefined;
802
819
  private internalSeq = 0;
803
820
  private readonly nextSeq: () => number;
821
+ private publishTail: Promise<void> = Promise.resolve();
804
822
 
805
823
  constructor(opts: PushEventEmitterOptions) {
806
824
  this.originTenantId = opts.originTenantId;
@@ -848,12 +866,35 @@ export class PushEventEmitter {
848
866
  */
849
867
  async emitForBatch(batch: TreeChangeBatch): Promise<void> {
850
868
  if (!this.enabled) return;
851
- const entries = [...batch.paths.entries()];
852
- await Promise.all(
853
- entries.map(([absolutePath, relativePath]) =>
854
- this.emitOne(absolutePath, relativePath),
855
- ),
869
+ const entriesByRel = new Map<string, [string, string]>();
870
+ for (const [absolutePath, relativePath] of batch.paths.entries()) {
871
+ entriesByRel.set(relativePath, [absolutePath, relativePath]);
872
+ }
873
+ const entries = [...entriesByRel.values()];
874
+ const run = this.publishTail.then(
875
+ () => this.emitEntries(entries),
876
+ () => this.emitEntries(entries),
856
877
  );
878
+ this.publishTail = run.catch(() => undefined);
879
+ await run;
880
+ }
881
+
882
+ private async emitEntries(entries: Array<[string, string]>): Promise<void> {
883
+ for (
884
+ let i = 0;
885
+ i < entries.length;
886
+ i += PushEventEmitter.MAX_CONCURRENT_PUBLISHES
887
+ ) {
888
+ const chunk = entries.slice(
889
+ i,
890
+ i + PushEventEmitter.MAX_CONCURRENT_PUBLISHES,
891
+ );
892
+ await Promise.all(
893
+ chunk.map(([absolutePath, relativePath]) =>
894
+ this.emitOne(absolutePath, relativePath),
895
+ ),
896
+ );
897
+ }
857
898
  }
858
899
 
859
900
  private async emitOne(