@indigoai-us/hq-cloud 6.15.63 → 6.15.65
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 +19 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +338 -58
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +8 -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 +724 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.test.js +2 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +11 -2
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +19 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/logger.test.js +3 -0
- package/dist/sync/logger.test.js.map +1 -1
- package/dist/sync/metrics.test.js +3 -0
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +21 -3
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +92 -59
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +169 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/watcher.d.ts +17 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +74 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +57 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@ import { runRunner, runRunnerWithLoop, resolveDeletePolicy, resolveSkipPersonal,
|
|
|
14
14
|
import { PresignObjectIO, S3SdkObjectIO } from "../object-io.js";
|
|
15
15
|
import { setBandwidthGovernorForTesting } from "../bandwidth.js";
|
|
16
16
|
import { FakeClock, TreeWatcher, } from "../watcher.js";
|
|
17
|
-
import { adaptivePollMs, advanceMonotonicDeadline, DEFAULT_EVENT_BATCH_LIMIT, DEFAULT_FULL_RECONCILE_MS, EVENT_BATCH_LIMIT_ENV, FULL_RECONCILE_MS_ENV, journalEntriesForWatcherDelete, resolveEventBatchLimit, resolveFullReconcileMs, shouldQuarantineWatcherDeleteIntents, WATCHER_REDISCOVERY_RETRY_MS, WATCHER_REDISCOVERY_RETRY_MAX_MS, } from "./sync-runner-watch-loop.js";
|
|
17
|
+
import { adaptivePollMs, advanceMonotonicDeadline, DEFAULT_EVENT_BATCH_LIMIT, DEFAULT_FAST_LANE_MAX_BYTES, DEFAULT_FAST_LANE_MAX_FILES, DEFAULT_FULL_RECONCILE_MS, EVENT_BATCH_LIMIT_ENV, FAST_LANE_MAX_BYTES_ENV, FAST_LANE_MAX_FILES_ENV, FULL_RECONCILE_MS_ENV, isFastLocalBatch, isFastReceiverBatch, journalEntriesForWatcherDelete, resolveEventBatchLimit, resolveFullReconcileMs, shouldQuarantineWatcherDeleteIntents, WATCHER_REDISCOVERY_RETRY_MS, WATCHER_REDISCOVERY_RETRY_MAX_MS, } from "./sync-runner-watch-loop.js";
|
|
18
18
|
import { PERSONAL_VAULT_JOURNAL_SLUG, readJournal, writeJournal, } from "../journal.js";
|
|
19
19
|
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
20
20
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
@@ -4340,6 +4340,63 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
4340
4340
|
triggerShutdown();
|
|
4341
4341
|
await loop;
|
|
4342
4342
|
});
|
|
4343
|
+
it("pushes a file created before watcher ready through the warm-up catch-up", async () => {
|
|
4344
|
+
const watcher = makeBatchWatcherStub();
|
|
4345
|
+
let markReady = () => { };
|
|
4346
|
+
watcher.onReady = (listener) => {
|
|
4347
|
+
markReady = listener;
|
|
4348
|
+
return () => { };
|
|
4349
|
+
};
|
|
4350
|
+
watcher.collectWarmupCatchup = vi.fn(() => ({
|
|
4351
|
+
batch: {
|
|
4352
|
+
paths: new Map([[
|
|
4353
|
+
"/tmp/hq/companies/indigo/knowledge/created-during-warmup.md",
|
|
4354
|
+
"companies/indigo/knowledge/created-during-warmup.md",
|
|
4355
|
+
]]),
|
|
4356
|
+
changes: new Map([[
|
|
4357
|
+
"/tmp/hq/companies/indigo/knowledge/created-during-warmup.md",
|
|
4358
|
+
{ kind: "add" },
|
|
4359
|
+
]]),
|
|
4360
|
+
},
|
|
4361
|
+
scannedPaths: 1,
|
|
4362
|
+
}));
|
|
4363
|
+
const sleep = makeSteppableSleep();
|
|
4364
|
+
const runPass = vi.fn().mockResolvedValue(completedPassOutcome());
|
|
4365
|
+
let shutdown = () => { };
|
|
4366
|
+
const loop = runRunnerWithLoop([
|
|
4367
|
+
"--company", "indigo", "--watch", "--event-push", "--direction", "pull",
|
|
4368
|
+
"--hq-root", "/tmp/hq", "--poll-remote-ms", "60000",
|
|
4369
|
+
], {
|
|
4370
|
+
runPass,
|
|
4371
|
+
createWatcher: () => watcher,
|
|
4372
|
+
sleep: sleep.sleep,
|
|
4373
|
+
onShutdownSignal: (handler) => {
|
|
4374
|
+
shutdown = handler;
|
|
4375
|
+
return () => { };
|
|
4376
|
+
},
|
|
4377
|
+
});
|
|
4378
|
+
try {
|
|
4379
|
+
await flushLoopMicrotasks();
|
|
4380
|
+
markReady();
|
|
4381
|
+
await flushLoopMicrotasks();
|
|
4382
|
+
expect(watcher.collectWarmupCatchup).toHaveBeenCalledOnce();
|
|
4383
|
+
expect(runPass.mock.calls.map(([argv]) => argv)).toEqual([
|
|
4384
|
+
["--company", "indigo", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
4385
|
+
[
|
|
4386
|
+
"--company", "indigo", "--direction", "push",
|
|
4387
|
+
"--scope-path", "knowledge/created-during-warmup.md", "--hq-root", "/tmp/hq",
|
|
4388
|
+
],
|
|
4389
|
+
[
|
|
4390
|
+
"--company", "indigo", "--direction", "pull",
|
|
4391
|
+
"--scope-path", "knowledge/created-during-warmup.md", "--hq-root", "/tmp/hq",
|
|
4392
|
+
],
|
|
4393
|
+
]);
|
|
4394
|
+
}
|
|
4395
|
+
finally {
|
|
4396
|
+
shutdown();
|
|
4397
|
+
await loop;
|
|
4398
|
+
}
|
|
4399
|
+
});
|
|
4343
4400
|
it("keeps every event surface off until a result proves maintenance completed", async () => {
|
|
4344
4401
|
const watcher = makeWatcherStub();
|
|
4345
4402
|
const sleep = makeSteppableSleep();
|
|
@@ -4506,10 +4563,10 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
4506
4563
|
await flushLoopMicrotasks();
|
|
4507
4564
|
expect(sleepCalls).toEqual([600_000]);
|
|
4508
4565
|
// The fake 600-second timer remains unresolved. The watcher wake must
|
|
4509
|
-
// nonetheless start
|
|
4566
|
+
// nonetheless start its bounded push and configured pull immediately.
|
|
4510
4567
|
watcher.emit("companies/indigo/board.json");
|
|
4511
4568
|
await flushLoopMicrotasks();
|
|
4512
|
-
expect(runPass).toHaveBeenCalledTimes(
|
|
4569
|
+
expect(runPass).toHaveBeenCalledTimes(3);
|
|
4513
4570
|
triggerShutdown();
|
|
4514
4571
|
await loop;
|
|
4515
4572
|
});
|
|
@@ -4592,6 +4649,528 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
4592
4649
|
triggerShutdown();
|
|
4593
4650
|
await loop;
|
|
4594
4651
|
});
|
|
4652
|
+
it("fast lane: a small watcher batch dispatches during a slow bulk pass", async () => {
|
|
4653
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-watch-"));
|
|
4654
|
+
const relativePath = "companies/indigo/realtime.md";
|
|
4655
|
+
const absolutePath = path.join(hqRoot, relativePath);
|
|
4656
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
4657
|
+
fs.writeFileSync(absolutePath, "tiny realtime edit");
|
|
4658
|
+
const watcher = makeBatchWatcherStub();
|
|
4659
|
+
let triggerShutdown = () => { };
|
|
4660
|
+
let releaseBulk = () => { };
|
|
4661
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
4662
|
+
const order = [];
|
|
4663
|
+
let fullPasses = 0;
|
|
4664
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4665
|
+
if (passArgv.includes("--scope-path")) {
|
|
4666
|
+
order.push("fast");
|
|
4667
|
+
return completedPassOutcome();
|
|
4668
|
+
}
|
|
4669
|
+
fullPasses += 1;
|
|
4670
|
+
if (fullPasses === 2) {
|
|
4671
|
+
order.push("bulk");
|
|
4672
|
+
await bulkGate;
|
|
4673
|
+
}
|
|
4674
|
+
return completedPassOutcome();
|
|
4675
|
+
});
|
|
4676
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "both", "--hq-root", hqRoot], {
|
|
4677
|
+
runPass,
|
|
4678
|
+
clock: new FakeClock(),
|
|
4679
|
+
createWatcher: () => watcher,
|
|
4680
|
+
sleep: () => new Promise(() => { }),
|
|
4681
|
+
onShutdownSignal: (handler) => {
|
|
4682
|
+
triggerShutdown = handler;
|
|
4683
|
+
return () => { };
|
|
4684
|
+
},
|
|
4685
|
+
});
|
|
4686
|
+
await flushLoopMicrotasks();
|
|
4687
|
+
watcher.emit(); // starts the second, deliberately blocked full pass
|
|
4688
|
+
await flushLoopMicrotasks();
|
|
4689
|
+
expect(order).toEqual(["bulk"]);
|
|
4690
|
+
watcher.emit(relativePath, { paths: new Map([[absolutePath, relativePath]]) });
|
|
4691
|
+
await flushLoopMicrotasks(30);
|
|
4692
|
+
// Regression: origin/main leaves this as ["bulk"] until releaseBulk().
|
|
4693
|
+
expect(order).toEqual(["bulk", "fast", "fast"]);
|
|
4694
|
+
releaseBulk();
|
|
4695
|
+
await flushLoopMicrotasks();
|
|
4696
|
+
triggerShutdown();
|
|
4697
|
+
await loop;
|
|
4698
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4699
|
+
});
|
|
4700
|
+
it("fast lane: a small receiver pull dispatches during a slow bulk pass", async () => {
|
|
4701
|
+
const watcher = makeWatcherStub();
|
|
4702
|
+
let triggerShutdown = () => { };
|
|
4703
|
+
let receiverSync;
|
|
4704
|
+
let releaseBulk = () => { };
|
|
4705
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
4706
|
+
const order = [];
|
|
4707
|
+
let fullPasses = 0;
|
|
4708
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4709
|
+
if (passArgv.includes("--scope-path")) {
|
|
4710
|
+
order.push("fast-pull");
|
|
4711
|
+
return completedPassOutcome();
|
|
4712
|
+
}
|
|
4713
|
+
fullPasses += 1;
|
|
4714
|
+
if (fullPasses === 2) {
|
|
4715
|
+
order.push("bulk");
|
|
4716
|
+
await bulkGate;
|
|
4717
|
+
}
|
|
4718
|
+
return completedPassOutcome();
|
|
4719
|
+
});
|
|
4720
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq-fast-lane-receiver"], {
|
|
4721
|
+
runPass,
|
|
4722
|
+
createWatcher: () => watcher,
|
|
4723
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
4724
|
+
receiverSync = syncBatchFn;
|
|
4725
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
4726
|
+
},
|
|
4727
|
+
sleep: () => new Promise(() => { }),
|
|
4728
|
+
onShutdownSignal: (handler) => {
|
|
4729
|
+
triggerShutdown = handler;
|
|
4730
|
+
return () => { };
|
|
4731
|
+
},
|
|
4732
|
+
});
|
|
4733
|
+
await flushLoopMicrotasks();
|
|
4734
|
+
watcher.emit();
|
|
4735
|
+
await flushLoopMicrotasks();
|
|
4736
|
+
expect(order).toEqual(["bulk"]);
|
|
4737
|
+
await receiverSync({
|
|
4738
|
+
events: [{
|
|
4739
|
+
kind: "upsert", relativePath: "companies/acme/realtime.md", contentHash: "sha256:x",
|
|
4740
|
+
mtime: "2026-08-26T00:00:00.000Z", originDeviceId: "peer", originTenantId: "acme",
|
|
4741
|
+
sequenceNumber: 1, eventTimestamp: "2026-08-26T00:00:00.000Z",
|
|
4742
|
+
}],
|
|
4743
|
+
signal: new AbortController().signal,
|
|
4744
|
+
});
|
|
4745
|
+
expect(order).toEqual(["bulk", "fast-pull"]);
|
|
4746
|
+
releaseBulk();
|
|
4747
|
+
await flushLoopMicrotasks();
|
|
4748
|
+
triggerShutdown();
|
|
4749
|
+
await loop;
|
|
4750
|
+
});
|
|
4751
|
+
it("fast lane: retains the root lock until an outliving fast pass completes", async () => {
|
|
4752
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-lock-"));
|
|
4753
|
+
const relativePath = "companies/indigo/realtime.md";
|
|
4754
|
+
const absolutePath = path.join(hqRoot, relativePath);
|
|
4755
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
4756
|
+
fs.writeFileSync(absolutePath, "tiny realtime edit");
|
|
4757
|
+
const watcher = makeBatchWatcherStub();
|
|
4758
|
+
let triggerShutdown = () => { };
|
|
4759
|
+
let releaseBulk = () => { };
|
|
4760
|
+
let releaseFast = () => { };
|
|
4761
|
+
let markFastStarted = () => { };
|
|
4762
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
4763
|
+
const fastGate = new Promise((resolve) => { releaseFast = resolve; });
|
|
4764
|
+
const fastStarted = new Promise((resolve) => { markFastStarted = resolve; });
|
|
4765
|
+
let fullPasses = 0;
|
|
4766
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4767
|
+
if (passArgv.includes("--scope-path")) {
|
|
4768
|
+
markFastStarted();
|
|
4769
|
+
await fastGate;
|
|
4770
|
+
return completedPassOutcome();
|
|
4771
|
+
}
|
|
4772
|
+
fullPasses += 1;
|
|
4773
|
+
if (fullPasses === 2)
|
|
4774
|
+
await bulkGate;
|
|
4775
|
+
return completedPassOutcome();
|
|
4776
|
+
});
|
|
4777
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "both", "--hq-root", hqRoot], {
|
|
4778
|
+
runPass,
|
|
4779
|
+
clock: new FakeClock(),
|
|
4780
|
+
createWatcher: () => watcher,
|
|
4781
|
+
sleep: () => new Promise(() => { }),
|
|
4782
|
+
onShutdownSignal: (handler) => {
|
|
4783
|
+
triggerShutdown = handler;
|
|
4784
|
+
return () => { };
|
|
4785
|
+
},
|
|
4786
|
+
});
|
|
4787
|
+
await flushLoopMicrotasks();
|
|
4788
|
+
watcher.emit();
|
|
4789
|
+
await flushLoopMicrotasks();
|
|
4790
|
+
watcher.emit(relativePath, { paths: new Map([[absolutePath, relativePath]]) });
|
|
4791
|
+
await fastStarted;
|
|
4792
|
+
releaseBulk();
|
|
4793
|
+
await flushLoopMicrotasks(20);
|
|
4794
|
+
// The slow pass has finished, but its fast bypass still mutates. A second
|
|
4795
|
+
// sync/rescue must not acquire the root operation lock in this interval.
|
|
4796
|
+
expect(fs.existsSync(lockPathFor(hqRoot))).toBe(true);
|
|
4797
|
+
releaseFast();
|
|
4798
|
+
await flushLoopMicrotasks(20);
|
|
4799
|
+
expect(fs.existsSync(lockPathFor(hqRoot))).toBe(false);
|
|
4800
|
+
triggerShutdown();
|
|
4801
|
+
await loop;
|
|
4802
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4803
|
+
});
|
|
4804
|
+
it("fast lane: an absent unlinkDir subtree is always slow-lane", () => {
|
|
4805
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-unlink-dir-"));
|
|
4806
|
+
const deletedDir = path.join(hqRoot, "companies/indigo/archive");
|
|
4807
|
+
fs.mkdirSync(deletedDir, { recursive: true });
|
|
4808
|
+
fs.rmSync(deletedDir, { recursive: true, force: true });
|
|
4809
|
+
expect(isFastLocalBatch([deletedDir], new Map([[deletedDir, { kind: "unlinkDir" }]]))).toBe(false);
|
|
4810
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4811
|
+
});
|
|
4812
|
+
it("fast lane: --skip-personal rejects a selected-out personal watcher batch", async () => {
|
|
4813
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-route-"));
|
|
4814
|
+
const relativePath = "knowledge/private.md";
|
|
4815
|
+
const absolutePath = path.join(hqRoot, relativePath);
|
|
4816
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
4817
|
+
fs.writeFileSync(absolutePath, "private");
|
|
4818
|
+
const watcher = makeBatchWatcherStub();
|
|
4819
|
+
let triggerShutdown = () => { };
|
|
4820
|
+
let releaseBulk = () => { };
|
|
4821
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
4822
|
+
let fullPasses = 0;
|
|
4823
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4824
|
+
if (passArgv.includes("--scope-path"))
|
|
4825
|
+
return completedPassOutcome();
|
|
4826
|
+
fullPasses += 1;
|
|
4827
|
+
if (fullPasses === 2)
|
|
4828
|
+
await bulkGate;
|
|
4829
|
+
return completedPassOutcome();
|
|
4830
|
+
});
|
|
4831
|
+
const loop = runRunnerWithLoop(["--companies", "--skip-personal", "--watch", "--event-push", "--direction", "both", "--hq-root", hqRoot], {
|
|
4832
|
+
runPass,
|
|
4833
|
+
clock: new FakeClock(),
|
|
4834
|
+
createWatcher: () => watcher,
|
|
4835
|
+
sleep: () => new Promise(() => { }),
|
|
4836
|
+
onShutdownSignal: (handler) => {
|
|
4837
|
+
triggerShutdown = handler;
|
|
4838
|
+
return () => { };
|
|
4839
|
+
},
|
|
4840
|
+
});
|
|
4841
|
+
await flushLoopMicrotasks();
|
|
4842
|
+
watcher.emit();
|
|
4843
|
+
await flushLoopMicrotasks();
|
|
4844
|
+
runPass.mockClear();
|
|
4845
|
+
watcher.emit(relativePath, { paths: new Map([[absolutePath, relativePath]]) });
|
|
4846
|
+
await flushLoopMicrotasks(20);
|
|
4847
|
+
expect(runPass.mock.calls).toEqual([]);
|
|
4848
|
+
releaseBulk();
|
|
4849
|
+
await flushLoopMicrotasks();
|
|
4850
|
+
triggerShutdown();
|
|
4851
|
+
await loop;
|
|
4852
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4853
|
+
});
|
|
4854
|
+
it("fast lane: signals the V2 scheduler for an immediate watcher dispatch", async () => {
|
|
4855
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-signal-"));
|
|
4856
|
+
const relativePath = "companies/indigo/realtime.md";
|
|
4857
|
+
const absolutePath = path.join(hqRoot, relativePath);
|
|
4858
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
4859
|
+
fs.writeFileSync(absolutePath, "tiny realtime edit");
|
|
4860
|
+
const watcher = makeBatchWatcherStub();
|
|
4861
|
+
const scheduler = {
|
|
4862
|
+
signal: vi.fn(),
|
|
4863
|
+
checkHighWater: vi.fn().mockResolvedValue(undefined),
|
|
4864
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
4865
|
+
};
|
|
4866
|
+
let triggerShutdown = () => { };
|
|
4867
|
+
let releaseBulk = () => { };
|
|
4868
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
4869
|
+
let fullPasses = 0;
|
|
4870
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4871
|
+
if (passArgv.includes("--scope-path"))
|
|
4872
|
+
return completedPassOutcome();
|
|
4873
|
+
fullPasses += 1;
|
|
4874
|
+
if (fullPasses === 2)
|
|
4875
|
+
await bulkGate;
|
|
4876
|
+
return completedPassOutcome();
|
|
4877
|
+
});
|
|
4878
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "both", "--hq-root", hqRoot], {
|
|
4879
|
+
runPass,
|
|
4880
|
+
clock: new FakeClock(),
|
|
4881
|
+
createWatcher: () => watcher,
|
|
4882
|
+
startRealtimeScheduler: async () => scheduler,
|
|
4883
|
+
sleep: () => new Promise(() => { }),
|
|
4884
|
+
onShutdownSignal: (handler) => {
|
|
4885
|
+
triggerShutdown = handler;
|
|
4886
|
+
return () => { };
|
|
4887
|
+
},
|
|
4888
|
+
});
|
|
4889
|
+
await flushLoopMicrotasks(20);
|
|
4890
|
+
watcher.emit();
|
|
4891
|
+
await flushLoopMicrotasks();
|
|
4892
|
+
scheduler.signal.mockClear();
|
|
4893
|
+
watcher.emit(relativePath, { paths: new Map([[absolutePath, relativePath]]) });
|
|
4894
|
+
await flushLoopMicrotasks(20);
|
|
4895
|
+
expect(scheduler.signal).toHaveBeenCalledWith("watcher");
|
|
4896
|
+
releaseBulk();
|
|
4897
|
+
await flushLoopMicrotasks();
|
|
4898
|
+
triggerShutdown();
|
|
4899
|
+
await loop;
|
|
4900
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4901
|
+
});
|
|
4902
|
+
it("fast lane: applies the receiver cap once across all eligible routes", async () => {
|
|
4903
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-receiver-cap-"));
|
|
4904
|
+
const watcher = makeWatcherStub();
|
|
4905
|
+
let receiverSync;
|
|
4906
|
+
let triggerShutdown = () => { };
|
|
4907
|
+
let releaseBulk = () => { };
|
|
4908
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
4909
|
+
let fullPasses = 0;
|
|
4910
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4911
|
+
if (passArgv.includes("--scope-path"))
|
|
4912
|
+
return completedPassOutcome();
|
|
4913
|
+
fullPasses += 1;
|
|
4914
|
+
if (fullPasses === 2)
|
|
4915
|
+
await bulkGate;
|
|
4916
|
+
return completedPassOutcome();
|
|
4917
|
+
});
|
|
4918
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", hqRoot], {
|
|
4919
|
+
runPass,
|
|
4920
|
+
createWatcher: () => watcher,
|
|
4921
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
4922
|
+
receiverSync = syncBatchFn;
|
|
4923
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
4924
|
+
},
|
|
4925
|
+
sleep: () => new Promise(() => { }),
|
|
4926
|
+
onShutdownSignal: (handler) => {
|
|
4927
|
+
triggerShutdown = handler;
|
|
4928
|
+
return () => { };
|
|
4929
|
+
},
|
|
4930
|
+
});
|
|
4931
|
+
await flushLoopMicrotasks();
|
|
4932
|
+
watcher.emit();
|
|
4933
|
+
await flushLoopMicrotasks();
|
|
4934
|
+
runPass.mockClear();
|
|
4935
|
+
const events = [
|
|
4936
|
+
...Array.from({ length: 17 }, (_, index) => ({
|
|
4937
|
+
kind: "upsert",
|
|
4938
|
+
relativePath: `companies/acme/a-${index}.md`,
|
|
4939
|
+
contentHash: "sha256:x",
|
|
4940
|
+
mtime: "2026-08-26T00:00:00.000Z",
|
|
4941
|
+
originDeviceId: "peer",
|
|
4942
|
+
originTenantId: "acme",
|
|
4943
|
+
sequenceNumber: index,
|
|
4944
|
+
eventTimestamp: "2026-08-26T00:00:00.000Z",
|
|
4945
|
+
})),
|
|
4946
|
+
...Array.from({ length: 16 }, (_, index) => ({
|
|
4947
|
+
kind: "upsert",
|
|
4948
|
+
relativePath: `companies/beta/b-${index}.md`,
|
|
4949
|
+
contentHash: "sha256:x",
|
|
4950
|
+
mtime: "2026-08-26T00:00:00.000Z",
|
|
4951
|
+
originDeviceId: "peer",
|
|
4952
|
+
originTenantId: "beta",
|
|
4953
|
+
sequenceNumber: index + 17,
|
|
4954
|
+
eventTimestamp: "2026-08-26T00:00:00.000Z",
|
|
4955
|
+
})),
|
|
4956
|
+
];
|
|
4957
|
+
const receiverDrain = receiverSync({
|
|
4958
|
+
events,
|
|
4959
|
+
signal: new AbortController().signal,
|
|
4960
|
+
});
|
|
4961
|
+
await flushLoopMicrotasks(20);
|
|
4962
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
4963
|
+
releaseBulk();
|
|
4964
|
+
await receiverDrain;
|
|
4965
|
+
triggerShutdown();
|
|
4966
|
+
await loop;
|
|
4967
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4968
|
+
});
|
|
4969
|
+
it("fast lane: queues slow work behind a receiver pass that owns the root lock", async () => {
|
|
4970
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-slow-queue-"));
|
|
4971
|
+
const watcher = makeWatcherStub();
|
|
4972
|
+
let receiverSync;
|
|
4973
|
+
let triggerShutdown = () => { };
|
|
4974
|
+
let releaseFast = () => { };
|
|
4975
|
+
let markFastStarted = () => { };
|
|
4976
|
+
const fastGate = new Promise((resolve) => { releaseFast = resolve; });
|
|
4977
|
+
const fastStarted = new Promise((resolve) => { markFastStarted = resolve; });
|
|
4978
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
4979
|
+
if (passArgv.includes("--scope-path")) {
|
|
4980
|
+
markFastStarted();
|
|
4981
|
+
await fastGate;
|
|
4982
|
+
return completedPassOutcome();
|
|
4983
|
+
}
|
|
4984
|
+
return completedPassOutcome();
|
|
4985
|
+
});
|
|
4986
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--lock-timeout", "0", "--hq-root", hqRoot], {
|
|
4987
|
+
runPass,
|
|
4988
|
+
createWatcher: () => watcher,
|
|
4989
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
4990
|
+
receiverSync = syncBatchFn;
|
|
4991
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
4992
|
+
},
|
|
4993
|
+
sleep: () => new Promise(() => { }),
|
|
4994
|
+
onShutdownSignal: (handler) => {
|
|
4995
|
+
triggerShutdown = handler;
|
|
4996
|
+
return () => { };
|
|
4997
|
+
},
|
|
4998
|
+
});
|
|
4999
|
+
let loopSettled = false;
|
|
5000
|
+
void loop.then(() => { loopSettled = true; });
|
|
5001
|
+
await flushLoopMicrotasks();
|
|
5002
|
+
const receiverDrain = receiverSync({
|
|
5003
|
+
events: [{
|
|
5004
|
+
kind: "upsert",
|
|
5005
|
+
relativePath: "companies/acme/remote.md",
|
|
5006
|
+
contentHash: "sha256:x",
|
|
5007
|
+
mtime: "2026-08-26T00:00:00.000Z",
|
|
5008
|
+
originDeviceId: "peer",
|
|
5009
|
+
originTenantId: "acme",
|
|
5010
|
+
sequenceNumber: 1,
|
|
5011
|
+
eventTimestamp: "2026-08-26T00:00:00.000Z",
|
|
5012
|
+
}],
|
|
5013
|
+
signal: new AbortController().signal,
|
|
5014
|
+
});
|
|
5015
|
+
await fastStarted;
|
|
5016
|
+
// This wake requests a slow reconcile while the receiver owns the lock.
|
|
5017
|
+
watcher.emit();
|
|
5018
|
+
await flushLoopMicrotasks(20);
|
|
5019
|
+
expect(loopSettled).toBe(false);
|
|
5020
|
+
releaseFast();
|
|
5021
|
+
await receiverDrain;
|
|
5022
|
+
triggerShutdown();
|
|
5023
|
+
await loop;
|
|
5024
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
5025
|
+
});
|
|
5026
|
+
it("fast lane: keeps disjoint journal rows under a 16-path interleave with a bulk pass", async () => {
|
|
5027
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-journal-"));
|
|
5028
|
+
const paths = Array.from({ length: 16 }, (_, index) => {
|
|
5029
|
+
const relativePath = `companies/indigo/realtime-${index}.md`;
|
|
5030
|
+
const absolutePath = path.join(hqRoot, relativePath);
|
|
5031
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
5032
|
+
fs.writeFileSync(absolutePath, "tiny realtime edit");
|
|
5033
|
+
return [absolutePath, relativePath];
|
|
5034
|
+
});
|
|
5035
|
+
writeJournal("indigo", { version: "2", lastSync: "", pulls: [], files: {} });
|
|
5036
|
+
const watcher = makeBatchWatcherStub();
|
|
5037
|
+
let triggerShutdown = () => { };
|
|
5038
|
+
let releaseBulk = () => { };
|
|
5039
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
5040
|
+
let fullPasses = 0;
|
|
5041
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
5042
|
+
if (passArgv.includes("--scope-path")) {
|
|
5043
|
+
const fast = readJournal("indigo");
|
|
5044
|
+
for (let index = 0; index < paths.length; index++) {
|
|
5045
|
+
fast.files[`realtime-${index}.md`] = {
|
|
5046
|
+
hash: `fast-${index}`, size: 1, syncedAt: "", direction: "up",
|
|
5047
|
+
};
|
|
5048
|
+
}
|
|
5049
|
+
writeJournal("indigo", fast);
|
|
5050
|
+
return completedPassOutcome();
|
|
5051
|
+
}
|
|
5052
|
+
fullPasses += 1;
|
|
5053
|
+
if (fullPasses === 2) {
|
|
5054
|
+
const slow = readJournal("indigo");
|
|
5055
|
+
await bulkGate;
|
|
5056
|
+
for (let index = 0; index < paths.length; index++) {
|
|
5057
|
+
slow.files[`bulk-${index}.md`] = {
|
|
5058
|
+
hash: `slow-${index}`, size: 1, syncedAt: "", direction: "up",
|
|
5059
|
+
};
|
|
5060
|
+
}
|
|
5061
|
+
writeJournal("indigo", slow);
|
|
5062
|
+
}
|
|
5063
|
+
return completedPassOutcome();
|
|
5064
|
+
});
|
|
5065
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "push", "--hq-root", hqRoot], {
|
|
5066
|
+
runPass,
|
|
5067
|
+
clock: new FakeClock(),
|
|
5068
|
+
createWatcher: () => watcher,
|
|
5069
|
+
sleep: () => new Promise(() => { }),
|
|
5070
|
+
onShutdownSignal: (handler) => {
|
|
5071
|
+
triggerShutdown = handler;
|
|
5072
|
+
return () => { };
|
|
5073
|
+
},
|
|
5074
|
+
});
|
|
5075
|
+
await flushLoopMicrotasks();
|
|
5076
|
+
watcher.emit();
|
|
5077
|
+
await flushLoopMicrotasks();
|
|
5078
|
+
watcher.emit(paths[0][1], { paths: new Map(paths) });
|
|
5079
|
+
await flushLoopMicrotasks(30);
|
|
5080
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(paths.length);
|
|
5081
|
+
releaseBulk();
|
|
5082
|
+
await flushLoopMicrotasks();
|
|
5083
|
+
const journal = readJournal("indigo").files;
|
|
5084
|
+
expect(Object.keys(journal)).toHaveLength(paths.length * 2);
|
|
5085
|
+
for (let index = 0; index < paths.length; index++) {
|
|
5086
|
+
expect(journal[`bulk-${index}.md`]?.hash).toBe(`slow-${index}`);
|
|
5087
|
+
expect(journal[`realtime-${index}.md`]?.hash).toBe(`fast-${index}`);
|
|
5088
|
+
}
|
|
5089
|
+
triggerShutdown();
|
|
5090
|
+
await loop;
|
|
5091
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
5092
|
+
});
|
|
5093
|
+
it("fast lane: a bulk pass still completes after repeated small watcher batches", async () => {
|
|
5094
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-fairness-"));
|
|
5095
|
+
const watcher = makeBatchWatcherStub();
|
|
5096
|
+
let triggerShutdown = () => { };
|
|
5097
|
+
let releaseBulk = () => { };
|
|
5098
|
+
const bulkGate = new Promise((resolve) => { releaseBulk = resolve; });
|
|
5099
|
+
const completed = [];
|
|
5100
|
+
let fullPasses = 0;
|
|
5101
|
+
const runPass = vi.fn().mockImplementation(async (passArgv) => {
|
|
5102
|
+
if (passArgv.includes("--scope-path")) {
|
|
5103
|
+
completed.push("fast");
|
|
5104
|
+
return completedPassOutcome();
|
|
5105
|
+
}
|
|
5106
|
+
fullPasses += 1;
|
|
5107
|
+
if (fullPasses === 2) {
|
|
5108
|
+
await bulkGate;
|
|
5109
|
+
completed.push("bulk");
|
|
5110
|
+
}
|
|
5111
|
+
return completedPassOutcome();
|
|
5112
|
+
});
|
|
5113
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "push", "--hq-root", hqRoot], {
|
|
5114
|
+
runPass,
|
|
5115
|
+
clock: new FakeClock(),
|
|
5116
|
+
createWatcher: () => watcher,
|
|
5117
|
+
sleep: () => new Promise(() => { }),
|
|
5118
|
+
onShutdownSignal: (handler) => {
|
|
5119
|
+
triggerShutdown = handler;
|
|
5120
|
+
return () => { };
|
|
5121
|
+
},
|
|
5122
|
+
});
|
|
5123
|
+
await flushLoopMicrotasks();
|
|
5124
|
+
watcher.emit();
|
|
5125
|
+
await flushLoopMicrotasks();
|
|
5126
|
+
for (let index = 0; index < 5; index++) {
|
|
5127
|
+
const relativePath = `companies/indigo/realtime-${index}.md`;
|
|
5128
|
+
const absolutePath = path.join(hqRoot, relativePath);
|
|
5129
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
5130
|
+
fs.writeFileSync(absolutePath, "tiny");
|
|
5131
|
+
watcher.emit(relativePath, { paths: new Map([[absolutePath, relativePath]]) });
|
|
5132
|
+
}
|
|
5133
|
+
await flushLoopMicrotasks(60);
|
|
5134
|
+
expect(completed).toEqual(["fast", "fast", "fast", "fast", "fast"]);
|
|
5135
|
+
releaseBulk();
|
|
5136
|
+
await flushLoopMicrotasks();
|
|
5137
|
+
expect(completed).toContain("bulk");
|
|
5138
|
+
triggerShutdown();
|
|
5139
|
+
await loop;
|
|
5140
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
5141
|
+
});
|
|
5142
|
+
it("fast lane: sends only batches at or below the file and byte limits", () => {
|
|
5143
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-fast-lane-limit-"));
|
|
5144
|
+
const small = path.join(hqRoot, "small.md");
|
|
5145
|
+
const boundary = path.join(hqRoot, "boundary.bin");
|
|
5146
|
+
fs.writeFileSync(small, "small");
|
|
5147
|
+
fs.writeFileSync(boundary, Buffer.alloc(DEFAULT_FAST_LANE_MAX_BYTES));
|
|
5148
|
+
expect(isFastLocalBatch([small])).toBe(true);
|
|
5149
|
+
expect(isFastLocalBatch([boundary])).toBe(true);
|
|
5150
|
+
fs.appendFileSync(boundary, "x");
|
|
5151
|
+
expect(isFastLocalBatch([boundary])).toBe(false);
|
|
5152
|
+
expect(isFastReceiverBatch(DEFAULT_FAST_LANE_MAX_FILES)).toBe(true);
|
|
5153
|
+
expect(isFastReceiverBatch(DEFAULT_FAST_LANE_MAX_FILES + 1)).toBe(false);
|
|
5154
|
+
const previousFiles = process.env[FAST_LANE_MAX_FILES_ENV];
|
|
5155
|
+
const previousBytes = process.env[FAST_LANE_MAX_BYTES_ENV];
|
|
5156
|
+
process.env[FAST_LANE_MAX_FILES_ENV] = "1";
|
|
5157
|
+
process.env[FAST_LANE_MAX_BYTES_ENV] = "4";
|
|
5158
|
+
try {
|
|
5159
|
+
expect(isFastLocalBatch([small])).toBe(false);
|
|
5160
|
+
expect(isFastReceiverBatch(2)).toBe(false);
|
|
5161
|
+
}
|
|
5162
|
+
finally {
|
|
5163
|
+
if (previousFiles === undefined)
|
|
5164
|
+
delete process.env[FAST_LANE_MAX_FILES_ENV];
|
|
5165
|
+
else
|
|
5166
|
+
process.env[FAST_LANE_MAX_FILES_ENV] = previousFiles;
|
|
5167
|
+
if (previousBytes === undefined)
|
|
5168
|
+
delete process.env[FAST_LANE_MAX_BYTES_ENV];
|
|
5169
|
+
else
|
|
5170
|
+
process.env[FAST_LANE_MAX_BYTES_ENV] = previousBytes;
|
|
5171
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
5172
|
+
}
|
|
5173
|
+
});
|
|
4595
5174
|
it("U07: drains the first watcher event after activation without guarded overlap", async () => {
|
|
4596
5175
|
const watcher = makeWatcherStub();
|
|
4597
5176
|
let triggerShutdown = () => { };
|
|
@@ -4636,8 +5215,9 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
4636
5215
|
// before the next wait is registered. Its first event must still drain.
|
|
4637
5216
|
watcher.emit("companies/indigo/pre-registration.md");
|
|
4638
5217
|
await flushLoopMicrotasks();
|
|
4639
|
-
expect(spans).toHaveLength(
|
|
5218
|
+
expect(spans).toHaveLength(3);
|
|
4640
5219
|
expect(spans[1].enteredAt >= spans[0].exitedAt).toBe(true);
|
|
5220
|
+
expect(spans[2].enteredAt >= spans[1].exitedAt).toBe(true);
|
|
4641
5221
|
triggerShutdown();
|
|
4642
5222
|
await loop;
|
|
4643
5223
|
});
|
|
@@ -5146,6 +5726,8 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
5146
5726
|
"bare-wake": matched.includes("bare-wake"),
|
|
5147
5727
|
"batch-over-limit": matched.includes("batch-over-limit"),
|
|
5148
5728
|
"overflow-routes-unknown": matched.includes("overflow-routes-unknown"),
|
|
5729
|
+
"watcher-warmup-catch-up": matched.includes("watcher-warmup-catch-up"),
|
|
5730
|
+
"watcher-warmup-catch-up-failed": matched.includes("watcher-warmup-catch-up-failed"),
|
|
5149
5731
|
"scheduled-interval": matched.includes("scheduled-interval"),
|
|
5150
5732
|
});
|
|
5151
5733
|
function expectFullReconcileDecision(record, mode, reasons) {
|
|
@@ -5518,12 +6100,11 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
5518
6100
|
tick();
|
|
5519
6101
|
await loop;
|
|
5520
6102
|
});
|
|
5521
|
-
it("
|
|
6103
|
+
it("a pending local batch is pushed on a pull-driven watcher tick", async () => {
|
|
5522
6104
|
const runPass = vi.fn().mockResolvedValue(completedPassOutcome());
|
|
5523
|
-
// No --direction flag → parseArgs defaults the run to pull-only.
|
|
5524
|
-
//
|
|
5525
|
-
//
|
|
5526
|
-
// pull-only watcher would push local edits it should never send.
|
|
6105
|
+
// No --direction flag → parseArgs defaults the cadence run to pull-only.
|
|
6106
|
+
// A watcher batch remains an explicit local-write delivery request: its
|
|
6107
|
+
// bounded push must run before the pull leg can consume the batch.
|
|
5527
6108
|
const pullOnlyArgv = [
|
|
5528
6109
|
"--companies",
|
|
5529
6110
|
"--watch",
|
|
@@ -5540,11 +6121,19 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
5540
6121
|
tick();
|
|
5541
6122
|
await flushLoopMicrotasks();
|
|
5542
6123
|
const calls = runPass.mock.calls.map((call) => call[0]);
|
|
5543
|
-
//
|
|
5544
|
-
|
|
5545
|
-
argv[argv.indexOf("--direction") + 1] === "push")).toBe(false);
|
|
5546
|
-
// ...only the pull leg ran — route-targeted and path-scoped now.
|
|
6124
|
+
// This expectation fails on origin/main: the old pull-only branch runs
|
|
6125
|
+
// only the second argv, so the local write waits for a later reconcile.
|
|
5547
6126
|
expect(calls).toEqual([
|
|
6127
|
+
[
|
|
6128
|
+
"--company",
|
|
6129
|
+
"indigo",
|
|
6130
|
+
"--direction",
|
|
6131
|
+
"push",
|
|
6132
|
+
"--scope-path",
|
|
6133
|
+
"knowledge/a.md",
|
|
6134
|
+
"--hq-root",
|
|
6135
|
+
"/tmp/hq",
|
|
6136
|
+
],
|
|
5548
6137
|
[
|
|
5549
6138
|
"--company",
|
|
5550
6139
|
"indigo",
|
|
@@ -5560,6 +6149,98 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
5560
6149
|
tick();
|
|
5561
6150
|
await loop;
|
|
5562
6151
|
});
|
|
6152
|
+
it("pushes a local event in the pass immediately after a guarded receiver drain", async () => {
|
|
6153
|
+
const watcher = makeBatchWatcherStub();
|
|
6154
|
+
const sleep = makeSteppableSleep();
|
|
6155
|
+
let shutdown = () => { };
|
|
6156
|
+
let receiverSync = null;
|
|
6157
|
+
let releaseReceiverPull = () => { };
|
|
6158
|
+
const receiverPull = new Promise((resolve) => {
|
|
6159
|
+
releaseReceiverPull = resolve;
|
|
6160
|
+
});
|
|
6161
|
+
const runPass = vi.fn(async (argv) => {
|
|
6162
|
+
if (argv.includes("--company") &&
|
|
6163
|
+
argv.includes("acme") &&
|
|
6164
|
+
argv[argv.indexOf("--direction") + 1] === "pull") {
|
|
6165
|
+
await receiverPull;
|
|
6166
|
+
}
|
|
6167
|
+
return completedPassOutcome();
|
|
6168
|
+
});
|
|
6169
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"], {
|
|
6170
|
+
runPass,
|
|
6171
|
+
createWatcher: () => watcher,
|
|
6172
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
6173
|
+
receiverSync = syncBatchFn;
|
|
6174
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
6175
|
+
},
|
|
6176
|
+
sleep: sleep.sleep,
|
|
6177
|
+
onShutdownSignal: (handler) => {
|
|
6178
|
+
shutdown = handler;
|
|
6179
|
+
return () => { };
|
|
6180
|
+
},
|
|
6181
|
+
});
|
|
6182
|
+
await flushLoopMicrotasks(12);
|
|
6183
|
+
runPass.mockClear();
|
|
6184
|
+
const receiverDrain = receiverSync({
|
|
6185
|
+
events: [{
|
|
6186
|
+
kind: "upsert",
|
|
6187
|
+
relativePath: "companies/acme/remote.md",
|
|
6188
|
+
contentHash: "sha256:remote",
|
|
6189
|
+
mtime: "2026-08-26T08:00:00.000Z",
|
|
6190
|
+
originDeviceId: "peer-device",
|
|
6191
|
+
originTenantId: "tenant-acme",
|
|
6192
|
+
sequenceNumber: 1,
|
|
6193
|
+
eventTimestamp: "2026-08-26T08:00:00.000Z",
|
|
6194
|
+
}],
|
|
6195
|
+
signal: new AbortController().signal,
|
|
6196
|
+
});
|
|
6197
|
+
await flushLoopMicrotasks();
|
|
6198
|
+
watcher.emit("companies/indigo/local.md", {
|
|
6199
|
+
paths: new Map([
|
|
6200
|
+
["/tmp/hq/companies/indigo/local.md", "companies/indigo/local.md"],
|
|
6201
|
+
]),
|
|
6202
|
+
});
|
|
6203
|
+
await flushLoopMicrotasks();
|
|
6204
|
+
releaseReceiverPull();
|
|
6205
|
+
await receiverDrain;
|
|
6206
|
+
await flushLoopMicrotasks(16);
|
|
6207
|
+
// On origin/main the next call is an indigo pull, so this asserts the
|
|
6208
|
+
// bounded local upload is not consumed by the receiver/drain cycle.
|
|
6209
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
6210
|
+
[
|
|
6211
|
+
"--company",
|
|
6212
|
+
"acme",
|
|
6213
|
+
"--direction",
|
|
6214
|
+
"pull",
|
|
6215
|
+
"--scope-path",
|
|
6216
|
+
"remote.md",
|
|
6217
|
+
"--hq-root",
|
|
6218
|
+
"/tmp/hq",
|
|
6219
|
+
],
|
|
6220
|
+
[
|
|
6221
|
+
"--company",
|
|
6222
|
+
"indigo",
|
|
6223
|
+
"--direction",
|
|
6224
|
+
"push",
|
|
6225
|
+
"--scope-path",
|
|
6226
|
+
"local.md",
|
|
6227
|
+
"--hq-root",
|
|
6228
|
+
"/tmp/hq",
|
|
6229
|
+
],
|
|
6230
|
+
[
|
|
6231
|
+
"--company",
|
|
6232
|
+
"indigo",
|
|
6233
|
+
"--direction",
|
|
6234
|
+
"pull",
|
|
6235
|
+
"--scope-path",
|
|
6236
|
+
"local.md",
|
|
6237
|
+
"--hq-root",
|
|
6238
|
+
"/tmp/hq",
|
|
6239
|
+
],
|
|
6240
|
+
]);
|
|
6241
|
+
shutdown();
|
|
6242
|
+
await loop;
|
|
6243
|
+
});
|
|
5563
6244
|
it("restores failed scoped push paths so the next tick retries them", async () => {
|
|
5564
6245
|
const runPass = vi
|
|
5565
6246
|
.fn()
|
|
@@ -6975,15 +7656,14 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
6975
7656
|
const sleep = makeSteppableSleep();
|
|
6976
7657
|
let triggerShutdown = () => { };
|
|
6977
7658
|
const loop = runRunnerWithLoop(
|
|
6978
|
-
//
|
|
6979
|
-
//
|
|
6980
|
-
// pull-only run by the parser default, which correctly never pushes.
|
|
7659
|
+
// Most wiring tests use `both`; pull-only watcher tests pin that a local
|
|
7660
|
+
// event still takes its scoped upload and publishes after success.
|
|
6981
7661
|
[
|
|
6982
7662
|
"--companies",
|
|
6983
7663
|
"--watch",
|
|
6984
7664
|
"--event-push",
|
|
6985
7665
|
"--direction",
|
|
6986
|
-
"both",
|
|
7666
|
+
opts.direction ?? "both",
|
|
6987
7667
|
"--hq-root",
|
|
6988
7668
|
"/tmp/hq",
|
|
6989
7669
|
], {
|
|
@@ -7136,6 +7816,33 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
7136
7816
|
shutdown();
|
|
7137
7817
|
await loop;
|
|
7138
7818
|
});
|
|
7819
|
+
it("publishes exactly one event per path after a pull-only watcher upload", async () => {
|
|
7820
|
+
const publishBatch = vi.fn();
|
|
7821
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
7822
|
+
publishBatch,
|
|
7823
|
+
receiver: { start: async () => { }, dispose: async () => { }, connected: true },
|
|
7824
|
+
ownDeviceId: "dev-test",
|
|
7825
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
7826
|
+
});
|
|
7827
|
+
const { loop, watcher, clock, shutdown } = runLoop({
|
|
7828
|
+
claims: EMAIL_CLAIMS,
|
|
7829
|
+
direction: "pull",
|
|
7830
|
+
startEventSync,
|
|
7831
|
+
});
|
|
7832
|
+
await microtasks();
|
|
7833
|
+
const relativePath = "companies/indigo/local.md";
|
|
7834
|
+
watcher.emit(relativePath, {
|
|
7835
|
+
paths: new Map([[`/tmp/hq/${relativePath}`, relativePath]]),
|
|
7836
|
+
});
|
|
7837
|
+
clock.advance(0);
|
|
7838
|
+
await microtasks();
|
|
7839
|
+
// The old pull-only branch never invoked the scoped push, so it published
|
|
7840
|
+
// zero events here. One accepted local upload must announce its path once.
|
|
7841
|
+
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
7842
|
+
expect([...publishBatch.mock.calls[0][0].paths.values()]).toEqual([relativePath]);
|
|
7843
|
+
shutdown();
|
|
7844
|
+
await loop;
|
|
7845
|
+
});
|
|
7139
7846
|
it("publishes a personal-vault edit from a --companies event-push runner", async () => {
|
|
7140
7847
|
const publishBatch = vi.fn();
|
|
7141
7848
|
const startEventSync = vi.fn().mockResolvedValue({
|