@indigoai-us/hq-cloud 6.13.5 → 6.14.1

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.
@@ -36,6 +36,7 @@ import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
36
36
  import { HQ_CLOUD_VERSION } from "../version.js";
37
37
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
38
38
  import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
39
+ import { isCoveredByAny } from "../prefix-coalesce.js";
39
40
  import type { SyncResult, SyncOptions } from "../cli/sync.js";
40
41
  import type { ShareResult, ShareOptions } from "../cli/share.js";
41
42
  import type {
@@ -1980,6 +1981,101 @@ describe("--direction", () => {
1980
1981
  expect(opts.prefixSet).toBeUndefined();
1981
1982
  });
1982
1983
 
1984
+ it("direction=push with --scope-path: forwards exact and descendant scoped prefixes to share()", async () => {
1985
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1986
+ const deps = makeDeps({
1987
+ createVaultClient: () =>
1988
+ makeVaultStub({
1989
+ memberships: [{ companyUid: "cmp_a" }],
1990
+ entityGet: (uid: string) =>
1991
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1992
+ }),
1993
+ sync: vi.fn(),
1994
+ share: shareSpy,
1995
+ });
1996
+
1997
+ await runRunner(
1998
+ [
1999
+ "--companies",
2000
+ "--direction",
2001
+ "push",
2002
+ "--hq-root",
2003
+ "/tmp/fake-hq",
2004
+ "--scope-path",
2005
+ "knowledge/a.md",
2006
+ ],
2007
+ deps,
2008
+ );
2009
+
2010
+ const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
2011
+ expect(opts.prefixSet).toEqual(["knowledge/a.md", "knowledge/a.md/"]);
2012
+ });
2013
+
2014
+ it("direction=push with directory --scope-path covers descendant keys for tombstones", async () => {
2015
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2016
+ const deps = makeDeps({
2017
+ createVaultClient: () =>
2018
+ makeVaultStub({
2019
+ memberships: [{ companyUid: "cmp_a" }],
2020
+ entityGet: (uid: string) =>
2021
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2022
+ }),
2023
+ sync: vi.fn(),
2024
+ share: shareSpy,
2025
+ });
2026
+
2027
+ await runRunner(
2028
+ [
2029
+ "--companies",
2030
+ "--direction",
2031
+ "push",
2032
+ "--hq-root",
2033
+ "/tmp/fake-hq",
2034
+ "--scope-path",
2035
+ "knowledge/archive",
2036
+ ],
2037
+ deps,
2038
+ );
2039
+
2040
+ const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
2041
+ expect(opts.prefixSet).toBeDefined();
2042
+ expect(isCoveredByAny("knowledge/archive/a.md", opts.prefixSet ?? [])).toBe(
2043
+ true,
2044
+ );
2045
+ });
2046
+
2047
+ it("direction=push with file --scope-path does not cover siblings", async () => {
2048
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2049
+ const deps = makeDeps({
2050
+ createVaultClient: () =>
2051
+ makeVaultStub({
2052
+ memberships: [{ companyUid: "cmp_a" }],
2053
+ entityGet: (uid: string) =>
2054
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2055
+ }),
2056
+ sync: vi.fn(),
2057
+ share: shareSpy,
2058
+ });
2059
+
2060
+ await runRunner(
2061
+ [
2062
+ "--companies",
2063
+ "--direction",
2064
+ "push",
2065
+ "--hq-root",
2066
+ "/tmp/fake-hq",
2067
+ "--scope-path",
2068
+ "board.json",
2069
+ ],
2070
+ deps,
2071
+ );
2072
+
2073
+ const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
2074
+ expect(opts.prefixSet).toBeDefined();
2075
+ expect(isCoveredByAny("board.json", opts.prefixSet ?? [])).toBe(true);
2076
+ expect(isCoveredByAny("other.json", opts.prefixSet ?? [])).toBe(false);
2077
+ });
2078
+
1983
2079
  it("direction=push (shared membership): forwards the resolved ACL prefixSet to share() (feedback_ded09d56)", async () => {
1984
2080
  // Plumbing regression for the fresh fix: a member/guest's push must be
1985
2081
  // scoped to their granted prefixes so out-of-scope keys are filtered
@@ -2015,6 +2111,92 @@ describe("--direction", () => {
2015
2111
  expect(opts.prefixSet).toEqual(["knowledge/", "policies/"]);
2016
2112
  });
2017
2113
 
2114
+ it("direction=push with --scope-path never widens a shared ACL prefixSet", async () => {
2115
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2116
+ const client = {
2117
+ ...makeVaultStub({
2118
+ memberships: [{ companyUid: "cmp_a" }],
2119
+ entityGet: (uid: string) =>
2120
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2121
+ }),
2122
+ getMembershipSyncConfig: async () => ({
2123
+ membershipId: "mk_a",
2124
+ syncMode: "shared",
2125
+ isDefault: false,
2126
+ }),
2127
+ listMyExplicitGrants: async () => [
2128
+ { companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
2129
+ ],
2130
+ } as unknown as ReturnType<typeof makeVaultStub>;
2131
+ const deps = makeDeps({
2132
+ createVaultClient: () => client,
2133
+ sync: vi.fn(),
2134
+ share: shareSpy,
2135
+ });
2136
+
2137
+ await runRunner(
2138
+ [
2139
+ "--companies",
2140
+ "--direction",
2141
+ "push",
2142
+ "--hq-root",
2143
+ "/tmp/fake-hq",
2144
+ "--scope-path",
2145
+ "knowledge/a.md",
2146
+ "--scope-path",
2147
+ "secrets/b.md",
2148
+ ],
2149
+ deps,
2150
+ );
2151
+
2152
+ const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
2153
+ expect(opts.prefixSet).toEqual(["knowledge/a.md", "knowledge/a.md/"]);
2154
+ expect(isCoveredByAny("secrets/b.md", opts.prefixSet ?? [])).toBe(false);
2155
+ });
2156
+
2157
+ it("direction=push with directory --scope-path never widens outside a shared ACL prefixSet", async () => {
2158
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2159
+ const client = {
2160
+ ...makeVaultStub({
2161
+ memberships: [{ companyUid: "cmp_a" }],
2162
+ entityGet: (uid: string) =>
2163
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2164
+ }),
2165
+ getMembershipSyncConfig: async () => ({
2166
+ membershipId: "mk_a",
2167
+ syncMode: "shared",
2168
+ isDefault: false,
2169
+ }),
2170
+ listMyExplicitGrants: async () => [
2171
+ { companyUid: "cmp_a", path: "policies/", permission: "read", source: "person" },
2172
+ ],
2173
+ } as unknown as ReturnType<typeof makeVaultStub>;
2174
+ const deps = makeDeps({
2175
+ createVaultClient: () => client,
2176
+ sync: vi.fn(),
2177
+ share: shareSpy,
2178
+ });
2179
+
2180
+ await runRunner(
2181
+ [
2182
+ "--companies",
2183
+ "--direction",
2184
+ "push",
2185
+ "--hq-root",
2186
+ "/tmp/fake-hq",
2187
+ "--scope-path",
2188
+ "knowledge/archive",
2189
+ ],
2190
+ deps,
2191
+ );
2192
+
2193
+ const opts = (shareSpy.mock.calls[0] as [ShareOptions])[0];
2194
+ expect(isCoveredByAny("knowledge/archive/a.md", opts.prefixSet ?? [])).toBe(
2195
+ false,
2196
+ );
2197
+ expect(isCoveredByAny("policies/a.md", opts.prefixSet ?? [])).toBe(false);
2198
+ });
2199
+
2018
2200
  it("direction=both: all-complete sums uploaded and downloaded across companies", async () => {
2019
2201
  const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
2020
2202
  const deps = makeDeps({
@@ -2992,6 +3174,25 @@ function makeWatcherStub(): WatcherStub {
2992
3174
  return stub;
2993
3175
  }
2994
3176
 
3177
+ async function flushLoopMicrotasks(n = 8) {
3178
+ for (let i = 0; i < n; i++) await Promise.resolve();
3179
+ }
3180
+
3181
+ function makeSteppableSleep() {
3182
+ const pending: Array<() => void> = [];
3183
+ return {
3184
+ sleep: () =>
3185
+ new Promise<void>((resolve) => {
3186
+ pending.push(resolve);
3187
+ }),
3188
+ tick() {
3189
+ const resolve = pending.shift();
3190
+ if (!resolve) throw new Error("poll sleep was not pending");
3191
+ resolve();
3192
+ },
3193
+ };
3194
+ }
3195
+
2995
3196
  describe("runRunnerWithLoop — event-push wiring", () => {
2996
3197
  it("starts the watcher alongside the poll loop when --event-push is on", async () => {
2997
3198
  const watcher = makeWatcherStub();
@@ -3023,7 +3224,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3023
3224
  expect(watcher.disposed).toBe(true);
3024
3225
  });
3025
3226
 
3026
- it("trigger-on-change: a debounced changed signal runs a targeted company push", async () => {
3227
+ it("trigger-on-change: a changed signal accumulates without an immediate push", async () => {
3027
3228
  const watcher = makeWatcherStub();
3028
3229
  const clock = new FakeClock();
3029
3230
  let triggerShutdown = () => {};
@@ -3054,24 +3255,13 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3054
3255
  await Promise.resolve();
3055
3256
  await Promise.resolve();
3056
3257
 
3057
- const targetedCall = runPass.mock.calls.find((c) =>
3058
- (c[0] as string[]).includes("--company"),
3059
- );
3060
- expect(targetedCall).toBeDefined();
3061
- expect(targetedCall![0]).toEqual([
3062
- "--company",
3063
- "indigo",
3064
- "--direction",
3065
- "push",
3066
- "--hq-root",
3067
- "/tmp/hq",
3068
- ]);
3258
+ expect(runPass).not.toHaveBeenCalled();
3069
3259
 
3070
3260
  triggerShutdown();
3071
3261
  await loop;
3072
3262
  });
3073
3263
 
3074
- it("bare signal (no path) routes the targeted push to the personal vault", async () => {
3264
+ it("bare signal (no path) accumulates without an immediate push", async () => {
3075
3265
  const watcher = makeWatcherStub();
3076
3266
  const clock = new FakeClock();
3077
3267
  let triggerShutdown = () => {};
@@ -3100,19 +3290,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3100
3290
  await Promise.resolve();
3101
3291
  await Promise.resolve();
3102
3292
 
3103
- const personalCall = runPass.mock.calls.find(
3104
- (c) =>
3105
- (c[0] as string[]).includes("--companies") &&
3106
- (c[0] as string[]).includes("--direction"),
3107
- );
3108
- expect(personalCall).toBeDefined();
3109
- expect(personalCall![0]).toEqual([
3110
- "--companies",
3111
- "--direction",
3112
- "push",
3113
- "--hq-root",
3114
- "/tmp/hq",
3115
- ]);
3293
+ expect(runPass).not.toHaveBeenCalled();
3116
3294
 
3117
3295
  triggerShutdown();
3118
3296
  await loop;
@@ -3182,7 +3360,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3182
3360
  await loop;
3183
3361
  });
3184
3362
 
3185
- it("F05: guard-held watcher push is queued after the active poll pass", async () => {
3363
+ it("F05: guard-held watcher changes stay accumulated until a poll drain", async () => {
3186
3364
  const watcher = makeWatcherStub();
3187
3365
  const clock = new FakeClock();
3188
3366
  let triggerShutdown = () => {};
@@ -3239,14 +3417,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3239
3417
  const targetedCall = runPass.mock.calls.find((c) =>
3240
3418
  (c[0] as string[]).includes("--company"),
3241
3419
  );
3242
- expect(targetedCall?.[0]).toEqual([
3243
- "--company",
3244
- "indigo",
3245
- "--direction",
3246
- "push",
3247
- "--hq-root",
3248
- "/tmp/hq",
3249
- ]);
3420
+ expect(targetedCall).toBeUndefined();
3250
3421
  });
3251
3422
 
3252
3423
  it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
@@ -3408,6 +3579,419 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3408
3579
  });
3409
3580
  });
3410
3581
 
3582
+ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
3583
+ const watchArgv = [
3584
+ "--companies",
3585
+ "--watch",
3586
+ "--event-push",
3587
+ "--direction",
3588
+ "both",
3589
+ "--hq-root",
3590
+ "/tmp/hq",
3591
+ "--poll-remote-ms",
3592
+ "60000",
3593
+ ];
3594
+ const fullArgv = ["--companies", "--direction", "both", "--hq-root", "/tmp/hq"];
3595
+ const fullPullArgv = [
3596
+ "--companies",
3597
+ "--direction",
3598
+ "pull",
3599
+ "--hq-root",
3600
+ "/tmp/hq",
3601
+ ];
3602
+
3603
+ function startDrainLoop(runPass: ReturnType<typeof vi.fn>) {
3604
+ return startDrainLoopWithArgv(runPass, watchArgv);
3605
+ }
3606
+
3607
+ function startDrainLoopWithArgv(
3608
+ runPass: ReturnType<typeof vi.fn>,
3609
+ argv: string[],
3610
+ ) {
3611
+ const watcher = makeWatcherStub();
3612
+ const sleep = makeSteppableSleep();
3613
+ let triggerShutdown = () => {};
3614
+ const loop = runRunnerWithLoop(argv, {
3615
+ runPass,
3616
+ clock: new FakeClock(),
3617
+ createWatcher: () => watcher,
3618
+ sleep: sleep.sleep,
3619
+ onShutdownSignal: (handler) => {
3620
+ triggerShutdown = handler;
3621
+ return () => {};
3622
+ },
3623
+ });
3624
+ return {
3625
+ loop,
3626
+ watcher,
3627
+ tick: sleep.tick,
3628
+ shutdown: () => triggerShutdown(),
3629
+ };
3630
+ }
3631
+
3632
+ it("startup runs one full reconcile, then watcher edits wait for the poll drain", async () => {
3633
+ const runPass = vi.fn().mockResolvedValue(0);
3634
+ const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
3635
+ await flushLoopMicrotasks();
3636
+
3637
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
3638
+ runPass.mockClear();
3639
+
3640
+ watcher.emit("companies/indigo/knowledge/a.md");
3641
+ await flushLoopMicrotasks();
3642
+ expect(runPass).not.toHaveBeenCalled();
3643
+
3644
+ tick();
3645
+ await flushLoopMicrotasks();
3646
+
3647
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([
3648
+ [
3649
+ "--company",
3650
+ "indigo",
3651
+ "--direction",
3652
+ "push",
3653
+ "--scope-path",
3654
+ "knowledge/a.md",
3655
+ "--hq-root",
3656
+ "/tmp/hq",
3657
+ ],
3658
+ fullPullArgv,
3659
+ ]);
3660
+
3661
+ shutdown();
3662
+ tick();
3663
+ await loop;
3664
+ });
3665
+
3666
+ it("coalesces repeated edits to the same path into one scoped push", async () => {
3667
+ const runPass = vi.fn().mockResolvedValue(0);
3668
+ const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
3669
+ await flushLoopMicrotasks();
3670
+ runPass.mockClear();
3671
+
3672
+ watcher.emit("companies/indigo/knowledge/a.md");
3673
+ watcher.emit("companies/indigo/knowledge/a.md");
3674
+ tick();
3675
+ await flushLoopMicrotasks();
3676
+
3677
+ const pushCalls = runPass.mock.calls
3678
+ .map((call) => call[0] as string[])
3679
+ .filter((argv) => argv.includes("--company"));
3680
+ expect(pushCalls).toEqual([
3681
+ [
3682
+ "--company",
3683
+ "indigo",
3684
+ "--direction",
3685
+ "push",
3686
+ "--scope-path",
3687
+ "knowledge/a.md",
3688
+ "--hq-root",
3689
+ "/tmp/hq",
3690
+ ],
3691
+ ]);
3692
+
3693
+ shutdown();
3694
+ tick();
3695
+ await loop;
3696
+ });
3697
+
3698
+ it("drains an unlink path through the same scoped tombstone push path", async () => {
3699
+ const runPass = vi.fn().mockResolvedValue(0);
3700
+ const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
3701
+ await flushLoopMicrotasks();
3702
+ runPass.mockClear();
3703
+
3704
+ watcher.emit("companies/indigo/knowledge/deleted.md");
3705
+ tick();
3706
+ await flushLoopMicrotasks();
3707
+
3708
+ expect(runPass.mock.calls[0][0]).toEqual([
3709
+ "--company",
3710
+ "indigo",
3711
+ "--direction",
3712
+ "push",
3713
+ "--scope-path",
3714
+ "knowledge/deleted.md",
3715
+ "--hq-root",
3716
+ "/tmp/hq",
3717
+ ]);
3718
+
3719
+ shutdown();
3720
+ tick();
3721
+ await loop;
3722
+ });
3723
+
3724
+ it("omitted --direction is pull-only (parser default): a scoped tick never pushes", async () => {
3725
+ const runPass = vi.fn().mockResolvedValue(0);
3726
+ // No --direction flag → parseArgs defaults the run to pull-only. The drain
3727
+ // must honor that and NOT run a scoped push. Regression: the loop used to
3728
+ // re-parse argv and wrongly default an omitted direction to "both", so a
3729
+ // pull-only watcher would push local edits it should never send.
3730
+ const pullOnlyArgv = [
3731
+ "--companies",
3732
+ "--watch",
3733
+ "--event-push",
3734
+ "--hq-root",
3735
+ "/tmp/hq",
3736
+ "--poll-remote-ms",
3737
+ "60000",
3738
+ ];
3739
+ const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(
3740
+ runPass,
3741
+ pullOnlyArgv,
3742
+ );
3743
+ await flushLoopMicrotasks();
3744
+ runPass.mockClear();
3745
+
3746
+ watcher.emit("companies/indigo/knowledge/a.md");
3747
+ tick();
3748
+ await flushLoopMicrotasks();
3749
+
3750
+ const calls = runPass.mock.calls.map((call) => call[0] as string[]);
3751
+ // No scoped push of any kind ran...
3752
+ expect(calls.some((argv) => argv.includes("--company"))).toBe(false);
3753
+ expect(
3754
+ calls.some(
3755
+ (argv) =>
3756
+ argv.includes("--direction") &&
3757
+ argv[argv.indexOf("--direction") + 1] === "push",
3758
+ ),
3759
+ ).toBe(false);
3760
+ // ...only the pull leg ran.
3761
+ expect(calls).toEqual([fullPullArgv]);
3762
+
3763
+ shutdown();
3764
+ tick();
3765
+ await loop;
3766
+ });
3767
+
3768
+ it("restores failed scoped push paths so the next tick retries them", async () => {
3769
+ const runPass = vi
3770
+ .fn()
3771
+ .mockResolvedValueOnce(0)
3772
+ .mockResolvedValueOnce(1)
3773
+ .mockResolvedValueOnce(0)
3774
+ .mockResolvedValueOnce(0)
3775
+ .mockResolvedValueOnce(0);
3776
+ const { loop, watcher, tick, shutdown } = startDrainLoop(runPass);
3777
+ await flushLoopMicrotasks();
3778
+ runPass.mockClear();
3779
+
3780
+ watcher.emit("companies/indigo/knowledge/retry.md");
3781
+ tick();
3782
+ await flushLoopMicrotasks();
3783
+ tick();
3784
+ await flushLoopMicrotasks();
3785
+
3786
+ const pushCalls = runPass.mock.calls
3787
+ .map((call) => call[0] as string[])
3788
+ .filter((argv) => argv.includes("--company"));
3789
+ expect(pushCalls).toEqual([
3790
+ [
3791
+ "--company",
3792
+ "indigo",
3793
+ "--direction",
3794
+ "push",
3795
+ "--scope-path",
3796
+ "knowledge/retry.md",
3797
+ "--hq-root",
3798
+ "/tmp/hq",
3799
+ ],
3800
+ [
3801
+ "--company",
3802
+ "indigo",
3803
+ "--direction",
3804
+ "push",
3805
+ "--scope-path",
3806
+ "knowledge/retry.md",
3807
+ "--hq-root",
3808
+ "/tmp/hq",
3809
+ ],
3810
+ ]);
3811
+
3812
+ shutdown();
3813
+ tick();
3814
+ await loop;
3815
+ });
3816
+
3817
+ it("empty scoped ticks run only the pull leg", async () => {
3818
+ const runPass = vi.fn().mockResolvedValue(0);
3819
+ const { loop, tick, shutdown } = startDrainLoop(runPass);
3820
+ await flushLoopMicrotasks();
3821
+ runPass.mockClear();
3822
+
3823
+ tick();
3824
+ await flushLoopMicrotasks();
3825
+
3826
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullPullArgv]);
3827
+
3828
+ shutdown();
3829
+ tick();
3830
+ await loop;
3831
+ });
3832
+
3833
+ it("personal scoped ticks preserve personal mode for the pull leg", async () => {
3834
+ const runPass = vi.fn().mockResolvedValue(0);
3835
+ const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
3836
+ "--personal",
3837
+ "--watch",
3838
+ "--event-push",
3839
+ "--direction",
3840
+ "both",
3841
+ "--hq-root",
3842
+ "/tmp/hq",
3843
+ "--poll-remote-ms",
3844
+ "60000",
3845
+ ]);
3846
+ await flushLoopMicrotasks();
3847
+ runPass.mockClear();
3848
+
3849
+ watcher.emit("knowledge/a.md");
3850
+ tick();
3851
+ await flushLoopMicrotasks();
3852
+
3853
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([
3854
+ [
3855
+ "--personal",
3856
+ "--direction",
3857
+ "push",
3858
+ "--scope-path",
3859
+ "knowledge/a.md",
3860
+ "--hq-root",
3861
+ "/tmp/hq",
3862
+ ],
3863
+ ["--personal", "--direction", "pull", "--hq-root", "/tmp/hq"],
3864
+ ]);
3865
+
3866
+ shutdown();
3867
+ tick();
3868
+ await loop;
3869
+ });
3870
+
3871
+ it("single-company scoped ticks preserve company mode for the pull leg", async () => {
3872
+ const runPass = vi.fn().mockResolvedValue(0);
3873
+ const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
3874
+ "--company",
3875
+ "acme",
3876
+ "--watch",
3877
+ "--event-push",
3878
+ "--direction",
3879
+ "both",
3880
+ "--hq-root",
3881
+ "/tmp/hq",
3882
+ "--poll-remote-ms",
3883
+ "60000",
3884
+ ]);
3885
+ await flushLoopMicrotasks();
3886
+ runPass.mockClear();
3887
+
3888
+ watcher.emit("companies/acme/knowledge/a.md");
3889
+ tick();
3890
+ await flushLoopMicrotasks();
3891
+
3892
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([
3893
+ [
3894
+ "--company",
3895
+ "acme",
3896
+ "--direction",
3897
+ "push",
3898
+ "--scope-path",
3899
+ "knowledge/a.md",
3900
+ "--hq-root",
3901
+ "/tmp/hq",
3902
+ ],
3903
+ ["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
3904
+ ]);
3905
+
3906
+ shutdown();
3907
+ tick();
3908
+ await loop;
3909
+ });
3910
+
3911
+ it("push-only scoped ticks run the scoped push and no pull leg", async () => {
3912
+ const runPass = vi.fn().mockResolvedValue(0);
3913
+ const { loop, watcher, tick, shutdown } = startDrainLoopWithArgv(runPass, [
3914
+ "--companies",
3915
+ "--watch",
3916
+ "--event-push",
3917
+ "--direction",
3918
+ "push",
3919
+ "--hq-root",
3920
+ "/tmp/hq",
3921
+ "--poll-remote-ms",
3922
+ "60000",
3923
+ ]);
3924
+ await flushLoopMicrotasks();
3925
+ runPass.mockClear();
3926
+
3927
+ watcher.emit("companies/indigo/knowledge/a.md");
3928
+ tick();
3929
+ await flushLoopMicrotasks();
3930
+
3931
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([
3932
+ [
3933
+ "--company",
3934
+ "indigo",
3935
+ "--direction",
3936
+ "push",
3937
+ "--scope-path",
3938
+ "knowledge/a.md",
3939
+ "--hq-root",
3940
+ "/tmp/hq",
3941
+ ],
3942
+ ]);
3943
+
3944
+ shutdown();
3945
+ tick();
3946
+ await loop;
3947
+ });
3948
+
3949
+ it("overflow and every tenth tick run a full unscoped reconcile", async () => {
3950
+ const watcher = makeBatchWatcherStub();
3951
+ const sleep = makeSteppableSleep();
3952
+ let triggerShutdown = () => {};
3953
+ const runPass = vi.fn().mockResolvedValue(0);
3954
+ const loop = runRunnerWithLoop(watchArgv, {
3955
+ runPass,
3956
+ clock: new FakeClock(),
3957
+ createWatcher: () => watcher,
3958
+ sleep: sleep.sleep,
3959
+ onShutdownSignal: (handler) => {
3960
+ triggerShutdown = handler;
3961
+ return () => {};
3962
+ },
3963
+ });
3964
+ await flushLoopMicrotasks();
3965
+ runPass.mockClear();
3966
+
3967
+ watcher.emit("companies/indigo/a.md", {
3968
+ paths: new Map([["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"]]),
3969
+ overflowed: true,
3970
+ } as never);
3971
+ sleep.tick();
3972
+ await flushLoopMicrotasks();
3973
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
3974
+
3975
+ runPass.mockClear();
3976
+ for (let i = 0; i < 7; i++) {
3977
+ sleep.tick();
3978
+ await flushLoopMicrotasks();
3979
+ }
3980
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual(
3981
+ Array.from({ length: 7 }, () => fullPullArgv),
3982
+ );
3983
+
3984
+ runPass.mockClear();
3985
+ sleep.tick();
3986
+ await flushLoopMicrotasks();
3987
+ expect(runPass.mock.calls.map((call) => call[0])).toEqual([fullArgv]);
3988
+
3989
+ triggerShutdown();
3990
+ sleep.tick();
3991
+ await loop;
3992
+ });
3993
+ });
3994
+
3411
3995
  // ---------------------------------------------------------------------------
3412
3996
  // Re-initialize for each test (mock state hygiene)
3413
3997
  // ---------------------------------------------------------------------------
@@ -4303,14 +4887,26 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4303
4887
  const watcher = opts.watcher ?? makeBatchWatcherStub();
4304
4888
  const runPass = opts.runPass ?? vi.fn().mockResolvedValue(0);
4305
4889
  const clock = new FakeClock();
4890
+ const sleep = makeSteppableSleep();
4306
4891
  let triggerShutdown = () => {};
4307
4892
  const loop = runRunnerWithLoop(
4308
- ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
4893
+ // `--direction both` so the push leg (and therefore the publish-after-push
4894
+ // wiring these tests exercise) actually runs. Omitting --direction is a
4895
+ // pull-only run by the parser default, which correctly never pushes.
4896
+ [
4897
+ "--companies",
4898
+ "--watch",
4899
+ "--event-push",
4900
+ "--direction",
4901
+ "both",
4902
+ "--hq-root",
4903
+ "/tmp/hq",
4904
+ ],
4309
4905
  {
4310
4906
  runPass,
4311
4907
  clock,
4312
4908
  createWatcher: () => watcher,
4313
- sleep: () => new Promise<void>(() => {}),
4909
+ sleep: sleep.sleep,
4314
4910
  onShutdownSignal: (handler) => {
4315
4911
  triggerShutdown = handler;
4316
4912
  return () => {};
@@ -4321,7 +4917,14 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4321
4917
  vi.fn().mockResolvedValue(null)) as never,
4322
4918
  },
4323
4919
  );
4324
- return { loop, watcher, runPass, clock, shutdown: () => triggerShutdown() };
4920
+ return {
4921
+ loop,
4922
+ watcher,
4923
+ runPass,
4924
+ clock,
4925
+ tick: sleep.tick,
4926
+ shutdown: () => triggerShutdown(),
4927
+ };
4325
4928
  }
4326
4929
 
4327
4930
  async function microtasks(n = 6) {
@@ -4377,7 +4980,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4377
4980
  dispose: vi.fn().mockResolvedValue(undefined),
4378
4981
  });
4379
4982
  const runPass = vi.fn().mockResolvedValue(0);
4380
- const { loop, watcher, clock, shutdown } = runLoop({
4983
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
4381
4984
  claims: ENROLLED_CLAIMS,
4382
4985
  startEventSync,
4383
4986
  runPass,
@@ -4391,11 +4994,18 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4391
4994
  watcher.emit("companies/indigo/a.md", batch);
4392
4995
  clock.advance(0);
4393
4996
  await microtasks();
4997
+ expect(runPass).not.toHaveBeenCalled();
4998
+ expect(publishBatch).not.toHaveBeenCalled();
4999
+
5000
+ tick();
5001
+ await microtasks();
4394
5002
 
4395
- // Targeted push ran and succeeded → batch published.
5003
+ // Scoped push drained and succeeded → batch published.
4396
5004
  expect(runPass).toHaveBeenCalled();
4397
5005
  expect(publishBatch).toHaveBeenCalledTimes(1);
4398
- expect(publishBatch.mock.calls[0][0]).toBe(batch);
5006
+ expect([...publishBatch.mock.calls[0][0].paths.values()]).toEqual([
5007
+ "companies/indigo/a.md",
5008
+ ]);
4399
5009
 
4400
5010
  shutdown();
4401
5011
  await loop;
@@ -4414,7 +5024,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4414
5024
  .fn()
4415
5025
  .mockResolvedValueOnce(0)
4416
5026
  .mockResolvedValue(1);
4417
- const { loop, watcher, clock, shutdown } = runLoop({
5027
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
4418
5028
  claims: ENROLLED_CLAIMS,
4419
5029
  startEventSync,
4420
5030
  runPass,
@@ -4427,6 +5037,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4427
5037
  );
4428
5038
  clock.advance(0);
4429
5039
  await microtasks();
5040
+ tick();
5041
+ await microtasks();
4430
5042
 
4431
5043
  expect(publishBatch).not.toHaveBeenCalled();
4432
5044
  shutdown();
@@ -4442,7 +5054,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4442
5054
  dispose: vi.fn().mockResolvedValue(undefined),
4443
5055
  });
4444
5056
  const runPass = vi.fn().mockResolvedValue(0);
4445
- const { loop, watcher, clock, shutdown } = runLoop({
5057
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
4446
5058
  claims: ENROLLED_CLAIMS,
4447
5059
  startEventSync,
4448
5060
  runPass,
@@ -4459,6 +5071,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4459
5071
  });
4460
5072
  clock.advance(0);
4461
5073
  await microtasks();
5074
+ tick();
5075
+ await microtasks();
4462
5076
 
4463
5077
  const pushedRoutes = new Set<string>();
4464
5078
  let pushedEverything = false;
@@ -4469,6 +5083,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4469
5083
  const companyIdx = passArgv.indexOf("--company");
4470
5084
  if (companyIdx >= 0) {
4471
5085
  pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
5086
+ } else if (passArgv.includes("--personal")) {
5087
+ pushedRoutes.add("personal");
4472
5088
  } else if (passArgv.includes("--companies")) {
4473
5089
  pushedEverything = true;
4474
5090
  }
@@ -4510,6 +5126,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4510
5126
  | null = null;
4511
5127
  const watcher = makeBatchWatcherStub();
4512
5128
  const clock = new FakeClock();
5129
+ const sleep = makeSteppableSleep();
4513
5130
  let triggerShutdown = () => {};
4514
5131
  let releasePoll: () => void = () => {};
4515
5132
  const pollGate = new Promise<void>((resolve) => {
@@ -4523,7 +5140,17 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4523
5140
  });
4524
5141
 
4525
5142
  const loop = runRunnerWithLoop(
4526
- ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
5143
+ // `--direction both` so the queued watcher pushes actually run (an omitted
5144
+ // direction is pull-only by the parser default).
5145
+ [
5146
+ "--companies",
5147
+ "--watch",
5148
+ "--event-push",
5149
+ "--direction",
5150
+ "both",
5151
+ "--hq-root",
5152
+ "/tmp/hq",
5153
+ ],
4527
5154
  {
4528
5155
  runPass,
4529
5156
  clock,
@@ -4536,7 +5163,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4536
5163
  dispose: async () => {},
4537
5164
  };
4538
5165
  },
4539
- sleep: () => new Promise<void>(() => {}),
5166
+ sleep: sleep.sleep,
4540
5167
  onShutdownSignal: (handler) => {
4541
5168
  triggerShutdown = handler;
4542
5169
  return () => {};
@@ -4585,14 +5212,32 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4585
5212
  releasePoll();
4586
5213
  await remotePull;
4587
5214
  await microtasks();
4588
- clock.advance(0);
5215
+ sleep.tick();
4589
5216
  await microtasks();
4590
5217
 
4591
5218
  const passArgvs = runPass.mock.calls.map((call) => call[0] as string[]);
4592
5219
  expect(passArgvs).toEqual(
4593
5220
  expect.arrayContaining([
4594
- ["--company", "indigo", "--direction", "push", "--hq-root", "/tmp/hq"],
4595
- ["--company", "beta", "--direction", "push", "--hq-root", "/tmp/hq"],
5221
+ [
5222
+ "--company",
5223
+ "indigo",
5224
+ "--direction",
5225
+ "push",
5226
+ "--scope-path",
5227
+ "a.md",
5228
+ "--hq-root",
5229
+ "/tmp/hq",
5230
+ ],
5231
+ [
5232
+ "--company",
5233
+ "beta",
5234
+ "--direction",
5235
+ "push",
5236
+ "--scope-path",
5237
+ "b.md",
5238
+ "--hq-root",
5239
+ "/tmp/hq",
5240
+ ],
4596
5241
  ["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
4597
5242
  ]),
4598
5243
  );
@@ -4633,7 +5278,7 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4633
5278
  ownDeviceId: "dev-test",
4634
5279
  dispose: vi.fn().mockResolvedValue(undefined),
4635
5280
  });
4636
- const { loop, watcher, clock, shutdown } = runLoop({
5281
+ const { loop, watcher, clock, tick, shutdown } = runLoop({
4637
5282
  claims: ENROLLED_CLAIMS,
4638
5283
  startEventSync,
4639
5284
  });
@@ -4642,6 +5287,8 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4642
5287
  watcher.emit("companies/indigo/b.md"); // no batch
4643
5288
  clock.advance(0);
4644
5289
  await microtasks();
5290
+ tick();
5291
+ await microtasks();
4645
5292
 
4646
5293
  expect(publishBatch).toHaveBeenCalledTimes(1);
4647
5294
  const synthesized = publishBatch.mock.calls[0][0] as {