@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.
Files changed (33) hide show
  1. package/dist/bin/sync-runner-company.d.ts +1 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +41 -2
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +9 -1
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +85 -51
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner-watch-routes.d.ts +3 -0
  10. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-watch-routes.js +52 -1
  12. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  13. package/dist/bin/sync-runner.d.ts.map +1 -1
  14. package/dist/bin/sync-runner.js +19 -4
  15. package/dist/bin/sync-runner.js.map +1 -1
  16. package/dist/bin/sync-runner.test.js +666 -45
  17. package/dist/bin/sync-runner.test.js.map +1 -1
  18. package/dist/lib/net-errors.d.ts +36 -0
  19. package/dist/lib/net-errors.d.ts.map +1 -0
  20. package/dist/lib/net-errors.js +81 -0
  21. package/dist/lib/net-errors.js.map +1 -0
  22. package/dist/lib/net-errors.test.d.ts +2 -0
  23. package/dist/lib/net-errors.test.d.ts.map +1 -0
  24. package/dist/lib/net-errors.test.js +47 -0
  25. package/dist/lib/net-errors.test.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/bin/sync-runner-company.ts +61 -2
  28. package/src/bin/sync-runner-watch-loop.ts +116 -55
  29. package/src/bin/sync-runner-watch-routes.ts +57 -1
  30. package/src/bin/sync-runner.test.ts +774 -50
  31. package/src/bin/sync-runner.ts +23 -4
  32. package/src/lib/net-errors.test.ts +53 -0
  33. 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 debounced changed signal runs a targeted company push", async () => {
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
- const targetedCall = runPass.mock.calls.find((c) => c[0].includes("--company"));
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) routes the targeted push to the personal vault", async () => {
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
- const personalCall = runPass.mock.calls.find((c) => c[0].includes("--companies") &&
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 push is queued after the active poll pass", async () => {
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?.[0]).toEqual([
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(["--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
+ ], {
3374
3943
  runPass,
3375
3944
  clock,
3376
3945
  createWatcher: () => watcher,
3377
- sleep: () => new Promise(() => { }),
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 { loop, watcher, runPass, clock, shutdown: () => triggerShutdown() };
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
- // 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.
3453
4033
  expect(runPass).toHaveBeenCalled();
3454
4034
  expect(publishBatch).toHaveBeenCalledTimes(1);
3455
- expect(publishBatch.mock.calls[0][0]).toBe(batch);
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(["--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
+ ], {
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: () => new Promise(() => { }),
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
- clock.advance(0);
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
- ["--company", "indigo", "--direction", "push", "--hq-root", "/tmp/hq"],
3630
- ["--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
+ ],
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"]);