@indigoai-us/hq-cloud 6.13.5 → 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.
@@ -17,6 +17,7 @@ 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
19
  import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
20
+ import { isCoveredByAny } from "../prefix-coalesce.js";
20
21
  import { VaultAuthError } from "../vault-client.js";
21
22
  // ---------------------------------------------------------------------------
22
23
  // Hermetic journal state — the runner now calls migratePersonalVaultJournal()
@@ -1640,6 +1641,75 @@ describe("--direction", () => {
1640
1641
  // preserving the pre-fix company-target args shape (full access).
1641
1642
  expect(opts.prefixSet).toBeUndefined();
1642
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
+ });
1643
1713
  it("direction=push (shared membership): forwards the resolved ACL prefixSet to share() (feedback_ded09d56)", async () => {
1644
1714
  // Plumbing regression for the fresh fix: a member/guest's push must be
1645
1715
  // scoped to their granted prefixes so out-of-scope keys are filtered
@@ -1669,6 +1739,76 @@ describe("--direction", () => {
1669
1739
  const opts = shareSpy.mock.calls[0][0];
1670
1740
  expect(opts.prefixSet).toEqual(["knowledge/", "policies/"]);
1671
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
+ });
1672
1812
  it("direction=both: all-complete sums uploaded and downloaded across companies", async () => {
1673
1813
  const slugs = { cmp_a: "acme", cmp_b: "beta" };
1674
1814
  const deps = makeDeps({
@@ -2413,6 +2553,24 @@ function makeWatcherStub() {
2413
2553
  };
2414
2554
  return stub;
2415
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
+ }
2416
2574
  describe("runRunnerWithLoop — event-push wiring", () => {
2417
2575
  it("starts the watcher alongside the poll loop when --event-push is on", async () => {
2418
2576
  const watcher = makeWatcherStub();
@@ -2438,7 +2596,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2438
2596
  expect(runPass).toHaveBeenCalled();
2439
2597
  expect(watcher.disposed).toBe(true);
2440
2598
  });
2441
- it("trigger-on-change: a debounced changed signal runs a targeted company push", async () => {
2599
+ it("trigger-on-change: a changed signal accumulates without an immediate push", async () => {
2442
2600
  const watcher = makeWatcherStub();
2443
2601
  const clock = new FakeClock();
2444
2602
  let triggerShutdown = () => { };
@@ -2462,20 +2620,11 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2462
2620
  clock.advance(0);
2463
2621
  await Promise.resolve();
2464
2622
  await Promise.resolve();
2465
- const targetedCall = runPass.mock.calls.find((c) => c[0].includes("--company"));
2466
- expect(targetedCall).toBeDefined();
2467
- expect(targetedCall[0]).toEqual([
2468
- "--company",
2469
- "indigo",
2470
- "--direction",
2471
- "push",
2472
- "--hq-root",
2473
- "/tmp/hq",
2474
- ]);
2623
+ expect(runPass).not.toHaveBeenCalled();
2475
2624
  triggerShutdown();
2476
2625
  await loop;
2477
2626
  });
2478
- it("bare signal (no path) routes the targeted push to the personal vault", async () => {
2627
+ it("bare signal (no path) accumulates without an immediate push", async () => {
2479
2628
  const watcher = makeWatcherStub();
2480
2629
  const clock = new FakeClock();
2481
2630
  let triggerShutdown = () => { };
@@ -2497,16 +2646,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2497
2646
  clock.advance(0);
2498
2647
  await Promise.resolve();
2499
2648
  await Promise.resolve();
2500
- const personalCall = runPass.mock.calls.find((c) => c[0].includes("--companies") &&
2501
- c[0].includes("--direction"));
2502
- expect(personalCall).toBeDefined();
2503
- expect(personalCall[0]).toEqual([
2504
- "--companies",
2505
- "--direction",
2506
- "push",
2507
- "--hq-root",
2508
- "/tmp/hq",
2509
- ]);
2649
+ expect(runPass).not.toHaveBeenCalled();
2510
2650
  triggerShutdown();
2511
2651
  await loop;
2512
2652
  });
@@ -2564,7 +2704,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2564
2704
  triggerShutdown();
2565
2705
  await loop;
2566
2706
  });
2567
- it("F05: guard-held watcher push is queued after the active poll pass", async () => {
2707
+ it("F05: guard-held watcher changes stay accumulated until a poll drain", async () => {
2568
2708
  const watcher = makeWatcherStub();
2569
2709
  const clock = new FakeClock();
2570
2710
  let triggerShutdown = () => { };
@@ -2612,14 +2752,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2612
2752
  await loop;
2613
2753
  expect(maxConcurrent).toBe(1);
2614
2754
  const targetedCall = runPass.mock.calls.find((c) => c[0].includes("--company"));
2615
- expect(targetedCall?.[0]).toEqual([
2616
- "--company",
2617
- "indigo",
2618
- "--direction",
2619
- "push",
2620
- "--hq-root",
2621
- "/tmp/hq",
2622
- ]);
2755
+ expect(targetedCall).toBeUndefined();
2623
2756
  });
2624
2757
  it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
2625
2758
  const watcher = makeWatcherStub();
@@ -2750,6 +2883,360 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2750
2883
  await loop;
2751
2884
  });
2752
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
+ });
2753
3240
  // ---------------------------------------------------------------------------
2754
3241
  // Re-initialize for each test (mock state hygiene)
2755
3242
  // ---------------------------------------------------------------------------
@@ -3438,12 +3925,25 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3438
3925
  const watcher = opts.watcher ?? makeBatchWatcherStub();
3439
3926
  const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
3440
3927
  const clock = new FakeClock();
3928
+ const sleep = makeSteppableSleep();
3441
3929
  let triggerShutdown = () => { };
3442
- const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"], {
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
+ ], {
3443
3943
  runPass,
3444
3944
  clock,
3445
3945
  createWatcher: () => watcher,
3446
- sleep: () => new Promise(() => { }),
3946
+ sleep: sleep.sleep,
3447
3947
  onShutdownSignal: (handler) => {
3448
3948
  triggerShutdown = handler;
3449
3949
  return () => { };
@@ -3453,7 +3953,14 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3453
3953
  startEventSync: (opts.startEventSync ??
3454
3954
  vi.fn().mockResolvedValue(null)),
3455
3955
  });
3456
- return { loop, watcher, runPass, clock, shutdown: () => triggerShutdown() };
3956
+ return {
3957
+ loop,
3958
+ watcher,
3959
+ runPass,
3960
+ clock,
3961
+ tick: sleep.tick,
3962
+ shutdown: () => triggerShutdown(),
3963
+ };
3457
3964
  }
3458
3965
  async function microtasks(n = 6) {
3459
3966
  for (let i = 0; i < n; i++)
@@ -3505,7 +4012,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3505
4012
  dispose: vi.fn().mockResolvedValue(undefined),
3506
4013
  });
3507
4014
  const runPass = vi.fn().mockResolvedValue(0);
3508
- const { loop, watcher, clock, shutdown } = runLoop({
4015
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
3509
4016
  claims: ENROLLED_CLAIMS,
3510
4017
  startEventSync,
3511
4018
  runPass,
@@ -3518,10 +4025,16 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3518
4025
  watcher.emit("companies/indigo/a.md", batch);
3519
4026
  clock.advance(0);
3520
4027
  await microtasks();
3521
- // Targeted push ran and succeeded → batch published.
4028
+ expect(runPass).not.toHaveBeenCalled();
4029
+ expect(publishBatch).not.toHaveBeenCalled();
4030
+ tick();
4031
+ await microtasks();
4032
+ // Scoped push drained and succeeded → batch published.
3522
4033
  expect(runPass).toHaveBeenCalled();
3523
4034
  expect(publishBatch).toHaveBeenCalledTimes(1);
3524
- expect(publishBatch.mock.calls[0][0]).toBe(batch);
4035
+ expect([...publishBatch.mock.calls[0][0].paths.values()]).toEqual([
4036
+ "companies/indigo/a.md",
4037
+ ]);
3525
4038
  shutdown();
3526
4039
  await loop;
3527
4040
  });
@@ -3538,7 +4051,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3538
4051
  .fn()
3539
4052
  .mockResolvedValueOnce(0)
3540
4053
  .mockResolvedValue(1);
3541
- const { loop, watcher, clock, shutdown } = runLoop({
4054
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
3542
4055
  claims: ENROLLED_CLAIMS,
3543
4056
  startEventSync,
3544
4057
  runPass,
@@ -3547,6 +4060,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3547
4060
  watcher.emit("companies/indigo/a.md", { paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]) });
3548
4061
  clock.advance(0);
3549
4062
  await microtasks();
4063
+ tick();
4064
+ await microtasks();
3550
4065
  expect(publishBatch).not.toHaveBeenCalled();
3551
4066
  shutdown();
3552
4067
  await loop;
@@ -3560,7 +4075,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3560
4075
  dispose: vi.fn().mockResolvedValue(undefined),
3561
4076
  });
3562
4077
  const runPass = vi.fn().mockResolvedValue(0);
3563
- const { loop, watcher, clock, shutdown } = runLoop({
4078
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
3564
4079
  claims: ENROLLED_CLAIMS,
3565
4080
  startEventSync,
3566
4081
  runPass,
@@ -3576,6 +4091,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3576
4091
  });
3577
4092
  clock.advance(0);
3578
4093
  await microtasks();
4094
+ tick();
4095
+ await microtasks();
3579
4096
  const pushedRoutes = new Set();
3580
4097
  let pushedEverything = false;
3581
4098
  for (const call of runPass.mock.calls) {
@@ -3587,6 +4104,9 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3587
4104
  if (companyIdx >= 0) {
3588
4105
  pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
3589
4106
  }
4107
+ else if (passArgv.includes("--personal")) {
4108
+ pushedRoutes.add("personal");
4109
+ }
3590
4110
  else if (passArgv.includes("--companies")) {
3591
4111
  pushedEverything = true;
3592
4112
  }
@@ -3622,6 +4142,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3622
4142
  let receiverSync = null;
3623
4143
  const watcher = makeBatchWatcherStub();
3624
4144
  const clock = new FakeClock();
4145
+ const sleep = makeSteppableSleep();
3625
4146
  let triggerShutdown = () => { };
3626
4147
  let releasePoll = () => { };
3627
4148
  const pollGate = new Promise((resolve) => {
@@ -3634,7 +4155,18 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3634
4155
  await pollGate;
3635
4156
  return 0;
3636
4157
  });
3637
- const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"], {
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
+ ], {
3638
4170
  runPass,
3639
4171
  clock,
3640
4172
  createWatcher: () => watcher,
@@ -3646,7 +4178,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3646
4178
  dispose: async () => { },
3647
4179
  };
3648
4180
  },
3649
- sleep: () => new Promise(() => { }),
4181
+ sleep: sleep.sleep,
3650
4182
  onShutdownSignal: (handler) => {
3651
4183
  triggerShutdown = handler;
3652
4184
  return () => { };
@@ -3691,12 +4223,30 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3691
4223
  releasePoll();
3692
4224
  await remotePull;
3693
4225
  await microtasks();
3694
- clock.advance(0);
4226
+ sleep.tick();
3695
4227
  await microtasks();
3696
4228
  const passArgvs = runPass.mock.calls.map((call) => call[0]);
3697
4229
  expect(passArgvs).toEqual(expect.arrayContaining([
3698
- ["--company", "indigo", "--direction", "push", "--hq-root", "/tmp/hq"],
3699
- ["--company", "beta", "--direction", "push", "--hq-root", "/tmp/hq"],
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
+ ],
3700
4250
  ["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
3701
4251
  ]));
3702
4252
  const pushedRoutes = new Set();
@@ -3732,7 +4282,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3732
4282
  ownDeviceId: "dev-test",
3733
4283
  dispose: vi.fn().mockResolvedValue(undefined),
3734
4284
  });
3735
- const { loop, watcher, clock, shutdown } = runLoop({
4285
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
3736
4286
  claims: ENROLLED_CLAIMS,
3737
4287
  startEventSync,
3738
4288
  });
@@ -3740,6 +4290,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3740
4290
  watcher.emit("companies/indigo/b.md"); // no batch
3741
4291
  clock.advance(0);
3742
4292
  await microtasks();
4293
+ tick();
4294
+ await microtasks();
3743
4295
  expect(publishBatch).toHaveBeenCalledTimes(1);
3744
4296
  const synthesized = publishBatch.mock.calls[0][0];
3745
4297
  expect([...synthesized.paths.values()]).toEqual(["companies/indigo/b.md"]);