@indigoai-us/hq-cloud 6.11.10 → 6.11.12

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 (173) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +330 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/reindex.d.ts.map +1 -1
  8. package/dist/cli/reindex.js +16 -1
  9. package/dist/cli/reindex.js.map +1 -1
  10. package/dist/cli/reindex.test.js +39 -1
  11. package/dist/cli/reindex.test.js.map +1 -1
  12. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  13. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  14. package/dist/cli/rescue-core.js +229 -15
  15. package/dist/cli/rescue-core.js.map +1 -1
  16. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
  17. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
  18. package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
  19. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
  20. package/dist/cli/share.d.ts +2 -1
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +100 -32
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +30 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts +28 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +188 -59
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/cli/sync.test.js +487 -1
  31. package/dist/cli/sync.test.js.map +1 -1
  32. package/dist/cognito-auth.d.ts.map +1 -1
  33. package/dist/cognito-auth.js +55 -10
  34. package/dist/cognito-auth.js.map +1 -1
  35. package/dist/cognito-auth.test.js +61 -0
  36. package/dist/cognito-auth.test.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +93 -6
  43. package/dist/journal.js.map +1 -1
  44. package/dist/journal.test.js +59 -0
  45. package/dist/journal.test.js.map +1 -1
  46. package/dist/machine-auth.test.js +60 -2
  47. package/dist/machine-auth.test.js.map +1 -1
  48. package/dist/object-io.d.ts +37 -1
  49. package/dist/object-io.d.ts.map +1 -1
  50. package/dist/object-io.js +148 -29
  51. package/dist/object-io.js.map +1 -1
  52. package/dist/object-io.test.js +121 -0
  53. package/dist/object-io.test.js.map +1 -1
  54. package/dist/operation-lock.d.ts +8 -8
  55. package/dist/operation-lock.d.ts.map +1 -1
  56. package/dist/operation-lock.js +99 -32
  57. package/dist/operation-lock.js.map +1 -1
  58. package/dist/operation-lock.test.js +51 -4
  59. package/dist/operation-lock.test.js.map +1 -1
  60. package/dist/personal-vault.d.ts +8 -0
  61. package/dist/personal-vault.d.ts.map +1 -1
  62. package/dist/personal-vault.js +17 -3
  63. package/dist/personal-vault.js.map +1 -1
  64. package/dist/personal-vault.test.js +34 -0
  65. package/dist/personal-vault.test.js.map +1 -1
  66. package/dist/prefix-coalesce.d.ts +20 -9
  67. package/dist/prefix-coalesce.d.ts.map +1 -1
  68. package/dist/prefix-coalesce.js +124 -28
  69. package/dist/prefix-coalesce.js.map +1 -1
  70. package/dist/prefix-coalesce.test.js +57 -2
  71. package/dist/prefix-coalesce.test.js.map +1 -1
  72. package/dist/remote-pull.d.ts +6 -1
  73. package/dist/remote-pull.d.ts.map +1 -1
  74. package/dist/remote-pull.js +62 -13
  75. package/dist/remote-pull.js.map +1 -1
  76. package/dist/remote-pull.test.js +189 -0
  77. package/dist/remote-pull.test.js.map +1 -1
  78. package/dist/s3.d.ts +2 -0
  79. package/dist/s3.d.ts.map +1 -1
  80. package/dist/s3.js +197 -116
  81. package/dist/s3.js.map +1 -1
  82. package/dist/s3.test.js +109 -0
  83. package/dist/s3.test.js.map +1 -1
  84. package/dist/scope-shrink.d.ts +3 -2
  85. package/dist/scope-shrink.d.ts.map +1 -1
  86. package/dist/scope-shrink.js +1 -1
  87. package/dist/scope-shrink.js.map +1 -1
  88. package/dist/skill-telemetry.d.ts +1 -1
  89. package/dist/skill-telemetry.d.ts.map +1 -1
  90. package/dist/skill-telemetry.js +69 -9
  91. package/dist/skill-telemetry.js.map +1 -1
  92. package/dist/skill-telemetry.test.js +86 -0
  93. package/dist/skill-telemetry.test.js.map +1 -1
  94. package/dist/sync/event-sync.d.ts +6 -0
  95. package/dist/sync/event-sync.d.ts.map +1 -1
  96. package/dist/sync/event-sync.js +34 -1
  97. package/dist/sync/event-sync.js.map +1 -1
  98. package/dist/sync/event-sync.test.js +73 -0
  99. package/dist/sync/event-sync.test.js.map +1 -1
  100. package/dist/sync/metrics.d.ts +17 -1
  101. package/dist/sync/metrics.d.ts.map +1 -1
  102. package/dist/sync/metrics.js +32 -1
  103. package/dist/sync/metrics.js.map +1 -1
  104. package/dist/sync/metrics.test.js +74 -1
  105. package/dist/sync/metrics.test.js.map +1 -1
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +15 -7
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/sync/push-receiver.d.ts +6 -5
  110. package/dist/sync/push-receiver.d.ts.map +1 -1
  111. package/dist/sync/push-receiver.js +13 -15
  112. package/dist/sync/push-receiver.js.map +1 -1
  113. package/dist/sync/push-receiver.test.js +36 -1
  114. package/dist/sync/push-receiver.test.js.map +1 -1
  115. package/dist/telemetry.d.ts +1 -1
  116. package/dist/telemetry.d.ts.map +1 -1
  117. package/dist/telemetry.js +59 -6
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/telemetry.test.js +74 -0
  120. package/dist/telemetry.test.js.map +1 -1
  121. package/dist/types.d.ts +8 -0
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/watcher.d.ts +36 -0
  124. package/dist/watcher.d.ts.map +1 -1
  125. package/dist/watcher.js +152 -30
  126. package/dist/watcher.js.map +1 -1
  127. package/dist/watcher.test.js +103 -0
  128. package/dist/watcher.test.js.map +1 -1
  129. package/package.json +1 -1
  130. package/src/bin/sync-runner.test.ts +396 -11
  131. package/src/bin/sync-runner.ts +254 -52
  132. package/src/cli/reindex.test.ts +47 -1
  133. package/src/cli/reindex.ts +17 -1
  134. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  135. package/src/cli/rescue-core.ts +261 -15
  136. package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
  137. package/src/cli/share.test.ts +38 -0
  138. package/src/cli/share.ts +103 -34
  139. package/src/cli/sync.test.ts +594 -1
  140. package/src/cli/sync.ts +229 -65
  141. package/src/cognito-auth.test.ts +77 -0
  142. package/src/cognito-auth.ts +73 -11
  143. package/src/index.ts +8 -0
  144. package/src/journal.test.ts +72 -0
  145. package/src/journal.ts +95 -8
  146. package/src/machine-auth.test.ts +64 -2
  147. package/src/object-io.test.ts +142 -0
  148. package/src/object-io.ts +182 -30
  149. package/src/operation-lock.test.ts +63 -4
  150. package/src/operation-lock.ts +99 -31
  151. package/src/personal-vault.test.ts +42 -0
  152. package/src/personal-vault.ts +18 -3
  153. package/src/prefix-coalesce.test.ts +71 -1
  154. package/src/prefix-coalesce.ts +155 -30
  155. package/src/remote-pull.test.ts +205 -0
  156. package/src/remote-pull.ts +77 -14
  157. package/src/s3.test.ts +126 -0
  158. package/src/s3.ts +237 -122
  159. package/src/scope-shrink.ts +6 -3
  160. package/src/skill-telemetry.test.ts +109 -0
  161. package/src/skill-telemetry.ts +82 -14
  162. package/src/sync/event-sync.test.ts +75 -0
  163. package/src/sync/event-sync.ts +54 -1
  164. package/src/sync/metrics.test.ts +81 -0
  165. package/src/sync/metrics.ts +59 -4
  166. package/src/sync/pull-scope.ts +23 -7
  167. package/src/sync/push-receiver.test.ts +38 -1
  168. package/src/sync/push-receiver.ts +15 -18
  169. package/src/telemetry.test.ts +85 -0
  170. package/src/telemetry.ts +69 -6
  171. package/src/types.ts +8 -0
  172. package/src/watcher.test.ts +117 -0
  173. package/src/watcher.ts +209 -33
