@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.
- package/dist/bin/sync-runner-company.d.ts +35 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.js +290 -0
- package/dist/bin/sync-runner-company.js.map +1 -0
- package/dist/bin/sync-runner-events.d.ts +12 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.js +12 -0
- package/dist/bin/sync-runner-events.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +53 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.js +59 -0
- package/dist/bin/sync-runner-planning.js.map +1 -0
- package/dist/bin/sync-runner-rollup.d.ts +24 -0
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
- package/dist/bin/sync-runner-rollup.js +46 -0
- package/dist/bin/sync-runner-rollup.js.map +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts +5 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
- package/dist/bin/sync-runner-telemetry.js +5 -0
- package/dist/bin/sync-runner-telemetry.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-loop.js +372 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.js +74 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +5 -54
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +76 -978
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +34 -17
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -5
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +75 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +45 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +320 -170
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +276 -660
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +541 -748
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +382 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/daemon-worker.d.ts +2 -2
- package/dist/daemon-worker.js +3 -3
- package/dist/daemon-worker.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +149 -30
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +8 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +8 -3
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +85 -16
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +213 -2
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +12 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +45 -17
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +67 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync-core.d.ts +27 -0
- package/dist/sync-core.d.ts.map +1 -0
- package/dist/sync-core.js +54 -0
- package/dist/sync-core.js.map +1 -0
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +284 -36
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +59 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +38 -20
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +155 -143
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +350 -0
- package/src/bin/sync-runner-events.ts +25 -0
- package/src/bin/sync-runner-planning.ts +121 -0
- package/src/bin/sync-runner-rollup.ts +72 -0
- package/src/bin/sync-runner-telemetry.ts +8 -0
- package/src/bin/sync-runner-watch-loop.ts +443 -0
- package/src/bin/sync-runner-watch-routes.ts +86 -0
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +99 -1054
- package/src/cli/reindex.test.ts +41 -3
- package/src/cli/reindex.ts +35 -19
- package/src/cli/rescue-classify-ordering.test.ts +81 -0
- package/src/cli/rescue-core.ts +400 -165
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +420 -693
- package/src/cli/sync.test.ts +460 -1
- package/src/cli/sync.ts +788 -825
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/daemon-worker.ts +3 -3
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +183 -31
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +235 -1
- package/src/remote-pull.ts +106 -18
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +73 -1
- package/src/sync/push-receiver.ts +56 -20
- package/src/sync-core.ts +58 -0
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/vault-client.test.ts +74 -0
- package/src/vault-client.ts +395 -43
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +215 -174
package/src/watcher.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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.
|
|
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
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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(
|