@indigoai-us/hq-cloud 6.13.4 → 6.14.0
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 +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +41 -2
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +9 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +85 -51
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +3 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +52 -1
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +19 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +666 -45
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/lib/net-errors.d.ts +36 -0
- package/dist/lib/net-errors.d.ts.map +1 -0
- package/dist/lib/net-errors.js +81 -0
- package/dist/lib/net-errors.js.map +1 -0
- package/dist/lib/net-errors.test.d.ts +2 -0
- package/dist/lib/net-errors.test.d.ts.map +1 -0
- package/dist/lib/net-errors.test.js +47 -0
- package/dist/lib/net-errors.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +61 -2
- package/src/bin/sync-runner-watch-loop.ts +116 -55
- package/src/bin/sync-runner-watch-routes.ts +57 -1
- package/src/bin/sync-runner.test.ts +774 -50
- package/src/bin/sync-runner.ts +23 -4
- package/src/lib/net-errors.test.ts +53 -0
- package/src/lib/net-errors.ts +83 -0
|
@@ -16,6 +16,8 @@ import { FakeClock } from "../watcher.js";
|
|
|
16
16
|
import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
|
|
17
17
|
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
18
18
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
19
|
+
import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
|
|
20
|
+
import { isCoveredByAny } from "../prefix-coalesce.js";
|
|
19
21
|
import { VaultAuthError } from "../vault-client.js";
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Hermetic journal state — the runner now calls migratePersonalVaultJournal()
|
|
@@ -307,6 +309,47 @@ describe("memberships retry", () => {
|
|
|
307
309
|
path: "(discovery)",
|
|
308
310
|
});
|
|
309
311
|
});
|
|
312
|
+
it("a transient network failure (fetch failed) during discovery returns TRANSIENT_NETWORK_EXIT, not 1 (HQ-SYNC-1W)", async () => {
|
|
313
|
+
// Regression for the "auto-sync watcher exited unexpectedly (code=Some(1))"
|
|
314
|
+
// cluster: a machine that stays offline through all 3 membership retries
|
|
315
|
+
// used to `return 1`, which the watch loop propagated and the menubar
|
|
316
|
+
// reported as a crash for every network blip. undici surfaces the blip as
|
|
317
|
+
// `TypeError: fetch failed`; that must map to the retryable exit code so the
|
|
318
|
+
// watcher stays alive instead of crash-reporting. A one-shot run still sees
|
|
319
|
+
// a non-zero exit.
|
|
320
|
+
let listCalls = 0;
|
|
321
|
+
const stub = makeVaultStub();
|
|
322
|
+
stub.listMyMemberships = () => {
|
|
323
|
+
listCalls++;
|
|
324
|
+
return Promise.reject(new TypeError("fetch failed"));
|
|
325
|
+
};
|
|
326
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
327
|
+
const code = await runRunner(["--companies"], deps);
|
|
328
|
+
expect(code).toBe(TRANSIENT_NETWORK_EXIT);
|
|
329
|
+
expect(listCalls).toBe(3);
|
|
330
|
+
// Still surfaced for observability — just no longer a "code 1" crash.
|
|
331
|
+
const events = deps.stderr.events();
|
|
332
|
+
expect(events).toHaveLength(1);
|
|
333
|
+
expect(events[0]).toMatchObject({
|
|
334
|
+
type: "error",
|
|
335
|
+
message: "fetch failed",
|
|
336
|
+
path: "(discovery)",
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
it("a NON-transient discovery failure still returns 1 (unchanged)", async () => {
|
|
340
|
+
// Guard the conservative classifier: a generic error (not a transport
|
|
341
|
+
// shape) is still a hard exit 1, exactly as before.
|
|
342
|
+
let listCalls = 0;
|
|
343
|
+
const stub = makeVaultStub();
|
|
344
|
+
stub.listMyMemberships = () => {
|
|
345
|
+
listCalls++;
|
|
346
|
+
return Promise.reject(new Error("unexpected 500 from vault api"));
|
|
347
|
+
};
|
|
348
|
+
const deps = makeDeps({ createVaultClient: () => stub });
|
|
349
|
+
const code = await runRunner(["--companies"], deps);
|
|
350
|
+
expect(code).toBe(1);
|
|
351
|
+
expect(listCalls).toBe(3);
|
|
352
|
+
});
|
|
310
353
|
it("VaultAuthError short-circuits retry (no retries on auth failure)", async () => {
|
|
311
354
|
let listCalls = 0;
|
|
312
355
|
const stub = makeVaultStub();
|
|
@@ -1598,6 +1641,75 @@ describe("--direction", () => {
|
|
|
1598
1641
|
// preserving the pre-fix company-target args shape (full access).
|
|
1599
1642
|
expect(opts.prefixSet).toBeUndefined();
|
|
1600
1643
|
});
|
|
1644
|
+
it("direction=push with --scope-path: forwards exact and descendant scoped prefixes to share()", async () => {
|
|
1645
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1646
|
+
const deps = makeDeps({
|
|
1647
|
+
createVaultClient: () => makeVaultStub({
|
|
1648
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1649
|
+
entityGet: (uid) => Promise.resolve({ uid, slug: "acme" }),
|
|
1650
|
+
}),
|
|
1651
|
+
sync: vi.fn(),
|
|
1652
|
+
share: shareSpy,
|
|
1653
|
+
});
|
|
1654
|
+
await runRunner([
|
|
1655
|
+
"--companies",
|
|
1656
|
+
"--direction",
|
|
1657
|
+
"push",
|
|
1658
|
+
"--hq-root",
|
|
1659
|
+
"/tmp/fake-hq",
|
|
1660
|
+
"--scope-path",
|
|
1661
|
+
"knowledge/a.md",
|
|
1662
|
+
], deps);
|
|
1663
|
+
const opts = shareSpy.mock.calls[0][0];
|
|
1664
|
+
expect(opts.prefixSet).toEqual(["knowledge/a.md", "knowledge/a.md/"]);
|
|
1665
|
+
});
|
|
1666
|
+
it("direction=push with directory --scope-path covers descendant keys for tombstones", async () => {
|
|
1667
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1668
|
+
const deps = makeDeps({
|
|
1669
|
+
createVaultClient: () => makeVaultStub({
|
|
1670
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1671
|
+
entityGet: (uid) => Promise.resolve({ uid, slug: "acme" }),
|
|
1672
|
+
}),
|
|
1673
|
+
sync: vi.fn(),
|
|
1674
|
+
share: shareSpy,
|
|
1675
|
+
});
|
|
1676
|
+
await runRunner([
|
|
1677
|
+
"--companies",
|
|
1678
|
+
"--direction",
|
|
1679
|
+
"push",
|
|
1680
|
+
"--hq-root",
|
|
1681
|
+
"/tmp/fake-hq",
|
|
1682
|
+
"--scope-path",
|
|
1683
|
+
"knowledge/archive",
|
|
1684
|
+
], deps);
|
|
1685
|
+
const opts = shareSpy.mock.calls[0][0];
|
|
1686
|
+
expect(opts.prefixSet).toBeDefined();
|
|
1687
|
+
expect(isCoveredByAny("knowledge/archive/a.md", opts.prefixSet ?? [])).toBe(true);
|
|
1688
|
+
});
|
|
1689
|
+
it("direction=push with file --scope-path does not cover siblings", async () => {
|
|
1690
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1691
|
+
const deps = makeDeps({
|
|
1692
|
+
createVaultClient: () => makeVaultStub({
|
|
1693
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1694
|
+
entityGet: (uid) => Promise.resolve({ uid, slug: "acme" }),
|
|
1695
|
+
}),
|
|
1696
|
+
sync: vi.fn(),
|
|
1697
|
+
share: shareSpy,
|
|
1698
|
+
});
|
|
1699
|
+
await runRunner([
|
|
1700
|
+
"--companies",
|
|
1701
|
+
"--direction",
|
|
1702
|
+
"push",
|
|
1703
|
+
"--hq-root",
|
|
1704
|
+
"/tmp/fake-hq",
|
|
1705
|
+
"--scope-path",
|
|
1706
|
+
"board.json",
|
|
1707
|
+
], deps);
|
|
1708
|
+
const opts = shareSpy.mock.calls[0][0];
|
|
1709
|
+
expect(opts.prefixSet).toBeDefined();
|
|
1710
|
+
expect(isCoveredByAny("board.json", opts.prefixSet ?? [])).toBe(true);
|
|
1711
|
+
expect(isCoveredByAny("other.json", opts.prefixSet ?? [])).toBe(false);
|
|
1712
|
+
});
|
|
1601
1713
|
it("direction=push (shared membership): forwards the resolved ACL prefixSet to share() (feedback_ded09d56)", async () => {
|
|
1602
1714
|
// Plumbing regression for the fresh fix: a member/guest's push must be
|
|
1603
1715
|
// scoped to their granted prefixes so out-of-scope keys are filtered
|
|
@@ -1627,6 +1739,76 @@ describe("--direction", () => {
|
|
|
1627
1739
|
const opts = shareSpy.mock.calls[0][0];
|
|
1628
1740
|
expect(opts.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
1629
1741
|
});
|
|
1742
|
+
it("direction=push with --scope-path never widens a shared ACL prefixSet", async () => {
|
|
1743
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1744
|
+
const client = {
|
|
1745
|
+
...makeVaultStub({
|
|
1746
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1747
|
+
entityGet: (uid) => Promise.resolve({ uid, slug: "acme" }),
|
|
1748
|
+
}),
|
|
1749
|
+
getMembershipSyncConfig: async () => ({
|
|
1750
|
+
membershipId: "mk_a",
|
|
1751
|
+
syncMode: "shared",
|
|
1752
|
+
isDefault: false,
|
|
1753
|
+
}),
|
|
1754
|
+
listMyExplicitGrants: async () => [
|
|
1755
|
+
{ companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
|
|
1756
|
+
],
|
|
1757
|
+
};
|
|
1758
|
+
const deps = makeDeps({
|
|
1759
|
+
createVaultClient: () => client,
|
|
1760
|
+
sync: vi.fn(),
|
|
1761
|
+
share: shareSpy,
|
|
1762
|
+
});
|
|
1763
|
+
await runRunner([
|
|
1764
|
+
"--companies",
|
|
1765
|
+
"--direction",
|
|
1766
|
+
"push",
|
|
1767
|
+
"--hq-root",
|
|
1768
|
+
"/tmp/fake-hq",
|
|
1769
|
+
"--scope-path",
|
|
1770
|
+
"knowledge/a.md",
|
|
1771
|
+
"--scope-path",
|
|
1772
|
+
"secrets/b.md",
|
|
1773
|
+
], deps);
|
|
1774
|
+
const opts = shareSpy.mock.calls[0][0];
|
|
1775
|
+
expect(opts.prefixSet).toEqual(["knowledge/a.md", "knowledge/a.md/"]);
|
|
1776
|
+
expect(isCoveredByAny("secrets/b.md", opts.prefixSet ?? [])).toBe(false);
|
|
1777
|
+
});
|
|
1778
|
+
it("direction=push with directory --scope-path never widens outside a shared ACL prefixSet", async () => {
|
|
1779
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1780
|
+
const client = {
|
|
1781
|
+
...makeVaultStub({
|
|
1782
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1783
|
+
entityGet: (uid) => Promise.resolve({ uid, slug: "acme" }),
|
|
1784
|
+
}),
|
|
1785
|
+
getMembershipSyncConfig: async () => ({
|
|
1786
|
+
membershipId: "mk_a",
|
|
1787
|
+
syncMode: "shared",
|
|
1788
|
+
isDefault: false,
|
|
1789
|
+
}),
|
|
1790
|
+
listMyExplicitGrants: async () => [
|
|
1791
|
+
{ companyUid: "cmp_a", path: "policies/", permission: "read", source: "person" },
|
|
1792
|
+
],
|
|
1793
|
+
};
|
|
1794
|
+
const deps = makeDeps({
|
|
1795
|
+
createVaultClient: () => client,
|
|
1796
|
+
sync: vi.fn(),
|
|
1797
|
+
share: shareSpy,
|
|
1798
|
+
});
|
|
1799
|
+
await runRunner([
|
|
1800
|
+
"--companies",
|
|
1801
|
+
"--direction",
|
|
1802
|
+
"push",
|
|
1803
|
+
"--hq-root",
|
|
1804
|
+
"/tmp/fake-hq",
|
|
1805
|
+
"--scope-path",
|
|
1806
|
+
"knowledge/archive",
|
|
1807
|
+
], deps);
|
|
1808
|
+
const opts = shareSpy.mock.calls[0][0];
|
|
1809
|
+
expect(isCoveredByAny("knowledge/archive/a.md", opts.prefixSet ?? [])).toBe(false);
|
|
1810
|
+
expect(isCoveredByAny("policies/a.md", opts.prefixSet ?? [])).toBe(false);
|
|
1811
|
+
});
|
|
1630
1812
|
it("direction=both: all-complete sums uploaded and downloaded across companies", async () => {
|
|
1631
1813
|
const slugs = { cmp_a: "acme", cmp_b: "beta" };
|
|
1632
1814
|
const deps = makeDeps({
|
|
@@ -2371,6 +2553,24 @@ function makeWatcherStub() {
|
|
|
2371
2553
|
};
|
|
2372
2554
|
return stub;
|
|
2373
2555
|
}
|
|
2556
|
+
async function flushLoopMicrotasks(n = 8) {
|
|
2557
|
+
for (let i = 0; i < n; i++)
|
|
2558
|
+
await Promise.resolve();
|
|
2559
|
+
}
|
|
2560
|
+
function makeSteppableSleep() {
|
|
2561
|
+
const pending = [];
|
|
2562
|
+
return {
|
|
2563
|
+
sleep: () => new Promise((resolve) => {
|
|
2564
|
+
pending.push(resolve);
|
|
2565
|
+
}),
|
|
2566
|
+
tick() {
|
|
2567
|
+
const resolve = pending.shift();
|
|
2568
|
+
if (!resolve)
|
|
2569
|
+
throw new Error("poll sleep was not pending");
|
|
2570
|
+
resolve();
|
|
2571
|
+
},
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2374
2574
|
describe("runRunnerWithLoop — event-push wiring", () => {
|
|
2375
2575
|
it("starts the watcher alongside the poll loop when --event-push is on", async () => {
|
|
2376
2576
|
const watcher = makeWatcherStub();
|
|
@@ -2396,7 +2596,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2396
2596
|
expect(runPass).toHaveBeenCalled();
|
|
2397
2597
|
expect(watcher.disposed).toBe(true);
|
|
2398
2598
|
});
|
|
2399
|
-
it("trigger-on-change: a
|
|
2599
|
+
it("trigger-on-change: a changed signal accumulates without an immediate push", async () => {
|
|
2400
2600
|
const watcher = makeWatcherStub();
|
|
2401
2601
|
const clock = new FakeClock();
|
|
2402
2602
|
let triggerShutdown = () => { };
|
|
@@ -2420,20 +2620,11 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2420
2620
|
clock.advance(0);
|
|
2421
2621
|
await Promise.resolve();
|
|
2422
2622
|
await Promise.resolve();
|
|
2423
|
-
|
|
2424
|
-
expect(targetedCall).toBeDefined();
|
|
2425
|
-
expect(targetedCall[0]).toEqual([
|
|
2426
|
-
"--company",
|
|
2427
|
-
"indigo",
|
|
2428
|
-
"--direction",
|
|
2429
|
-
"push",
|
|
2430
|
-
"--hq-root",
|
|
2431
|
-
"/tmp/hq",
|
|
2432
|
-
]);
|
|
2623
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
2433
2624
|
triggerShutdown();
|
|
2434
2625
|
await loop;
|
|
2435
2626
|
});
|
|
2436
|
-
it("bare signal (no path)
|
|
2627
|
+
it("bare signal (no path) accumulates without an immediate push", async () => {
|
|
2437
2628
|
const watcher = makeWatcherStub();
|
|
2438
2629
|
const clock = new FakeClock();
|
|
2439
2630
|
let triggerShutdown = () => { };
|
|
@@ -2455,16 +2646,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2455
2646
|
clock.advance(0);
|
|
2456
2647
|
await Promise.resolve();
|
|
2457
2648
|
await Promise.resolve();
|
|
2458
|
-
|
|
2459
|
-
c[0].includes("--direction"));
|
|
2460
|
-
expect(personalCall).toBeDefined();
|
|
2461
|
-
expect(personalCall[0]).toEqual([
|
|
2462
|
-
"--companies",
|
|
2463
|
-
"--direction",
|
|
2464
|
-
"push",
|
|
2465
|
-
"--hq-root",
|
|
2466
|
-
"/tmp/hq",
|
|
2467
|
-
]);
|
|
2649
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
2468
2650
|
triggerShutdown();
|
|
2469
2651
|
await loop;
|
|
2470
2652
|
});
|
|
@@ -2522,7 +2704,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2522
2704
|
triggerShutdown();
|
|
2523
2705
|
await loop;
|
|
2524
2706
|
});
|
|
2525
|
-
it("F05: guard-held watcher
|
|
2707
|
+
it("F05: guard-held watcher changes stay accumulated until a poll drain", async () => {
|
|
2526
2708
|
const watcher = makeWatcherStub();
|
|
2527
2709
|
const clock = new FakeClock();
|
|
2528
2710
|
let triggerShutdown = () => { };
|
|
@@ -2570,14 +2752,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2570
2752
|
await loop;
|
|
2571
2753
|
expect(maxConcurrent).toBe(1);
|
|
2572
2754
|
const targetedCall = runPass.mock.calls.find((c) => c[0].includes("--company"));
|
|
2573
|
-
expect(targetedCall
|
|
2574
|
-
"--company",
|
|
2575
|
-
"indigo",
|
|
2576
|
-
"--direction",
|
|
2577
|
-
"push",
|
|
2578
|
-
"--hq-root",
|
|
2579
|
-
"/tmp/hq",
|
|
2580
|
-
]);
|
|
2755
|
+
expect(targetedCall).toBeUndefined();
|
|
2581
2756
|
});
|
|
2582
2757
|
it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
|
|
2583
2758
|
const watcher = makeWatcherStub();
|
|
@@ -2660,6 +2835,33 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2660
2835
|
expect(code).toBe(2);
|
|
2661
2836
|
expect(watcher.disposed).toBe(true);
|
|
2662
2837
|
});
|
|
2838
|
+
it("a TRANSIENT_NETWORK_EXIT poll pass does NOT surface as a crash — the loop retries (HQ-SYNC-1W)", async () => {
|
|
2839
|
+
// A transient offline blip must keep the watcher alive: the loop logs and
|
|
2840
|
+
// polls again instead of returning the code and letting the process exit
|
|
2841
|
+
// (which the menubar reports as "watcher exited unexpectedly"). A genuine
|
|
2842
|
+
// hard error on a later pass still terminates the loop.
|
|
2843
|
+
const watcher = makeWatcherStub();
|
|
2844
|
+
let n = 0;
|
|
2845
|
+
const runPass = vi.fn().mockImplementation(async () => {
|
|
2846
|
+
n++;
|
|
2847
|
+
// First pass: machine is offline (transient). Second pass: a real hard
|
|
2848
|
+
// error, used here only to end the otherwise-infinite loop deterministically.
|
|
2849
|
+
return n === 1 ? TRANSIENT_NETWORK_EXIT : 2;
|
|
2850
|
+
});
|
|
2851
|
+
const code = await runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"], {
|
|
2852
|
+
runPass,
|
|
2853
|
+
clock: new FakeClock(),
|
|
2854
|
+
createWatcher: () => watcher,
|
|
2855
|
+
sleep: () => Promise.resolve(),
|
|
2856
|
+
onShutdownSignal: () => () => { },
|
|
2857
|
+
});
|
|
2858
|
+
// The transient pass did NOT end the loop — it ran again…
|
|
2859
|
+
expect(runPass).toHaveBeenCalledTimes(2);
|
|
2860
|
+
// …and the transient code was never surfaced as the watcher's exit code.
|
|
2861
|
+
expect(code).toBe(2);
|
|
2862
|
+
expect(code).not.toBe(TRANSIENT_NETWORK_EXIT);
|
|
2863
|
+
expect(watcher.disposed).toBe(true);
|
|
2864
|
+
});
|
|
2663
2865
|
it("without --event-push, no watcher is created (poll-only safety net)", async () => {
|
|
2664
2866
|
let triggerShutdown = () => { };
|
|
2665
2867
|
const createWatcher = vi.fn(() => makeWatcherStub());
|
|
@@ -2681,6 +2883,360 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
2681
2883
|
await loop;
|
|
2682
2884
|
});
|
|
2683
2885
|
});
|
|
2886
|
+
describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
2887
|
+
const watchArgv = [
|
|
2888
|
+
"--companies",
|
|
2889
|
+
"--watch",
|
|
2890
|
+
"--event-push",
|
|
2891
|
+
"--direction",
|
|
2892
|
+
"both",
|
|
2893
|
+
"--hq-root",
|
|
2894
|
+
"/tmp/hq",
|
|
2895
|
+
"--poll-remote-ms",
|
|
2896
|
+
"60000",
|
|
2897
|
+
];
|
|
2898
|
+
const fullArgv = ["--companies", "--direction", "both", "--hq-root", "/tmp/hq"];
|
|
2899
|
+
const fullPullArgv = [
|
|
2900
|
+
"--companies",
|
|
2901
|
+
"--direction",
|
|
2902
|
+
"pull",
|
|
2903
|
+
"--hq-root",
|
|
2904
|
+
"/tmp/hq",
|
|
2905
|
+
];
|
|
2906
|
+
function startDrainLoop(runPass) {
|
|
2907
|
+
return startDrainLoopWithArgv(runPass, watchArgv);
|
|
2908
|
+
}
|
|
2909
|
+
function startDrainLoopWithArgv(runPass, argv) {
|
|
2910
|
+
const watcher = makeWatcherStub();
|
|
2911
|
+
const sleep = makeSteppableSleep();
|
|
2912
|
+
let triggerShutdown = () => { };
|
|
2913
|
+
const loop = runRunnerWithLoop(argv, {
|
|
2914
|
+
runPass,
|
|
2915
|
+
clock: new FakeClock(),
|
|
2916
|
+
createWatcher: () => watcher,
|
|
2917
|
+
sleep: sleep.sleep,
|
|
2918
|
+
onShutdownSignal: (handler) => {
|
|
2919
|
+
triggerShutdown = handler;
|
|
2920
|
+
return () => { };
|
|
2921
|
+
},
|
|
2922
|
+
});
|
|
2923
|
+
return {
|
|
2924
|
+
loop,
|
|
2925
|
+
watcher,
|
|
2926
|
+
tick: sleep.tick,
|
|
2927
|
+
shutdown: () => triggerShutdown(),
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
it("startup runs one full reconcile, then watcher edits wait for the poll drain", async () => {
|
|
2931
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
2932
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
2933
|
+
await flushLoopMicrotasks();
|
|
2934
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
|
|
2935
|
+
runPass.mockClear();
|
|
2936
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
2937
|
+
await flushLoopMicrotasks();
|
|
2938
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
2939
|
+
tick();
|
|
2940
|
+
await flushLoopMicrotasks();
|
|
2941
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
2942
|
+
[
|
|
2943
|
+
"--company",
|
|
2944
|
+
"indigo",
|
|
2945
|
+
"--direction",
|
|
2946
|
+
"push",
|
|
2947
|
+
"--scope-path",
|
|
2948
|
+
"knowledge/a.md",
|
|
2949
|
+
"--hq-root",
|
|
2950
|
+
"/tmp/hq",
|
|
2951
|
+
],
|
|
2952
|
+
fullPullArgv,
|
|
2953
|
+
]);
|
|
2954
|
+
shutdown();
|
|
2955
|
+
tick();
|
|
2956
|
+
await loop;
|
|
2957
|
+
});
|
|
2958
|
+
it("coalesces repeated edits to the same path into one scoped push", async () => {
|
|
2959
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
2960
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
2961
|
+
await flushLoopMicrotasks();
|
|
2962
|
+
runPass.mockClear();
|
|
2963
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
2964
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
2965
|
+
tick();
|
|
2966
|
+
await flushLoopMicrotasks();
|
|
2967
|
+
const pushCalls = runPass.mock.calls
|
|
2968
|
+
.map((call) => call[0])
|
|
2969
|
+
.filter((argv) => argv.includes("--company"));
|
|
2970
|
+
expect(pushCalls).toEqual([
|
|
2971
|
+
[
|
|
2972
|
+
"--company",
|
|
2973
|
+
"indigo",
|
|
2974
|
+
"--direction",
|
|
2975
|
+
"push",
|
|
2976
|
+
"--scope-path",
|
|
2977
|
+
"knowledge/a.md",
|
|
2978
|
+
"--hq-root",
|
|
2979
|
+
"/tmp/hq",
|
|
2980
|
+
],
|
|
2981
|
+
]);
|
|
2982
|
+
shutdown();
|
|
2983
|
+
tick();
|
|
2984
|
+
await loop;
|
|
2985
|
+
});
|
|
2986
|
+
it("drains an unlink path through the same scoped tombstone push path", async () => {
|
|
2987
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
2988
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
2989
|
+
await flushLoopMicrotasks();
|
|
2990
|
+
runPass.mockClear();
|
|
2991
|
+
watcher.emit("companies/indigo/knowledge/deleted.md");
|
|
2992
|
+
tick();
|
|
2993
|
+
await flushLoopMicrotasks();
|
|
2994
|
+
expect(runPass.mock.calls[0][0]).toEqual([
|
|
2995
|
+
"--company",
|
|
2996
|
+
"indigo",
|
|
2997
|
+
"--direction",
|
|
2998
|
+
"push",
|
|
2999
|
+
"--scope-path",
|
|
3000
|
+
"knowledge/deleted.md",
|
|
3001
|
+
"--hq-root",
|
|
3002
|
+
"/tmp/hq",
|
|
3003
|
+
]);
|
|
3004
|
+
shutdown();
|
|
3005
|
+
tick();
|
|
3006
|
+
await loop;
|
|
3007
|
+
});
|
|
3008
|
+
it("omitted --direction is pull-only (parser default): a scoped tick never pushes", async () => {
|
|
3009
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3010
|
+
// No --direction flag → parseArgs defaults the run to pull-only. The drain
|
|
3011
|
+
// must honor that and NOT run a scoped push. Regression: the loop used to
|
|
3012
|
+
// re-parse argv and wrongly default an omitted direction to "both", so a
|
|
3013
|
+
// pull-only watcher would push local edits it should never send.
|
|
3014
|
+
const pullOnlyArgv = [
|
|
3015
|
+
"--companies",
|
|
3016
|
+
"--watch",
|
|
3017
|
+
"--event-push",
|
|
3018
|
+
"--hq-root",
|
|
3019
|
+
"/tmp/hq",
|
|
3020
|
+
"--poll-remote-ms",
|
|
3021
|
+
"60000",
|
|
3022
|
+
];
|
|
3023
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, pullOnlyArgv);
|
|
3024
|
+
await flushLoopMicrotasks();
|
|
3025
|
+
runPass.mockClear();
|
|
3026
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3027
|
+
tick();
|
|
3028
|
+
await flushLoopMicrotasks();
|
|
3029
|
+
const calls = runPass.mock.calls.map((call) => call[0]);
|
|
3030
|
+
// No scoped push of any kind ran...
|
|
3031
|
+
expect(calls.some((argv) => argv.includes("--company"))).toBe(false);
|
|
3032
|
+
expect(calls.some((argv) => argv.includes("--direction") &&
|
|
3033
|
+
argv[argv.indexOf("--direction") + 1] === "push")).toBe(false);
|
|
3034
|
+
// ...only the pull leg ran.
|
|
3035
|
+
expect(calls).toEqual([fullPullArgv]);
|
|
3036
|
+
shutdown();
|
|
3037
|
+
tick();
|
|
3038
|
+
await loop;
|
|
3039
|
+
});
|
|
3040
|
+
it("restores failed scoped push paths so the next tick retries them", async () => {
|
|
3041
|
+
const runPass = vi
|
|
3042
|
+
.fn()
|
|
3043
|
+
.mockResolvedValueOnce(0)
|
|
3044
|
+
.mockResolvedValueOnce(1)
|
|
3045
|
+
.mockResolvedValueOnce(0)
|
|
3046
|
+
.mockResolvedValueOnce(0)
|
|
3047
|
+
.mockResolvedValueOnce(0);
|
|
3048
|
+
const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
|
|
3049
|
+
await flushLoopMicrotasks();
|
|
3050
|
+
runPass.mockClear();
|
|
3051
|
+
watcher.emit("companies/indigo/knowledge/retry.md");
|
|
3052
|
+
tick();
|
|
3053
|
+
await flushLoopMicrotasks();
|
|
3054
|
+
tick();
|
|
3055
|
+
await flushLoopMicrotasks();
|
|
3056
|
+
const pushCalls = runPass.mock.calls
|
|
3057
|
+
.map((call) => call[0])
|
|
3058
|
+
.filter((argv) => argv.includes("--company"));
|
|
3059
|
+
expect(pushCalls).toEqual([
|
|
3060
|
+
[
|
|
3061
|
+
"--company",
|
|
3062
|
+
"indigo",
|
|
3063
|
+
"--direction",
|
|
3064
|
+
"push",
|
|
3065
|
+
"--scope-path",
|
|
3066
|
+
"knowledge/retry.md",
|
|
3067
|
+
"--hq-root",
|
|
3068
|
+
"/tmp/hq",
|
|
3069
|
+
],
|
|
3070
|
+
[
|
|
3071
|
+
"--company",
|
|
3072
|
+
"indigo",
|
|
3073
|
+
"--direction",
|
|
3074
|
+
"push",
|
|
3075
|
+
"--scope-path",
|
|
3076
|
+
"knowledge/retry.md",
|
|
3077
|
+
"--hq-root",
|
|
3078
|
+
"/tmp/hq",
|
|
3079
|
+
],
|
|
3080
|
+
]);
|
|
3081
|
+
shutdown();
|
|
3082
|
+
tick();
|
|
3083
|
+
await loop;
|
|
3084
|
+
});
|
|
3085
|
+
it("empty scoped ticks run only the pull leg", async () => {
|
|
3086
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3087
|
+
const { loop, tick, shutdown } = startDrainLoop(runPass);
|
|
3088
|
+
await flushLoopMicrotasks();
|
|
3089
|
+
runPass.mockClear();
|
|
3090
|
+
tick();
|
|
3091
|
+
await flushLoopMicrotasks();
|
|
3092
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullPullArgv]);
|
|
3093
|
+
shutdown();
|
|
3094
|
+
tick();
|
|
3095
|
+
await loop;
|
|
3096
|
+
});
|
|
3097
|
+
it("personal scoped ticks preserve personal mode for the pull leg", async () => {
|
|
3098
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3099
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
|
|
3100
|
+
"--personal",
|
|
3101
|
+
"--watch",
|
|
3102
|
+
"--event-push",
|
|
3103
|
+
"--direction",
|
|
3104
|
+
"both",
|
|
3105
|
+
"--hq-root",
|
|
3106
|
+
"/tmp/hq",
|
|
3107
|
+
"--poll-remote-ms",
|
|
3108
|
+
"60000",
|
|
3109
|
+
]);
|
|
3110
|
+
await flushLoopMicrotasks();
|
|
3111
|
+
runPass.mockClear();
|
|
3112
|
+
watcher.emit("knowledge/a.md");
|
|
3113
|
+
tick();
|
|
3114
|
+
await flushLoopMicrotasks();
|
|
3115
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3116
|
+
[
|
|
3117
|
+
"--personal",
|
|
3118
|
+
"--direction",
|
|
3119
|
+
"push",
|
|
3120
|
+
"--scope-path",
|
|
3121
|
+
"knowledge/a.md",
|
|
3122
|
+
"--hq-root",
|
|
3123
|
+
"/tmp/hq",
|
|
3124
|
+
],
|
|
3125
|
+
["--personal", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
3126
|
+
]);
|
|
3127
|
+
shutdown();
|
|
3128
|
+
tick();
|
|
3129
|
+
await loop;
|
|
3130
|
+
});
|
|
3131
|
+
it("single-company scoped ticks preserve company mode for the pull leg", async () => {
|
|
3132
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3133
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
|
|
3134
|
+
"--company",
|
|
3135
|
+
"acme",
|
|
3136
|
+
"--watch",
|
|
3137
|
+
"--event-push",
|
|
3138
|
+
"--direction",
|
|
3139
|
+
"both",
|
|
3140
|
+
"--hq-root",
|
|
3141
|
+
"/tmp/hq",
|
|
3142
|
+
"--poll-remote-ms",
|
|
3143
|
+
"60000",
|
|
3144
|
+
]);
|
|
3145
|
+
await flushLoopMicrotasks();
|
|
3146
|
+
runPass.mockClear();
|
|
3147
|
+
watcher.emit("companies/acme/knowledge/a.md");
|
|
3148
|
+
tick();
|
|
3149
|
+
await flushLoopMicrotasks();
|
|
3150
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3151
|
+
[
|
|
3152
|
+
"--company",
|
|
3153
|
+
"acme",
|
|
3154
|
+
"--direction",
|
|
3155
|
+
"push",
|
|
3156
|
+
"--scope-path",
|
|
3157
|
+
"knowledge/a.md",
|
|
3158
|
+
"--hq-root",
|
|
3159
|
+
"/tmp/hq",
|
|
3160
|
+
],
|
|
3161
|
+
["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
3162
|
+
]);
|
|
3163
|
+
shutdown();
|
|
3164
|
+
tick();
|
|
3165
|
+
await loop;
|
|
3166
|
+
});
|
|
3167
|
+
it("push-only scoped ticks run the scoped push and no pull leg", async () => {
|
|
3168
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3169
|
+
const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
|
|
3170
|
+
"--companies",
|
|
3171
|
+
"--watch",
|
|
3172
|
+
"--event-push",
|
|
3173
|
+
"--direction",
|
|
3174
|
+
"push",
|
|
3175
|
+
"--hq-root",
|
|
3176
|
+
"/tmp/hq",
|
|
3177
|
+
"--poll-remote-ms",
|
|
3178
|
+
"60000",
|
|
3179
|
+
]);
|
|
3180
|
+
await flushLoopMicrotasks();
|
|
3181
|
+
runPass.mockClear();
|
|
3182
|
+
watcher.emit("companies/indigo/knowledge/a.md");
|
|
3183
|
+
tick();
|
|
3184
|
+
await flushLoopMicrotasks();
|
|
3185
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([
|
|
3186
|
+
[
|
|
3187
|
+
"--company",
|
|
3188
|
+
"indigo",
|
|
3189
|
+
"--direction",
|
|
3190
|
+
"push",
|
|
3191
|
+
"--scope-path",
|
|
3192
|
+
"knowledge/a.md",
|
|
3193
|
+
"--hq-root",
|
|
3194
|
+
"/tmp/hq",
|
|
3195
|
+
],
|
|
3196
|
+
]);
|
|
3197
|
+
shutdown();
|
|
3198
|
+
tick();
|
|
3199
|
+
await loop;
|
|
3200
|
+
});
|
|
3201
|
+
it("overflow and every tenth tick run a full unscoped reconcile", async () => {
|
|
3202
|
+
const watcher = makeBatchWatcherStub();
|
|
3203
|
+
const sleep = makeSteppableSleep();
|
|
3204
|
+
let triggerShutdown = () => { };
|
|
3205
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3206
|
+
const loop = runRunnerWithLoop(watchArgv, {
|
|
3207
|
+
runPass,
|
|
3208
|
+
clock: new FakeClock(),
|
|
3209
|
+
createWatcher: () => watcher,
|
|
3210
|
+
sleep: sleep.sleep,
|
|
3211
|
+
onShutdownSignal: (handler) => {
|
|
3212
|
+
triggerShutdown = handler;
|
|
3213
|
+
return () => { };
|
|
3214
|
+
},
|
|
3215
|
+
});
|
|
3216
|
+
await flushLoopMicrotasks();
|
|
3217
|
+
runPass.mockClear();
|
|
3218
|
+
watcher.emit("companies/indigo/a.md", {
|
|
3219
|
+
paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]),
|
|
3220
|
+
overflowed: true,
|
|
3221
|
+
});
|
|
3222
|
+
sleep.tick();
|
|
3223
|
+
await flushLoopMicrotasks();
|
|
3224
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
|
|
3225
|
+
runPass.mockClear();
|
|
3226
|
+
for (let i = 0; i < 7; i++) {
|
|
3227
|
+
sleep.tick();
|
|
3228
|
+
await flushLoopMicrotasks();
|
|
3229
|
+
}
|
|
3230
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual(Array.from({ length: 7 }, () => fullPullArgv));
|
|
3231
|
+
runPass.mockClear();
|
|
3232
|
+
sleep.tick();
|
|
3233
|
+
await flushLoopMicrotasks();
|
|
3234
|
+
expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
|
|
3235
|
+
triggerShutdown();
|
|
3236
|
+
sleep.tick();
|
|
3237
|
+
await loop;
|
|
3238
|
+
});
|
|
3239
|
+
});
|
|
2684
3240
|
// ---------------------------------------------------------------------------
|
|
2685
3241
|
// Re-initialize for each test (mock state hygiene)
|
|
2686
3242
|
// ---------------------------------------------------------------------------
|
|
@@ -3369,12 +3925,25 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3369
3925
|
const watcher = opts.watcher ?? makeBatchWatcherStub();
|
|
3370
3926
|
const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
|
|
3371
3927
|
const clock = new FakeClock();
|
|
3928
|
+
const sleep = makeSteppableSleep();
|
|
3372
3929
|
let triggerShutdown = () => { };
|
|
3373
|
-
const loop = runRunnerWithLoop(
|
|
3930
|
+
const loop = runRunnerWithLoop(
|
|
3931
|
+
// `--direction both` so the push leg (and therefore the publish-after-push
|
|
3932
|
+
// wiring these tests exercise) actually runs. Omitting --direction is a
|
|
3933
|
+
// pull-only run by the parser default, which correctly never pushes.
|
|
3934
|
+
[
|
|
3935
|
+
"--companies",
|
|
3936
|
+
"--watch",
|
|
3937
|
+
"--event-push",
|
|
3938
|
+
"--direction",
|
|
3939
|
+
"both",
|
|
3940
|
+
"--hq-root",
|
|
3941
|
+
"/tmp/hq",
|
|
3942
|
+
], {
|
|
3374
3943
|
runPass,
|
|
3375
3944
|
clock,
|
|
3376
3945
|
createWatcher: () => watcher,
|
|
3377
|
-
sleep:
|
|
3946
|
+
sleep: sleep.sleep,
|
|
3378
3947
|
onShutdownSignal: (handler) => {
|
|
3379
3948
|
triggerShutdown = handler;
|
|
3380
3949
|
return () => { };
|
|
@@ -3384,7 +3953,14 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3384
3953
|
startEventSync: (opts.startEventSync ??
|
|
3385
3954
|
vi.fn().mockResolvedValue(null)),
|
|
3386
3955
|
});
|
|
3387
|
-
return {
|
|
3956
|
+
return {
|
|
3957
|
+
loop,
|
|
3958
|
+
watcher,
|
|
3959
|
+
runPass,
|
|
3960
|
+
clock,
|
|
3961
|
+
tick: sleep.tick,
|
|
3962
|
+
shutdown: () => triggerShutdown(),
|
|
3963
|
+
};
|
|
3388
3964
|
}
|
|
3389
3965
|
async function microtasks(n = 6) {
|
|
3390
3966
|
for (let i = 0; i < n; i++)
|
|
@@ -3436,7 +4012,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3436
4012
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3437
4013
|
});
|
|
3438
4014
|
const runPass = vi.fn().mockResolvedValue(0);
|
|
3439
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
4015
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
3440
4016
|
claims: ENROLLED_CLAIMS,
|
|
3441
4017
|
startEventSync,
|
|
3442
4018
|
runPass,
|
|
@@ -3449,10 +4025,16 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3449
4025
|
watcher.emit("companies/indigo/a.md", batch);
|
|
3450
4026
|
clock.advance(0);
|
|
3451
4027
|
await microtasks();
|
|
3452
|
-
|
|
4028
|
+
expect(runPass).not.toHaveBeenCalled();
|
|
4029
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
4030
|
+
tick();
|
|
4031
|
+
await microtasks();
|
|
4032
|
+
// Scoped push drained and succeeded → batch published.
|
|
3453
4033
|
expect(runPass).toHaveBeenCalled();
|
|
3454
4034
|
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
3455
|
-
expect(publishBatch.mock.calls[0][0]).
|
|
4035
|
+
expect([...publishBatch.mock.calls[0][0].paths.values()]).toEqual([
|
|
4036
|
+
"companies/indigo/a.md",
|
|
4037
|
+
]);
|
|
3456
4038
|
shutdown();
|
|
3457
4039
|
await loop;
|
|
3458
4040
|
});
|
|
@@ -3469,7 +4051,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3469
4051
|
.fn()
|
|
3470
4052
|
.mockResolvedValueOnce(0)
|
|
3471
4053
|
.mockResolvedValue(1);
|
|
3472
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
4054
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
3473
4055
|
claims: ENROLLED_CLAIMS,
|
|
3474
4056
|
startEventSync,
|
|
3475
4057
|
runPass,
|
|
@@ -3478,6 +4060,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3478
4060
|
watcher.emit("companies/indigo/a.md", { paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]) });
|
|
3479
4061
|
clock.advance(0);
|
|
3480
4062
|
await microtasks();
|
|
4063
|
+
tick();
|
|
4064
|
+
await microtasks();
|
|
3481
4065
|
expect(publishBatch).not.toHaveBeenCalled();
|
|
3482
4066
|
shutdown();
|
|
3483
4067
|
await loop;
|
|
@@ -3491,7 +4075,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3491
4075
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3492
4076
|
});
|
|
3493
4077
|
const runPass = vi.fn().mockResolvedValue(0);
|
|
3494
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
4078
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
3495
4079
|
claims: ENROLLED_CLAIMS,
|
|
3496
4080
|
startEventSync,
|
|
3497
4081
|
runPass,
|
|
@@ -3507,6 +4091,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3507
4091
|
});
|
|
3508
4092
|
clock.advance(0);
|
|
3509
4093
|
await microtasks();
|
|
4094
|
+
tick();
|
|
4095
|
+
await microtasks();
|
|
3510
4096
|
const pushedRoutes = new Set();
|
|
3511
4097
|
let pushedEverything = false;
|
|
3512
4098
|
for (const call of runPass.mock.calls) {
|
|
@@ -3518,6 +4104,9 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3518
4104
|
if (companyIdx >= 0) {
|
|
3519
4105
|
pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
|
|
3520
4106
|
}
|
|
4107
|
+
else if (passArgv.includes("--personal")) {
|
|
4108
|
+
pushedRoutes.add("personal");
|
|
4109
|
+
}
|
|
3521
4110
|
else if (passArgv.includes("--companies")) {
|
|
3522
4111
|
pushedEverything = true;
|
|
3523
4112
|
}
|
|
@@ -3553,6 +4142,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3553
4142
|
let receiverSync = null;
|
|
3554
4143
|
const watcher = makeBatchWatcherStub();
|
|
3555
4144
|
const clock = new FakeClock();
|
|
4145
|
+
const sleep = makeSteppableSleep();
|
|
3556
4146
|
let triggerShutdown = () => { };
|
|
3557
4147
|
let releasePoll = () => { };
|
|
3558
4148
|
const pollGate = new Promise((resolve) => {
|
|
@@ -3565,7 +4155,18 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3565
4155
|
await pollGate;
|
|
3566
4156
|
return 0;
|
|
3567
4157
|
});
|
|
3568
|
-
const loop = runRunnerWithLoop(
|
|
4158
|
+
const loop = runRunnerWithLoop(
|
|
4159
|
+
// `--direction both` so the queued watcher pushes actually run (an omitted
|
|
4160
|
+
// direction is pull-only by the parser default).
|
|
4161
|
+
[
|
|
4162
|
+
"--companies",
|
|
4163
|
+
"--watch",
|
|
4164
|
+
"--event-push",
|
|
4165
|
+
"--direction",
|
|
4166
|
+
"both",
|
|
4167
|
+
"--hq-root",
|
|
4168
|
+
"/tmp/hq",
|
|
4169
|
+
], {
|
|
3569
4170
|
runPass,
|
|
3570
4171
|
clock,
|
|
3571
4172
|
createWatcher: () => watcher,
|
|
@@ -3577,7 +4178,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3577
4178
|
dispose: async () => { },
|
|
3578
4179
|
};
|
|
3579
4180
|
},
|
|
3580
|
-
sleep:
|
|
4181
|
+
sleep: sleep.sleep,
|
|
3581
4182
|
onShutdownSignal: (handler) => {
|
|
3582
4183
|
triggerShutdown = handler;
|
|
3583
4184
|
return () => { };
|
|
@@ -3622,12 +4223,30 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3622
4223
|
releasePoll();
|
|
3623
4224
|
await remotePull;
|
|
3624
4225
|
await microtasks();
|
|
3625
|
-
|
|
4226
|
+
sleep.tick();
|
|
3626
4227
|
await microtasks();
|
|
3627
4228
|
const passArgvs = runPass.mock.calls.map((call) => call[0]);
|
|
3628
4229
|
expect(passArgvs).toEqual(expect.arrayContaining([
|
|
3629
|
-
[
|
|
3630
|
-
|
|
4230
|
+
[
|
|
4231
|
+
"--company",
|
|
4232
|
+
"indigo",
|
|
4233
|
+
"--direction",
|
|
4234
|
+
"push",
|
|
4235
|
+
"--scope-path",
|
|
4236
|
+
"a.md",
|
|
4237
|
+
"--hq-root",
|
|
4238
|
+
"/tmp/hq",
|
|
4239
|
+
],
|
|
4240
|
+
[
|
|
4241
|
+
"--company",
|
|
4242
|
+
"beta",
|
|
4243
|
+
"--direction",
|
|
4244
|
+
"push",
|
|
4245
|
+
"--scope-path",
|
|
4246
|
+
"b.md",
|
|
4247
|
+
"--hq-root",
|
|
4248
|
+
"/tmp/hq",
|
|
4249
|
+
],
|
|
3631
4250
|
["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
|
|
3632
4251
|
]));
|
|
3633
4252
|
const pushedRoutes = new Set();
|
|
@@ -3663,7 +4282,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3663
4282
|
ownDeviceId: "dev-test",
|
|
3664
4283
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
3665
4284
|
});
|
|
3666
|
-
const { loop, watcher, clock, shutdown } = runLoop({
|
|
4285
|
+
const { loop, watcher, clock, tick, shutdown } = runLoop({
|
|
3667
4286
|
claims: ENROLLED_CLAIMS,
|
|
3668
4287
|
startEventSync,
|
|
3669
4288
|
});
|
|
@@ -3671,6 +4290,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3671
4290
|
watcher.emit("companies/indigo/b.md"); // no batch
|
|
3672
4291
|
clock.advance(0);
|
|
3673
4292
|
await microtasks();
|
|
4293
|
+
tick();
|
|
4294
|
+
await microtasks();
|
|
3674
4295
|
expect(publishBatch).toHaveBeenCalledTimes(1);
|
|
3675
4296
|
const synthesized = publishBatch.mock.calls[0][0];
|
|
3676
4297
|
expect([...synthesized.paths.values()]).toEqual(["companies/indigo/b.md"]);
|