@indigoai-us/hq-cloud 6.15.75 → 6.15.77
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-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +12 -2
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +4 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +95 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +16 -0
- package/dist/index.test.js.map +1 -0
- package/dist/operation-lock.d.ts +31 -28
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +206 -50
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +105 -1
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/sync/area-resolver.d.ts +66 -0
- package/dist/sync/area-resolver.d.ts.map +1 -0
- package/dist/sync/area-resolver.js +306 -0
- package/dist/sync/area-resolver.js.map +1 -0
- package/dist/sync/area-resolver.test.d.ts +2 -0
- package/dist/sync/area-resolver.test.d.ts.map +1 -0
- package/dist/sync/area-resolver.test.js +211 -0
- package/dist/sync/area-resolver.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +5 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +3 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +45 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +4 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +2 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/parallel-sync-coordinator.d.ts +77 -0
- package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -0
- package/dist/sync/parallel-sync-coordinator.js +287 -0
- package/dist/sync/parallel-sync-coordinator.js.map +1 -0
- package/dist/sync/parallel-sync-coordinator.test.d.ts +2 -0
- package/dist/sync/parallel-sync-coordinator.test.d.ts.map +1 -0
- package/dist/sync/parallel-sync-coordinator.test.js +281 -0
- package/dist/sync/parallel-sync-coordinator.test.js.map +1 -0
- package/dist/sync/path-leases.d.ts +31 -0
- package/dist/sync/path-leases.d.ts.map +1 -0
- package/dist/sync/path-leases.js +74 -0
- package/dist/sync/path-leases.js.map +1 -0
- package/dist/sync/path-leases.test.d.ts +2 -0
- package/dist/sync/path-leases.test.d.ts.map +1 -0
- package/dist/sync/path-leases.test.js +51 -0
- package/dist/sync/path-leases.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +7 -0
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +104 -43
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +62 -1
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/sync/sync-work.d.ts +64 -0
- package/dist/sync/sync-work.d.ts.map +1 -0
- package/dist/sync/sync-work.js +11 -0
- package/dist/sync/sync-work.js.map +1 -0
- package/dist/watcher.d.ts +65 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +280 -24
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +401 -3
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
package/dist/watcher.test.js
CHANGED
|
@@ -4,8 +4,9 @@ import * as os from "os";
|
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { watch } from "chokidar";
|
|
7
|
-
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, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, } from "./watcher.js";
|
|
7
|
+
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, } from "./watcher.js";
|
|
8
8
|
import { StaticFlagProvider } from "./sync/feature-flags.js";
|
|
9
|
+
import { StateStore, StateStoreLockError } from "./sync/state-store.js";
|
|
9
10
|
import { computePersonalVaultPaths } from "./personal-vault.js";
|
|
10
11
|
/**
|
|
11
12
|
* US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
|
|
@@ -790,6 +791,167 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
|
|
|
790
791
|
clock.advance(1);
|
|
791
792
|
expect(changed).toHaveBeenCalledTimes(1);
|
|
792
793
|
});
|
|
794
|
+
it("flushes a quiet, known-small batch through the short debounce window", () => {
|
|
795
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-debounce-"));
|
|
796
|
+
try {
|
|
797
|
+
const small = path.join(dir, "small.md");
|
|
798
|
+
fs.writeFileSync(small, "sixteen-byte-doc");
|
|
799
|
+
const clock = new FakeClock();
|
|
800
|
+
const changed = vi.fn();
|
|
801
|
+
const watcher = new TreeWatcher({
|
|
802
|
+
hqRoot: dir,
|
|
803
|
+
debounceMs: 15_000,
|
|
804
|
+
smallBatchDebounceMs: 2_500,
|
|
805
|
+
smallBatchMaxPaths: 8,
|
|
806
|
+
smallBatchMaxBytes: 1024 * 1024,
|
|
807
|
+
clock,
|
|
808
|
+
pathFilter: () => true,
|
|
809
|
+
});
|
|
810
|
+
watcher.onChange(changed);
|
|
811
|
+
watcher.handleEvent(small);
|
|
812
|
+
clock.advance(2_499);
|
|
813
|
+
expect(changed).not.toHaveBeenCalled();
|
|
814
|
+
clock.advance(1);
|
|
815
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
816
|
+
expect(changed.mock.calls[0][1].paths.get(small)).toBe("small.md");
|
|
817
|
+
watcher.dispose();
|
|
818
|
+
}
|
|
819
|
+
finally {
|
|
820
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
it("keeps a burst above the small-path cap on the 15s debounce", () => {
|
|
824
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-burst-debounce-"));
|
|
825
|
+
try {
|
|
826
|
+
const clock = new FakeClock();
|
|
827
|
+
const changed = vi.fn();
|
|
828
|
+
const watcher = new TreeWatcher({
|
|
829
|
+
hqRoot: dir,
|
|
830
|
+
debounceMs: 15_000,
|
|
831
|
+
smallBatchDebounceMs: 2_500,
|
|
832
|
+
smallBatchMaxPaths: 8,
|
|
833
|
+
smallBatchMaxBytes: 1024 * 1024,
|
|
834
|
+
clock,
|
|
835
|
+
pathFilter: () => true,
|
|
836
|
+
});
|
|
837
|
+
watcher.onChange(changed);
|
|
838
|
+
for (let index = 0; index < 9; index += 1) {
|
|
839
|
+
const file = path.join(dir, `burst-${index}.md`);
|
|
840
|
+
fs.writeFileSync(file, "small");
|
|
841
|
+
watcher.handleEvent(file);
|
|
842
|
+
clock.advance(100);
|
|
843
|
+
}
|
|
844
|
+
// The final event landed at t=800ms; the loop's final 100ms advance is
|
|
845
|
+
// outside the quiet window, so stop one millisecond before t=15_800ms.
|
|
846
|
+
clock.advance(14_899);
|
|
847
|
+
expect(changed).not.toHaveBeenCalled();
|
|
848
|
+
clock.advance(1);
|
|
849
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
850
|
+
expect(changed.mock.calls[0][1].paths.size).toBe(9);
|
|
851
|
+
watcher.dispose();
|
|
852
|
+
}
|
|
853
|
+
finally {
|
|
854
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
it("preserves max-wait for a continuous small batch that never becomes quiet", () => {
|
|
858
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-max-wait-"));
|
|
859
|
+
try {
|
|
860
|
+
const file = path.join(dir, "continuous.md");
|
|
861
|
+
fs.writeFileSync(file, "small");
|
|
862
|
+
const clock = new FakeClock();
|
|
863
|
+
const changed = vi.fn();
|
|
864
|
+
const watcher = new TreeWatcher({
|
|
865
|
+
hqRoot: dir,
|
|
866
|
+
debounceMs: 15_000,
|
|
867
|
+
maxWaitMs: 120_000,
|
|
868
|
+
smallBatchDebounceMs: 2_500,
|
|
869
|
+
smallBatchMaxPaths: 8,
|
|
870
|
+
smallBatchMaxBytes: 1024 * 1024,
|
|
871
|
+
clock,
|
|
872
|
+
pathFilter: () => true,
|
|
873
|
+
});
|
|
874
|
+
watcher.onChange(changed);
|
|
875
|
+
for (let elapsed = 0; elapsed < 120_000; elapsed += 2_000) {
|
|
876
|
+
watcher.handleEvent(file);
|
|
877
|
+
if (elapsed < 118_000)
|
|
878
|
+
clock.advance(2_000);
|
|
879
|
+
}
|
|
880
|
+
expect(changed).not.toHaveBeenCalled();
|
|
881
|
+
clock.advance(2_000);
|
|
882
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
883
|
+
watcher.dispose();
|
|
884
|
+
}
|
|
885
|
+
finally {
|
|
886
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
it("keeps a duplicate directory delete out of the short debounce lane", () => {
|
|
890
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-delete-debounce-"));
|
|
891
|
+
try {
|
|
892
|
+
const deleted = path.join(dir, "deleted-directory");
|
|
893
|
+
const clock = new FakeClock();
|
|
894
|
+
const changed = vi.fn();
|
|
895
|
+
const watcher = new TreeWatcher({
|
|
896
|
+
hqRoot: dir,
|
|
897
|
+
debounceMs: 15_000,
|
|
898
|
+
smallBatchDebounceMs: 2_500,
|
|
899
|
+
smallBatchMaxPaths: 8,
|
|
900
|
+
smallBatchMaxBytes: 1024 * 1024,
|
|
901
|
+
clock,
|
|
902
|
+
pathFilter: () => true,
|
|
903
|
+
});
|
|
904
|
+
watcher.onChange(changed);
|
|
905
|
+
watcher.handleEvent(deleted, "unlinkDir");
|
|
906
|
+
watcher.handleEvent(deleted, "unlink");
|
|
907
|
+
clock.advance(2_500);
|
|
908
|
+
expect(changed).not.toHaveBeenCalled();
|
|
909
|
+
clock.advance(12_500);
|
|
910
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
911
|
+
expect(changed.mock.calls[0][1].changes.get(deleted)).toMatchObject({
|
|
912
|
+
kind: "unlinkDir",
|
|
913
|
+
});
|
|
914
|
+
watcher.dispose();
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
it("keeps an overflowed batch out of the short debounce lane", () => {
|
|
921
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-overflow-debounce-"));
|
|
922
|
+
try {
|
|
923
|
+
const first = path.join(dir, "first.md");
|
|
924
|
+
const dropped = path.join(dir, "dropped.md");
|
|
925
|
+
fs.writeFileSync(first, "small");
|
|
926
|
+
fs.writeFileSync(dropped, "small");
|
|
927
|
+
const clock = new FakeClock();
|
|
928
|
+
const changed = vi.fn();
|
|
929
|
+
const watcher = new TreeWatcher({
|
|
930
|
+
hqRoot: dir,
|
|
931
|
+
debounceMs: 15_000,
|
|
932
|
+
smallBatchDebounceMs: 2_500,
|
|
933
|
+
smallBatchMaxPaths: 8,
|
|
934
|
+
smallBatchMaxBytes: 1024 * 1024,
|
|
935
|
+
maxPendingPaths: 1,
|
|
936
|
+
maxPendingBytes: 1024 * 1024,
|
|
937
|
+
clock,
|
|
938
|
+
pathFilter: () => true,
|
|
939
|
+
onBacklogOverflow: () => undefined,
|
|
940
|
+
});
|
|
941
|
+
watcher.onChange(changed);
|
|
942
|
+
watcher.handleEvent(first);
|
|
943
|
+
watcher.handleEvent(dropped);
|
|
944
|
+
clock.advance(2_500);
|
|
945
|
+
expect(changed).not.toHaveBeenCalled();
|
|
946
|
+
clock.advance(12_500);
|
|
947
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
948
|
+
expect(changed.mock.calls[0][1]).toMatchObject({ overflowed: true });
|
|
949
|
+
watcher.dispose();
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
953
|
+
}
|
|
954
|
+
});
|
|
793
955
|
it("does NOT emit for an ignored / excluded path", () => {
|
|
794
956
|
const { clock, changed, watcher } = makeWatcher({ personalMode: true });
|
|
795
957
|
watcher.handleEvent(path.join(ROOT, ".env"));
|
|
@@ -1249,9 +1411,11 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
1249
1411
|
transport,
|
|
1250
1412
|
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
1251
1413
|
now: () => new Date("2026-06-18T12:00:00.000Z"),
|
|
1414
|
+
clock: opts?.clock,
|
|
1252
1415
|
getSequenceNumber: opts?.getSequenceNumber,
|
|
1253
1416
|
onError: opts?.onError,
|
|
1254
1417
|
lastPublishedContentHashMaxPaths: opts?.lastPublishedContentHashMaxPaths,
|
|
1418
|
+
publishMinIntervalMs: opts?.publishMinIntervalMs ?? 0,
|
|
1255
1419
|
...(opts?.lastPublishedContentHashStateDir
|
|
1256
1420
|
? { lastPublishedContentHashStateDir: opts.lastPublishedContentHashStateDir }
|
|
1257
1421
|
: {}),
|
|
@@ -1371,6 +1535,109 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
1371
1535
|
]);
|
|
1372
1536
|
expect(published[0].contentHash).not.toBe(published[1].contentHash);
|
|
1373
1537
|
});
|
|
1538
|
+
it("coalesces ten rewrites of one path into an immediate and trailing latest publish", async () => {
|
|
1539
|
+
const clock = new FakeClock();
|
|
1540
|
+
const published = [];
|
|
1541
|
+
const rewritten = path.join(dir, "high-churn-prd.json");
|
|
1542
|
+
const relativePath = "personal/projects/churn/prd.json";
|
|
1543
|
+
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
1544
|
+
const batch = { paths: new Map([[rewritten, relativePath]]) };
|
|
1545
|
+
for (let version = 0; version < 10; version += 1) {
|
|
1546
|
+
fs.writeFileSync(rewritten, `{"version":${version}}\n`);
|
|
1547
|
+
await emitter.emitForBatch(batch);
|
|
1548
|
+
if (version < 9)
|
|
1549
|
+
clock.advance(5_000);
|
|
1550
|
+
}
|
|
1551
|
+
expect(published).toHaveLength(1);
|
|
1552
|
+
clock.advance(15_000);
|
|
1553
|
+
await emitter.emitForBatch({ paths: new Map() });
|
|
1554
|
+
expect(published).toHaveLength(2);
|
|
1555
|
+
expect(clock.pendingTimerCount()).toBe(0);
|
|
1556
|
+
expect(published[1]).toMatchObject({ kind: "upsert", relativePath });
|
|
1557
|
+
expect(published[1].contentHash).toBe(`sha256:${createHash("sha256").update('{"version":9}\n').digest("hex")}`);
|
|
1558
|
+
});
|
|
1559
|
+
it("publishes the uploaded deferred revision when the local file changes before flush", async () => {
|
|
1560
|
+
const clock = new FakeClock();
|
|
1561
|
+
const published = [];
|
|
1562
|
+
const rewritten = path.join(dir, "captured-deferred-revision.json");
|
|
1563
|
+
const relativePath = "personal/projects/captured/prd.json";
|
|
1564
|
+
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
1565
|
+
const batch = { paths: new Map([[rewritten, relativePath]]) };
|
|
1566
|
+
fs.writeFileSync(rewritten, '{"revision":"uploaded"}\n');
|
|
1567
|
+
await emitter.emitForBatch(batch);
|
|
1568
|
+
fs.writeFileSync(rewritten, '{"revision":"deferred-uploaded"}\n');
|
|
1569
|
+
await emitter.emitForBatch(batch);
|
|
1570
|
+
const expectedHash = `sha256:${createHash("sha256")
|
|
1571
|
+
.update('{"revision":"deferred-uploaded"}\n')
|
|
1572
|
+
.digest("hex")}`;
|
|
1573
|
+
// This local revision has not completed its scoped push yet. The trailing
|
|
1574
|
+
// announcement must remain bound to the previously uploaded bytes.
|
|
1575
|
+
fs.writeFileSync(rewritten, '{"revision":"newer-local"}\n');
|
|
1576
|
+
clock.advance(60_000);
|
|
1577
|
+
await emitter.emitForBatch({ paths: new Map() });
|
|
1578
|
+
expect(published).toHaveLength(2);
|
|
1579
|
+
expect(published[1]).toMatchObject({
|
|
1580
|
+
kind: "upsert",
|
|
1581
|
+
relativePath,
|
|
1582
|
+
contentHash: expectedHash,
|
|
1583
|
+
});
|
|
1584
|
+
expect(published[1].contentHash).not.toBe(`sha256:${createHash("sha256")
|
|
1585
|
+
.update('{"revision":"newer-local"}\n')
|
|
1586
|
+
.digest("hex")}`);
|
|
1587
|
+
});
|
|
1588
|
+
it("skips the trailing publish when the final rewrite restores the last published hash", async () => {
|
|
1589
|
+
const clock = new FakeClock();
|
|
1590
|
+
const published = [];
|
|
1591
|
+
const rewritten = path.join(dir, "reverted-prd.json");
|
|
1592
|
+
const relativePath = "personal/projects/reverted/prd.json";
|
|
1593
|
+
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
1594
|
+
const batch = { paths: new Map([[rewritten, relativePath]]) };
|
|
1595
|
+
fs.writeFileSync(rewritten, "A\n");
|
|
1596
|
+
await emitter.emitForBatch(batch);
|
|
1597
|
+
fs.writeFileSync(rewritten, "B\n");
|
|
1598
|
+
await emitter.emitForBatch(batch);
|
|
1599
|
+
fs.writeFileSync(rewritten, "A\n");
|
|
1600
|
+
await emitter.emitForBatch(batch);
|
|
1601
|
+
clock.advance(60_000);
|
|
1602
|
+
await emitter.emitForBatch({ paths: new Map() });
|
|
1603
|
+
expect(published).toHaveLength(1);
|
|
1604
|
+
expect(clock.pendingTimerCount()).toBe(0);
|
|
1605
|
+
expect(published[0]).toMatchObject({ kind: "upsert", relativePath });
|
|
1606
|
+
});
|
|
1607
|
+
it("publishes a delete immediately while an upsert is deferred", async () => {
|
|
1608
|
+
const clock = new FakeClock();
|
|
1609
|
+
const published = [];
|
|
1610
|
+
const deleted = path.join(dir, "delete-during-quiet.jsonl");
|
|
1611
|
+
const relativePath = "workspace/.session-logs/delete-during-quiet.jsonl";
|
|
1612
|
+
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
|
|
1613
|
+
const batch = { paths: new Map([[deleted, relativePath]]) };
|
|
1614
|
+
fs.writeFileSync(deleted, "A\n");
|
|
1615
|
+
await emitter.emitForBatch(batch);
|
|
1616
|
+
fs.writeFileSync(deleted, "B\n");
|
|
1617
|
+
await emitter.emitForBatch(batch);
|
|
1618
|
+
fs.rmSync(deleted);
|
|
1619
|
+
await emitter.emitForBatch(batch);
|
|
1620
|
+
expect(published.map((event) => event.kind)).toEqual(["upsert", "delete"]);
|
|
1621
|
+
clock.advance(60_000);
|
|
1622
|
+
await emitter.emitForBatch({ paths: new Map() });
|
|
1623
|
+
expect(published).toHaveLength(2);
|
|
1624
|
+
});
|
|
1625
|
+
it("does not make a second path wait behind another path's quiet interval", async () => {
|
|
1626
|
+
const published = [];
|
|
1627
|
+
const first = path.join(dir, "first-high-churn.jsonl");
|
|
1628
|
+
const second = path.join(dir, "independent.jsonl");
|
|
1629
|
+
const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000 });
|
|
1630
|
+
fs.writeFileSync(first, "A\n");
|
|
1631
|
+
fs.writeFileSync(second, "B\n");
|
|
1632
|
+
await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
|
|
1633
|
+
fs.writeFileSync(first, "A2\n");
|
|
1634
|
+
await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
|
|
1635
|
+
await emitter.emitForBatch({ paths: new Map([[second, "workspace/.session-logs/second.jsonl"]]) });
|
|
1636
|
+
expect(published.map((event) => event.relativePath)).toEqual([
|
|
1637
|
+
"workspace/.session-logs/first.jsonl",
|
|
1638
|
+
"workspace/.session-logs/second.jsonl",
|
|
1639
|
+
]);
|
|
1640
|
+
});
|
|
1374
1641
|
it("publishes a content revert because it compares only with the last publication", async () => {
|
|
1375
1642
|
const published = [];
|
|
1376
1643
|
const reverted = path.join(dir, "reverted-session-log.jsonl");
|
|
@@ -1395,6 +1662,7 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
1395
1662
|
originTenantId: "tenant-indigo",
|
|
1396
1663
|
originDeviceId: "device-a",
|
|
1397
1664
|
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
1665
|
+
publishMinIntervalMs: 0,
|
|
1398
1666
|
transport: {
|
|
1399
1667
|
start: async () => { },
|
|
1400
1668
|
dispose: async () => { },
|
|
@@ -1545,6 +1813,95 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
1545
1813
|
expect(published).toHaveLength(1);
|
|
1546
1814
|
expect(published[0]).toMatchObject({ kind: "upsert", relativePath: "fresh-device.md" });
|
|
1547
1815
|
});
|
|
1816
|
+
it("retries a durable publish-state lock timeout without rejecting the watcher announce", async () => {
|
|
1817
|
+
const clock = new FakeClock();
|
|
1818
|
+
const onError = vi.fn();
|
|
1819
|
+
const appendAsync = vi.spyOn(StateStore.prototype, "appendAsync")
|
|
1820
|
+
.mockResolvedValueOnce({})
|
|
1821
|
+
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
|
|
1822
|
+
const appendIfAsync = vi.spyOn(StateStore.prototype, "appendIfAsync");
|
|
1823
|
+
const published = [];
|
|
1824
|
+
const changed = path.join(dir, "lock-timeout.md");
|
|
1825
|
+
fs.writeFileSync(changed, "lock timeout\n");
|
|
1826
|
+
try {
|
|
1827
|
+
await expect(makeEmitter({
|
|
1828
|
+
clock,
|
|
1829
|
+
onError,
|
|
1830
|
+
published,
|
|
1831
|
+
lastPublishedContentHashStateDir: path.join(dir, "lock-timeout-state"),
|
|
1832
|
+
}).emitForBatch({ paths: new Map([[changed, "lock-timeout.md"]]) })).resolves.toBeUndefined();
|
|
1833
|
+
expect(published).toHaveLength(1);
|
|
1834
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ name: "StateStoreLockError" }), { relativePath: "lock-timeout.md" });
|
|
1835
|
+
expect(appendAsync).toHaveBeenCalledTimes(2); // invalidate, then the failed durable set
|
|
1836
|
+
const conditionalAppendsBeforeRetry = appendIfAsync.mock.calls.length;
|
|
1837
|
+
clock.advance(250);
|
|
1838
|
+
await Promise.resolve();
|
|
1839
|
+
await Promise.resolve();
|
|
1840
|
+
expect(appendIfAsync).toHaveBeenCalledTimes(conditionalAppendsBeforeRetry + 1);
|
|
1841
|
+
}
|
|
1842
|
+
finally {
|
|
1843
|
+
appendIfAsync.mockRestore();
|
|
1844
|
+
appendAsync.mockRestore();
|
|
1845
|
+
}
|
|
1846
|
+
});
|
|
1847
|
+
it("cancels a stale durable-state retry after persisting a newer hash", async () => {
|
|
1848
|
+
const clock = new FakeClock();
|
|
1849
|
+
const appendAsync = vi.spyOn(StateStore.prototype, "appendAsync")
|
|
1850
|
+
.mockResolvedValueOnce({})
|
|
1851
|
+
.mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
|
|
1852
|
+
const stateDir = path.join(dir, "superseded-retry-state");
|
|
1853
|
+
const changed = path.join(dir, "superseded-retry.md");
|
|
1854
|
+
const published = [];
|
|
1855
|
+
fs.writeFileSync(changed, "A\n");
|
|
1856
|
+
try {
|
|
1857
|
+
const emitter = makeEmitter({ clock, published, lastPublishedContentHashStateDir: stateDir });
|
|
1858
|
+
await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
|
|
1859
|
+
expect(clock.pendingTimerCount()).toBe(1);
|
|
1860
|
+
fs.writeFileSync(changed, "B\n");
|
|
1861
|
+
await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
|
|
1862
|
+
expect(clock.pendingTimerCount()).toBe(0);
|
|
1863
|
+
const appendsAfterNewerSuccess = appendAsync.mock.calls.length;
|
|
1864
|
+
clock.advance(10_000);
|
|
1865
|
+
await Promise.resolve();
|
|
1866
|
+
expect(appendAsync).toHaveBeenCalledTimes(appendsAfterNewerSuccess);
|
|
1867
|
+
const afterRestart = [];
|
|
1868
|
+
await makeEmitter({
|
|
1869
|
+
published: afterRestart,
|
|
1870
|
+
lastPublishedContentHashStateDir: stateDir,
|
|
1871
|
+
}).emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
|
|
1872
|
+
expect(afterRestart).toEqual([]);
|
|
1873
|
+
}
|
|
1874
|
+
finally {
|
|
1875
|
+
appendAsync.mockRestore();
|
|
1876
|
+
}
|
|
1877
|
+
});
|
|
1878
|
+
it("drops permanent durable-state persistence errors without scheduling retries", async () => {
|
|
1879
|
+
const clock = new FakeClock();
|
|
1880
|
+
const onError = vi.fn();
|
|
1881
|
+
const appendAsync = vi.spyOn(StateStore.prototype, "appendAsync")
|
|
1882
|
+
.mockResolvedValueOnce({})
|
|
1883
|
+
.mockRejectedValue(new Error("EACCES: state directory is unwritable"));
|
|
1884
|
+
const changed = path.join(dir, "permanent-state-error.md");
|
|
1885
|
+
fs.writeFileSync(changed, "cannot persist\n");
|
|
1886
|
+
try {
|
|
1887
|
+
await makeEmitter({
|
|
1888
|
+
clock,
|
|
1889
|
+
onError,
|
|
1890
|
+
lastPublishedContentHashStateDir: path.join(dir, "permanent-state-error"),
|
|
1891
|
+
}).emitForBatch({ paths: new Map([[changed, "permanent-state-error.md"]]) });
|
|
1892
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
1893
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("EACCES") }), { relativePath: "permanent-state-error.md" });
|
|
1894
|
+
expect(clock.pendingTimerCount()).toBe(0);
|
|
1895
|
+
const appendsAfterFailure = appendAsync.mock.calls.length;
|
|
1896
|
+
clock.advance(60_000);
|
|
1897
|
+
await Promise.resolve();
|
|
1898
|
+
expect(appendAsync).toHaveBeenCalledTimes(appendsAfterFailure);
|
|
1899
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
1900
|
+
}
|
|
1901
|
+
finally {
|
|
1902
|
+
appendAsync.mockRestore();
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1548
1905
|
it("emits push telemetry only when publication fails", async () => {
|
|
1549
1906
|
const posts = [];
|
|
1550
1907
|
const telemetryClient = {
|
|
@@ -1758,7 +2115,13 @@ describe("TreeWatcher deferred delete snapshots", () => {
|
|
|
1758
2115
|
// Event-pass debounce configuration (HQ_SYNC_EVENT_DEBOUNCE_MS / _MAX_WAIT_MS)
|
|
1759
2116
|
// ---------------------------------------------------------------------------
|
|
1760
2117
|
describe("resolveEventDebounceConfig", () => {
|
|
1761
|
-
const ENV_KEYS = [
|
|
2118
|
+
const ENV_KEYS = [
|
|
2119
|
+
EVENT_DEBOUNCE_MS_ENV,
|
|
2120
|
+
EVENT_MAX_WAIT_MS_ENV,
|
|
2121
|
+
EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV,
|
|
2122
|
+
EVENT_SMALL_BATCH_MAX_PATHS_ENV,
|
|
2123
|
+
EVENT_SMALL_BATCH_MAX_BYTES_ENV,
|
|
2124
|
+
];
|
|
1762
2125
|
let saved;
|
|
1763
2126
|
beforeEach(() => {
|
|
1764
2127
|
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
@@ -1777,6 +2140,9 @@ describe("resolveEventDebounceConfig", () => {
|
|
|
1777
2140
|
expect(resolveEventDebounceConfig()).toEqual({
|
|
1778
2141
|
debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
|
|
1779
2142
|
maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
|
|
2143
|
+
smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
|
|
2144
|
+
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
2145
|
+
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
1780
2146
|
});
|
|
1781
2147
|
expect(DEFAULT_EVENT_DEBOUNCE_MS).toBe(15_000);
|
|
1782
2148
|
expect(DEFAULT_EVENT_MAX_WAIT_MS).toBe(120_000);
|
|
@@ -1784,9 +2150,15 @@ describe("resolveEventDebounceConfig", () => {
|
|
|
1784
2150
|
it("honors env overrides", () => {
|
|
1785
2151
|
process.env[EVENT_DEBOUNCE_MS_ENV] = "5000";
|
|
1786
2152
|
process.env[EVENT_MAX_WAIT_MS_ENV] = "30000";
|
|
2153
|
+
process.env[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV] = "2000";
|
|
2154
|
+
process.env[EVENT_SMALL_BATCH_MAX_PATHS_ENV] = "6";
|
|
2155
|
+
process.env[EVENT_SMALL_BATCH_MAX_BYTES_ENV] = "4096";
|
|
1787
2156
|
expect(resolveEventDebounceConfig()).toEqual({
|
|
1788
2157
|
debounceMs: 5000,
|
|
1789
2158
|
maxWaitMs: 30_000,
|
|
2159
|
+
smallBatchDebounceMs: 2000,
|
|
2160
|
+
smallBatchMaxPaths: 6,
|
|
2161
|
+
smallBatchMaxBytes: 4096,
|
|
1790
2162
|
});
|
|
1791
2163
|
});
|
|
1792
2164
|
it("treats 0, negative, and non-numeric values as unset (defaults)", () => {
|
|
@@ -1796,6 +2168,9 @@ describe("resolveEventDebounceConfig", () => {
|
|
|
1796
2168
|
expect(resolveEventDebounceConfig()).toEqual({
|
|
1797
2169
|
debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
|
|
1798
2170
|
maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
|
|
2171
|
+
smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
|
|
2172
|
+
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
2173
|
+
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
1799
2174
|
});
|
|
1800
2175
|
}
|
|
1801
2176
|
});
|
|
@@ -1805,13 +2180,36 @@ describe("resolveEventDebounceConfig", () => {
|
|
|
1805
2180
|
expect(resolveEventDebounceConfig()).toEqual({
|
|
1806
2181
|
debounceMs: 60_000,
|
|
1807
2182
|
maxWaitMs: 60_000,
|
|
2183
|
+
smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
|
|
2184
|
+
smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
|
|
2185
|
+
smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
|
|
1808
2186
|
});
|
|
1809
2187
|
});
|
|
1810
2188
|
it("accepts an injected env record without touching process.env", () => {
|
|
1811
2189
|
expect(resolveEventDebounceConfig({
|
|
1812
2190
|
[EVENT_DEBOUNCE_MS_ENV]: "1000",
|
|
1813
2191
|
[EVENT_MAX_WAIT_MS_ENV]: "2000",
|
|
1814
|
-
|
|
2192
|
+
[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "750",
|
|
2193
|
+
[EVENT_SMALL_BATCH_MAX_PATHS_ENV]: "4",
|
|
2194
|
+
[EVENT_SMALL_BATCH_MAX_BYTES_ENV]: "1024",
|
|
2195
|
+
})).toEqual({
|
|
2196
|
+
debounceMs: 1000,
|
|
2197
|
+
maxWaitMs: 2000,
|
|
2198
|
+
smallBatchDebounceMs: 750,
|
|
2199
|
+
smallBatchMaxPaths: 4,
|
|
2200
|
+
smallBatchMaxBytes: 1024,
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
});
|
|
2204
|
+
describe("resolvePublishMinIntervalMs", () => {
|
|
2205
|
+
it("defaults to 60 seconds, accepts zero to disable coalescing, and rejects invalid values", () => {
|
|
2206
|
+
expect(resolvePublishMinIntervalMs({})).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
|
|
2207
|
+
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "0" })).toBe(0);
|
|
2208
|
+
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "invalid" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
|
|
2209
|
+
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "-1" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
|
|
2210
|
+
});
|
|
2211
|
+
it("uses the environment override in milliseconds", () => {
|
|
2212
|
+
expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "1234.9" })).toBe(1234);
|
|
1815
2213
|
});
|
|
1816
2214
|
});
|
|
1817
2215
|
//# sourceMappingURL=watcher.test.js.map
|