@@ -42,6 +42,7 @@ import type {
42
42
  PendingInviteByEmail,
43
43
  } from "../vault-client.js";
44
44
  import { VaultAuthError } from "../vault-client.js";
45
+ import type { PushEvent } from "../sync/push-event.js";
45
46
 
46
47
  // ---------------------------------------------------------------------------
47
48
  // Hermetic journal state — the runner now calls migratePersonalVaultJournal()
@@ -1008,7 +1009,7 @@ describe("per-company fanout", () => {
1008
1009
  ]);
1009
1010
  });
1010
1011
 
1011
- it("tags per-file error events with the company slug", async () => {
1012
+ it("F09: per-file transfer errors make the run partial and non-zero", async () => {
1012
1013
  const deps = makeDeps({
1013
1014
  createVaultClient: () =>
1014
1015
  makeVaultStub({
@@ -1027,7 +1028,7 @@ describe("per-company fanout", () => {
1027
1028
  });
1028
1029
 
1029
1030
  const code = await runRunner(["--companies"], deps);
1030
- expect(code).toBe(0);
1031
+ expect(code).toBe(2);
1031
1032
  // Per-file errors are routed to stderr (error-class events).
1032
1033
  const errs = deps.stderr
1033
1034
  .events()
@@ -1042,6 +1043,14 @@ describe("per-company fanout", () => {
1042
1043
  message: "access denied",
1043
1044
  },
1044
1045
  ]);
1046
+ const all = deps.stdout
1047
+ .events()
1048
+ .find(
1049
+ (e): e is Extract<RunnerEvent, { type: "all-complete" }> =>
1050
+ e.type === "all-complete",
1051
+ );
1052
+ expect(all).toBeDefined();
1053
+ expect(all?.partial).toBe(true);
1045
1054
  });
1046
1055
 
1047
1056
  it("emits complete event per company with the SyncResult spread", async () => {
@@ -2182,6 +2191,104 @@ describe("personal slot fanout", () => {
2182
2191
  }
2183
2192
  });
2184
2193
 
2194
+ it("E2: personal slot pierces the workspace/ exclusion for the session-continuity pointer + active thread file, while the rest of workspace/ stays local (DEV-1778)", async () => {
2195
+ // Regression guard for the cross-machine session-handoff carve-out. Test
2196
+ // "E" proves `workspace/` is excluded wholesale; this proves the ONE
2197
+ // exception travels through the REAL runner push composition (not just the
2198
+ // pure computeContinuityPointerPaths helper): `/handoff` writes
2199
+ // workspace/threads/handoff.json + the thread file it points to, and both
2200
+ // must reach share() so a session handed off on machine A resumes on
2201
+ // machine B. Everything else under workspace/ (locks, other threads, the
2202
+ // INDEX) must remain machine-local.
2203
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2204
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
2205
+ try {
2206
+ // A normal included top-level dir, so the personal push is non-empty
2207
+ // regardless of the carve-out.
2208
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
2209
+
2210
+ // The session-continuity carve-out: handoff.json + the active thread it
2211
+ // references via `thread_path` (hq-root-relative, as handoff-finalize.sh
2212
+ // writes it).
2213
+ const threadsDir = path.join(tmpHqRoot, "workspace", "threads");
2214
+ fs.mkdirSync(threadsDir, { recursive: true });
2215
+ const activeThreadRel = "workspace/threads/T-20260619-1200-resume-me.json";
2216
+ fs.writeFileSync(
2217
+ path.join(tmpHqRoot, activeThreadRel),
2218
+ JSON.stringify({ conversation_summary: "pick up here" }),
2219
+ );
2220
+ fs.writeFileSync(
2221
+ path.join(threadsDir, "handoff.json"),
2222
+ JSON.stringify({ thread_path: activeThreadRel }),
2223
+ );
2224
+
2225
+ // The rest of workspace/ that must NOT travel: a lock, an inactive
2226
+ // thread, and the regenerated index.
2227
+ fs.mkdirSync(path.join(tmpHqRoot, "workspace", "locks"), {
2228
+ recursive: true,
2229
+ });
2230
+ fs.writeFileSync(
2231
+ path.join(tmpHqRoot, "workspace", "locks", "sync.lock"),
2232
+ "1",
2233
+ );
2234
+ fs.writeFileSync(
2235
+ path.join(threadsDir, "T-20260101-0000-stale.json"),
2236
+ "{}",
2237
+ );
2238
+ fs.writeFileSync(path.join(threadsDir, "INDEX.md"), "# threads");
2239
+
2240
+ const deps = makeDeps({
2241
+ createVaultClient: () =>
2242
+ makeVaultStub({
2243
+ memberships: [{ companyUid: "cmp_a" }],
2244
+ entityGet: (uid: string) =>
2245
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2246
+ listPersons: () => Promise.resolve([olderPerson]),
2247
+ }),
2248
+ share: shareSpy,
2249
+ });
2250
+
2251
+ const code = await runRunner(
2252
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
2253
+ deps,
2254
+ );
2255
+ expect(code).toBe(0);
2256
+
2257
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
2258
+ (c) => c[0].company?.startsWith("prs_"),
2259
+ );
2260
+ expect(personalCall).toBeDefined();
2261
+ const personalArgs = personalCall![0];
2262
+
2263
+ // Compare as hq-root-relative, forward-slash paths (the namespace the
2264
+ // continuity carve-out is specified in).
2265
+ const relPaths = personalArgs.paths.map((p) =>
2266
+ path.relative(tmpHqRoot, p).split(path.sep).join("/"),
2267
+ );
2268
+
2269
+ // The two continuity files MUST be in the push set.
2270
+ expect(relPaths).toContain("workspace/threads/handoff.json");
2271
+ expect(relPaths).toContain(activeThreadRel);
2272
+
2273
+ // The rest of workspace/ must NOT leak in — neither as the parent dir
2274
+ // nor as the individual machine-local files.
2275
+ expect(relPaths).not.toContain("workspace");
2276
+ expect(relPaths).not.toContain("workspace/locks");
2277
+ expect(
2278
+ relPaths.some(
2279
+ (p) => p === "workspace/locks" || p.startsWith("workspace/locks/"),
2280
+ ),
2281
+ ).toBe(false);
2282
+ expect(relPaths).not.toContain("workspace/threads/T-20260101-0000-stale.json");
2283
+ expect(relPaths).not.toContain("workspace/threads/INDEX.md");
2284
+ // The bare threads dir is never handed in wholesale — only the two
2285
+ // explicit files (otherwise share()'s walk would sweep stale threads).
2286
+ expect(relPaths).not.toContain("workspace/threads");
2287
+ } finally {
2288
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
2289
+ }
2290
+ });
2291
+
2185
2292
  it("G: personal slot includes companies/{slug}/ subdirs when cloud:false marker is set AND HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1, excluding _template + team-synced slugs + missing/cloud:true markers", async () => {
2186
2293
  // Gate the new behavior: without this env var the runner falls back to
2187
2294
  // the legacy "companies/ never enumerated" personal vault. Test H below
@@ -2958,6 +3065,73 @@ describe("runRunnerWithLoop — event-push wiring", () => {
2958
3065
  await loop;
2959
3066
  });
2960
3067
 
3068
+ it("F05: guard-held watcher push is queued after the active poll pass", async () => {
3069
+ const watcher = makeWatcherStub();
3070
+ const clock = new FakeClock();
3071
+ let triggerShutdown = () => {};
3072
+
3073
+ let releasePoll: () => void = () => {};
3074
+ const pollGate = new Promise<void>((resolve) => {
3075
+ releasePoll = resolve;
3076
+ });
3077
+ let active = 0;
3078
+ let maxConcurrent = 0;
3079
+ let calls = 0;
3080
+ const runPass = vi.fn().mockImplementation(async () => {
3081
+ active++;
3082
+ maxConcurrent = Math.max(maxConcurrent, active);
3083
+ calls++;
3084
+ if (calls === 1) await pollGate;
3085
+ active--;
3086
+ return 0;
3087
+ });
3088
+
3089
+ const loop = runRunnerWithLoop(
3090
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
3091
+ {
3092
+ runPass,
3093
+ clock,
3094
+ createWatcher: () => watcher,
3095
+ sleep: () => new Promise<void>(() => {}),
3096
+ onShutdownSignal: (handler) => {
3097
+ triggerShutdown = handler;
3098
+ return () => {};
3099
+ },
3100
+ },
3101
+ );
3102
+
3103
+ await Promise.resolve();
3104
+ await Promise.resolve();
3105
+ expect(active).toBe(1);
3106
+
3107
+ watcher.emit("companies/indigo/queued.md");
3108
+ clock.advance(0);
3109
+ await Promise.resolve();
3110
+ await Promise.resolve();
3111
+ expect(maxConcurrent).toBe(1);
3112
+
3113
+ releasePoll();
3114
+ for (let i = 0; i < 8; i++) await Promise.resolve();
3115
+ clock.advance(0);
3116
+ for (let i = 0; i < 8; i++) await Promise.resolve();
3117
+
3118
+ triggerShutdown();
3119
+ await loop;
3120
+
3121
+ expect(maxConcurrent).toBe(1);
3122
+ const targetedCall = runPass.mock.calls.find((c) =>
3123
+ (c[0] as string[]).includes("--company"),
3124
+ );
3125
+ expect(targetedCall?.[0]).toEqual([
3126
+ "--company",
3127
+ "indigo",
3128
+ "--direction",
3129
+ "push",
3130
+ "--hq-root",
3131
+ "/tmp/hq",
3132
+ ]);
3133
+ });
3134
+
2961
3135
  it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
2962
3136
  const watcher = makeWatcherStub();
2963
3137
  let triggerShutdown = () => {};
@@ -3715,7 +3889,7 @@ describe("readPinnedPrefixes", () => {
3715
3889
  });
3716
3890
 
3717
3891
  // ---------------------------------------------------------------------------
3718
- // Operation lock — one-shot sync takes it; the watch runner is exempt.
3892
+ // Operation lock — sync operations serialize per root.
3719
3893
  // ---------------------------------------------------------------------------
3720
3894
  describe("runRunnerWithLoop — operation lock", () => {
3721
3895
  const HQ = "/tmp/hq-oplock";
@@ -3833,14 +4007,29 @@ describe("runRunnerWithLoop — operation lock", () => {
3833
4007
  expect(out).toContain("reindex");
3834
4008
  }, 20_000);
3835
4009
 
3836
- it("the watch runner is EXEMPT runs despite a held lock and never takes it", async () => {
4010
+ it("F03: watch runner refuses when another operation holds the root lock", async () => {
3837
4011
  const lp = writeLiveHolder("sync");
3838
4012
  const watcher = makeWatcherStub();
3839
4013
  let triggerShutdown = () => {};
3840
4014
  const runPass = vi.fn().mockResolvedValue(0);
4015
+ const errs: string[] = [];
4016
+ const spy = vi
4017
+ .spyOn(process.stderr, "write")
4018
+ .mockImplementation((chunk: string | Uint8Array) => {
4019
+ errs.push(String(chunk));
4020
+ return true;
4021
+ });
3841
4022
 
3842
4023
  const loop = runRunnerWithLoop(
3843
- ["--companies", "--watch", "--event-push", "--hq-root", HQ],
4024
+ [
4025
+ "--companies",
4026
+ "--watch",
4027
+ "--event-push",
4028
+ "--hq-root",
4029
+ HQ,
4030
+ "--lock-timeout",
4031
+ "0",
4032
+ ],
3844
4033
  {
3845
4034
  runPass,
3846
4035
  clock: new FakeClock(),
@@ -3855,14 +4044,18 @@ describe("runRunnerWithLoop — operation lock", () => {
3855
4044
 
3856
4045
  await Promise.resolve();
3857
4046
  await Promise.resolve();
3858
- // It started and ran a pass even though the lock is held → not blocked.
3859
- expect(watcher.started).toBe(true);
3860
- expect(runPass).toHaveBeenCalled();
4047
+ if (watcher.started || runPass.mock.calls.length > 0) {
4048
+ triggerShutdown();
4049
+ }
4050
+ const code = await loop;
4051
+ spy.mockRestore();
3861
4052
 
3862
- triggerShutdown();
3863
- await loop;
4053
+ expect(code).toBe(OPERATION_LOCKED_EXIT);
4054
+ expect(watcher.started).toBe(false);
4055
+ expect(runPass).not.toHaveBeenCalled();
4056
+ expect(errs.join("")).toContain("sync");
3864
4057
 
3865
- // The pre-existing holder lock is untouched → the watcher never took it.
4058
+ // The pre-existing holder lock is untouched.
3866
4059
  const held = JSON.parse(fs.readFileSync(lp, "utf8"));
3867
4060
  expect(held.pid).toBe(1);
3868
4061
  expect(held.command).toBe("sync");
@@ -4049,6 +4242,198 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
4049
4242
  await loop;
4050
4243
  });
4051
4244
 
4245
+ it("F04: mixed-route watcher batches publish only routes that were pushed", async () => {
4246
+ const publishBatch = vi.fn();
4247
+ const startEventSync = vi.fn().mockResolvedValue({
4248
+ publishBatch,
4249
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
4250
+ ownDeviceId: "dev-test",
4251
+ dispose: vi.fn().mockResolvedValue(undefined),
4252
+ });
4253
+ const runPass = vi.fn().mockResolvedValue(0);
4254
+ const { loop, watcher, clock, shutdown } = runLoop({
4255
+ claims: ENROLLED_CLAIMS,
4256
+ startEventSync,
4257
+ runPass,
4258
+ });
4259
+ await microtasks();
4260
+ runPass.mockClear();
4261
+
4262
+ watcher.emit("companies/indigo/a.md", {
4263
+ paths: new Map([
4264
+ ["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"],
4265
+ ["/tmp/hq/companies/acme/b.md", "companies/acme/b.md"],
4266
+ ["/tmp/hq/notes/personal.md", "notes/personal.md"],
4267
+ ]),
4268
+ });
4269
+ clock.advance(0);
4270
+ await microtasks();
4271
+
4272
+ const pushedRoutes = new Set<string>();
4273
+ let pushedEverything = false;
4274
+ for (const call of runPass.mock.calls) {
4275
+ const passArgv = call[0] as string[];
4276
+ const directionIdx = passArgv.indexOf("--direction");
4277
+ if (passArgv[directionIdx + 1] !== "push") continue;
4278
+ const companyIdx = passArgv.indexOf("--company");
4279
+ if (companyIdx >= 0) {
4280
+ pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
4281
+ } else if (passArgv.includes("--companies")) {
4282
+ pushedEverything = true;
4283
+ }
4284
+ }
4285
+ const publishedRelPaths = publishBatch.mock.calls.flatMap((call) => {
4286
+ const batch = call[0] as { paths: Map<string, string> };
4287
+ return [...batch.paths.values()];
4288
+ });
4289
+ const publishedWithoutPush = publishedRelPaths.filter((relPath) => {
4290
+ if (pushedEverything) return false;
4291
+ const route = routeChangeToTarget(relPath);
4292
+ if (!route) return true;
4293
+ if (route.kind === "company") {
4294
+ return !pushedRoutes.has(`company:${route.slug}`);
4295
+ }
4296
+ return !pushedRoutes.has("personal");
4297
+ });
4298
+
4299
+ expect(publishedRelPaths).toContain("companies/indigo/a.md");
4300
+ expect(publishedWithoutPush).toEqual([]);
4301
+
4302
+ shutdown();
4303
+ await loop;
4304
+ });
4305
+
4306
+ it("R-F04F05: preserves queued watcher push targets across overlap", async () => {
4307
+ const publishBatch = vi.fn();
4308
+ const startEventSync = vi.fn().mockResolvedValue({
4309
+ publishBatch,
4310
+ receiver: { start: async () => {}, dispose: async () => {}, connected: true },
4311
+ ownDeviceId: "dev-test",
4312
+ dispose: vi.fn().mockResolvedValue(undefined),
4313
+ });
4314
+ let receiverSync:
4315
+ | ((ctx: {
4316
+ event: PushEvent;
4317
+ signal: AbortSignal;
4318
+ }) => Promise<void>)
4319
+ | null = null;
4320
+ const watcher = makeBatchWatcherStub();
4321
+ const clock = new FakeClock();
4322
+ let triggerShutdown = () => {};
4323
+ let releasePoll: () => void = () => {};
4324
+ const pollGate = new Promise<void>((resolve) => {
4325
+ releasePoll = resolve;
4326
+ });
4327
+ let passCount = 0;
4328
+ const runPass = vi.fn().mockImplementation(async () => {
4329
+ passCount += 1;
4330
+ if (passCount === 1) await pollGate;
4331
+ return 0;
4332
+ });
4333
+
4334
+ const loop = runRunnerWithLoop(
4335
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
4336
+ {
4337
+ runPass,
4338
+ clock,
4339
+ createWatcher: () => watcher,
4340
+ createReceiver: ({ syncFn }) => {
4341
+ receiverSync = syncFn;
4342
+ return {
4343
+ connected: true,
4344
+ start: async () => {},
4345
+ dispose: async () => {},
4346
+ };
4347
+ },
4348
+ sleep: () => new Promise<void>(() => {}),
4349
+ onShutdownSignal: (handler) => {
4350
+ triggerShutdown = handler;
4351
+ return () => {};
4352
+ },
4353
+ getIdTokenClaims: () => ENROLLED_CLAIMS as never,
4354
+ getAccessToken: async () => "jwt-test",
4355
+ startEventSync,
4356
+ },
4357
+ );
4358
+ await microtasks();
4359
+ expect(passCount).toBe(1);
4360
+ expect(receiverSync).toBeTruthy();
4361
+
4362
+ const indigoBatch = {
4363
+ paths: new Map([
4364
+ ["/tmp/hq/companies/indigo/a.md", "companies/indigo/a.md"],
4365
+ ]),
4366
+ };
4367
+ const betaBatch = {
4368
+ paths: new Map([
4369
+ ["/tmp/hq/companies/beta/b.md", "companies/beta/b.md"],
4370
+ ]),
4371
+ };
4372
+ watcher.emit("companies/indigo/a.md", indigoBatch);
4373
+ clock.advance(0);
4374
+ await microtasks();
4375
+ watcher.emit("companies/beta/b.md", betaBatch);
4376
+ clock.advance(0);
4377
+ await microtasks();
4378
+
4379
+ const remotePull = receiverSync!({
4380
+ event: {
4381
+ kind: "upsert",
4382
+ relativePath: "companies/acme/remote.md",
4383
+ contentHash: "sha256:remote",
4384
+ mtime: "2026-06-18T12:00:00.000Z",
4385
+ originDeviceId: "peer-device",
4386
+ originTenantId: "tenant-acme",
4387
+ sequenceNumber: 7,
4388
+ eventTimestamp: "2026-06-18T12:00:00.000Z",
4389
+ },
4390
+ signal: new AbortController().signal,
4391
+ });
4392
+ await microtasks();
4393
+
4394
+ releasePoll();
4395
+ await remotePull;
4396
+ await microtasks();
4397
+ clock.advance(0);
4398
+ await microtasks();
4399
+
4400
+ const passArgvs = runPass.mock.calls.map((call) => call[0] as string[]);
4401
+ expect(passArgvs).toEqual(
4402
+ expect.arrayContaining([
4403
+ ["--company", "indigo", "--direction", "push", "--hq-root", "/tmp/hq"],
4404
+ ["--company", "beta", "--direction", "push", "--hq-root", "/tmp/hq"],
4405
+ ["--company", "acme", "--direction", "pull", "--hq-root", "/tmp/hq"],
4406
+ ]),
4407
+ );
4408
+
4409
+ const pushedRoutes = new Set<string>();
4410
+ for (const passArgv of passArgvs) {
4411
+ const directionIdx = passArgv.indexOf("--direction");
4412
+ if (passArgv[directionIdx + 1] !== "push") continue;
4413
+ const companyIdx = passArgv.indexOf("--company");
4414
+ if (companyIdx >= 0) pushedRoutes.add(`company:${passArgv[companyIdx + 1]}`);
4415
+ }
4416
+ const publishedRelPaths = publishBatch.mock.calls.flatMap((call) => {
4417
+ const batch = call[0] as { paths: Map<string, string> };
4418
+ return [...batch.paths.values()];
4419
+ });
4420
+ const publishedWithoutPush = publishedRelPaths.filter((relPath) => {
4421
+ const route = routeChangeToTarget(relPath);
4422
+ return route?.kind !== "company" || !pushedRoutes.has(`company:${route.slug}`);
4423
+ });
4424
+
4425
+ expect(publishedRelPaths).toEqual(
4426
+ expect.arrayContaining([
4427
+ "companies/indigo/a.md",
4428
+ "companies/beta/b.md",
4429
+ ]),
4430
+ );
4431
+ expect(publishedWithoutPush).toEqual([]);
4432
+
4433
+ triggerShutdown();
4434
+ await loop;
4435
+ });
4436
+
4052
4437
  it("falls back to a synthesized single-path batch when the watcher emits a bare path", async () => {
4053
4438
  const publishBatch = vi.fn();
4054
4439
  const startEventSync = vi.fn().mockResolvedValue({