@indigoai-us/hq-cloud 6.14.49 → 6.15.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 (126) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/reindex-knowledge.d.ts +7 -0
  20. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  21. package/dist/cli/reindex-knowledge.js +389 -0
  22. package/dist/cli/reindex-knowledge.js.map +1 -0
  23. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  24. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  25. package/dist/cli/reindex-knowledge.test.js +260 -0
  26. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  27. package/dist/cli/reindex.d.ts.map +1 -1
  28. package/dist/cli/reindex.js +8 -0
  29. package/dist/cli/reindex.js.map +1 -1
  30. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  31. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  32. package/dist/cli/rescue-core.d.ts +85 -0
  33. package/dist/cli/rescue-core.d.ts.map +1 -1
  34. package/dist/cli/rescue-core.js +390 -1
  35. package/dist/cli/rescue-core.js.map +1 -1
  36. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  37. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  38. package/dist/cli/rescue-settings-reconcile.test.js +523 -0
  39. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/journal.d.ts.map +1 -1
  45. package/dist/journal.js +81 -51
  46. package/dist/journal.js.map +1 -1
  47. package/dist/journal.test.js +11 -19
  48. package/dist/journal.test.js.map +1 -1
  49. package/dist/sync/event-sync.d.ts +8 -25
  50. package/dist/sync/event-sync.d.ts.map +1 -1
  51. package/dist/sync/event-sync.js +23 -48
  52. package/dist/sync/event-sync.js.map +1 -1
  53. package/dist/sync/event-sync.test.js +6 -32
  54. package/dist/sync/event-sync.test.js.map +1 -1
  55. package/dist/sync/feature-flags.d.ts +20 -117
  56. package/dist/sync/feature-flags.d.ts.map +1 -1
  57. package/dist/sync/feature-flags.js +24 -147
  58. package/dist/sync/feature-flags.js.map +1 -1
  59. package/dist/sync/feature-flags.test.js +15 -61
  60. package/dist/sync/feature-flags.test.js.map +1 -1
  61. package/dist/sync/index.d.ts +1 -1
  62. package/dist/sync/index.d.ts.map +1 -1
  63. package/dist/sync/index.js +1 -1
  64. package/dist/sync/index.js.map +1 -1
  65. package/dist/sync/lease-client.d.ts +67 -0
  66. package/dist/sync/lease-client.d.ts.map +1 -0
  67. package/dist/sync/lease-client.js +158 -0
  68. package/dist/sync/lease-client.js.map +1 -0
  69. package/dist/sync/lease-client.test.d.ts +2 -0
  70. package/dist/sync/lease-client.test.d.ts.map +1 -0
  71. package/dist/sync/lease-client.test.js +119 -0
  72. package/dist/sync/lease-client.test.js.map +1 -0
  73. package/dist/sync/push-receiver.d.ts +8 -19
  74. package/dist/sync/push-receiver.d.ts.map +1 -1
  75. package/dist/sync/push-receiver.js +6 -50
  76. package/dist/sync/push-receiver.js.map +1 -1
  77. package/dist/sync/push-receiver.test.js +6 -57
  78. package/dist/sync/push-receiver.test.js.map +1 -1
  79. package/dist/sync/realtime-rollout.d.ts +85 -0
  80. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  81. package/dist/sync/realtime-rollout.js +206 -0
  82. package/dist/sync/realtime-rollout.js.map +1 -0
  83. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  84. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  85. package/dist/sync/realtime-rollout.test.js +71 -0
  86. package/dist/sync/realtime-rollout.test.js.map +1 -0
  87. package/dist/sync/state-store.d.ts +40 -0
  88. package/dist/sync/state-store.d.ts.map +1 -0
  89. package/dist/sync/state-store.js +680 -0
  90. package/dist/sync/state-store.js.map +1 -0
  91. package/dist/sync/state-store.test.d.ts +2 -0
  92. package/dist/sync/state-store.test.d.ts.map +1 -0
  93. package/dist/sync/state-store.test.js +159 -0
  94. package/dist/sync/state-store.test.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/bin/sync-runner-watch-loop.ts +176 -32
  99. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  100. package/src/bin/sync-runner-watch-routes.ts +27 -0
  101. package/src/bin/sync-runner.test.ts +188 -53
  102. package/src/bin/sync-runner.ts +11 -12
  103. package/src/cli/reindex-knowledge.test.ts +307 -0
  104. package/src/cli/reindex-knowledge.ts +450 -0
  105. package/src/cli/reindex.ts +9 -0
  106. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  107. package/src/cli/rescue-core.ts +459 -1
  108. package/src/cli/rescue-settings-reconcile.test.ts +637 -0
  109. package/src/index.ts +2 -4
  110. package/src/journal.test.ts +19 -21
  111. package/src/journal.ts +87 -54
  112. package/src/sync/event-sync.test.ts +9 -44
  113. package/src/sync/event-sync.ts +26 -52
  114. package/src/sync/feature-flags.test.ts +22 -72
  115. package/src/sync/feature-flags.ts +32 -199
  116. package/src/sync/index.ts +2 -4
  117. package/src/sync/lease-client.test.ts +128 -0
  118. package/src/sync/lease-client.ts +207 -0
  119. package/src/sync/push-receiver.test.ts +6 -58
  120. package/src/sync/push-receiver.ts +9 -74
  121. package/src/sync/realtime-rollout.test.ts +86 -0
  122. package/src/sync/realtime-rollout.ts +262 -0
  123. package/src/sync/state-store.test.ts +194 -0
  124. package/src/sync/state-store.ts +727 -0
  125. package/src/types.ts +14 -0
  126. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -1,6 +1,7 @@
1
1
  import * as os from "os";
2
2
  import * as path from "path";
3
3
  import * as fs from "fs";
4
+ import { performance } from "node:perf_hooks";
4
5
  import { inventoryManagedSnapshots } from "../backup-prune.js";
5
6
  import { VaultClient } from "../index.js";
6
7
  import { pickCanonicalPersonEntity } from "../vault-client.js";
@@ -44,6 +45,7 @@ import {
44
45
  buildScopedDrainPullArgv,
45
46
  buildScopedPushArgv,
46
47
  buildTargetedPullArgv,
48
+ isWatchRouteSelected,
47
49
  routeChangeToTarget,
48
50
  routeKey,
49
51
  type WatchRoute,
@@ -91,9 +93,35 @@ export function adaptivePollMs(
91
93
  return Math.round(floorMs + ratio * (ceilMs - floorMs));
92
94
  }
93
95
 
96
+ /**
97
+ * Advance the cadence from its previous monotonic deadline, rather than from
98
+ * the end of a pass. A slow pass is allowed one immediate catch-up pass; the
99
+ * returned deadline is always the first future slot, so missed slots cannot
100
+ * turn into an unbounded burst.
101
+ */
102
+ export function advanceMonotonicDeadline(
103
+ previousDueAt: number,
104
+ now: number,
105
+ intervalMs: number,
106
+ ): { nextDueAt: number; delayMs: number } {
107
+ let nextDueAt = previousDueAt + intervalMs;
108
+ if (nextDueAt > now) {
109
+ return { nextDueAt, delayMs: nextDueAt - now };
110
+ }
111
+
112
+ // The pass crossed at least one deadline. Run exactly one catch-up now,
113
+ // then retain only the first future cadence slot.
114
+ do {
115
+ nextDueAt += intervalMs;
116
+ } while (nextDueAt <= now);
117
+ return { nextDueAt, delayMs: 0 };
118
+ }
119
+
94
120
  export interface ParsedLoopArgs {
95
121
  hqRoot: string;
96
122
  lockTimeoutSec?: number;
123
+ /** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */
124
+ skipPersonal?: boolean;
97
125
  /**
98
126
  * The run's resolved sync direction, straight from `parseArgs` (the single
99
127
  * source of truth — same default as the parser). The drain reads this to
@@ -220,6 +248,7 @@ export async function runWatchLoop(
220
248
  const sleep =
221
249
  deps.sleep ??
222
250
  ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
251
+ const monotonicNow = deps.monotonicNow ?? (() => performance.now());
223
252
  const runPass =
224
253
  deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
225
254
  const pollIdx = argv.indexOf("--poll-remote-ms");
@@ -232,6 +261,22 @@ export async function runWatchLoop(
232
261
  const cpuCount = os.cpus().length;
233
262
  const eventPush = argv.includes("--event-push");
234
263
  const companiesMode = argv.includes("--companies");
264
+ const companyMode = argv.includes("--company");
265
+ const personalMode = argv.includes("--personal");
266
+ // Keep this at the watcher ingress rather than importing the runner's
267
+ // resolver: sync-runner imports this loop, and a reverse value import would
268
+ // create a runtime cycle. These are the parser's documented truthy values.
269
+ const skipPersonalEnv = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
270
+ const watchRouteSelection = {
271
+ companies: companiesMode,
272
+ company: companyMode,
273
+ personal: personalMode,
274
+ skipPersonal:
275
+ parsed.skipPersonal === true ||
276
+ skipPersonalEnv === "1" ||
277
+ skipPersonalEnv === "true" ||
278
+ skipPersonalEnv === "yes",
279
+ };
235
280
  const hqRoot = parsed.hqRoot;
236
281
  // Delayed and daily: this must never be attached to the 15s poll cadence.
237
282
  // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
@@ -323,6 +368,45 @@ export async function runWatchLoop(
323
368
  };
324
369
 
325
370
  let stopped = false;
371
+ let resolveStopped: (() => void) | null = null;
372
+ const stoppedSignal = new Promise<void>((resolve) => {
373
+ resolveStopped = resolve;
374
+ });
375
+ // A signal can arrive while a guarded pass is running, before the next wait
376
+ // has registered. Keep a monotonic generation so that event is retained and
377
+ // consumed by the next scheduler wait instead of being lost.
378
+ let interruptGeneration = 0;
379
+ let observedInterruptGeneration = 0;
380
+ let resolveInterruptWait: (() => void) | null = null;
381
+ const interruptWait = (): void => {
382
+ if (stopped) return;
383
+ interruptGeneration += 1;
384
+ const resolve = resolveInterruptWait;
385
+ resolveInterruptWait = null;
386
+ resolve?.();
387
+ };
388
+ const waitForNextPass = async (delayMs: number): Promise<void> => {
389
+ if (interruptGeneration !== observedInterruptGeneration) {
390
+ observedInterruptGeneration = interruptGeneration;
391
+ return;
392
+ }
393
+
394
+ await Promise.race([
395
+ sleep(delayMs),
396
+ new Promise<void>((resolve) => {
397
+ resolveInterruptWait = resolve;
398
+ // Do not lose an event that arrived between the first generation
399
+ // check and registration of this wait.
400
+ if (interruptGeneration !== observedInterruptGeneration) {
401
+ resolveInterruptWait = null;
402
+ resolve();
403
+ }
404
+ }),
405
+ stoppedSignal,
406
+ ]);
407
+ resolveInterruptWait = null;
408
+ observedInterruptGeneration = interruptGeneration;
409
+ };
326
410
  let activePass: Promise<number | RunnerPassOutcome> | null = null;
327
411
  type QueuedPass = {
328
412
  argv: string[];
@@ -384,6 +468,13 @@ export async function runWatchLoop(
384
468
  const pendingWatcherPaths = new Map<string, string>();
385
469
  const pendingWatcherChanges = new Map<string, TreeChange>();
386
470
  let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
471
+ // Every coalesced watcher batch gets one durable in-process generation.
472
+ // A wake can cause the scheduler to revisit a drained batch, but an event
473
+ // announcement is an external side effect and must occur at most once for
474
+ // that batch after its targeted push succeeds.
475
+ let nextWatcherBatchGeneration = 0;
476
+ let pendingWatcherBatchGeneration: number | null = null;
477
+ const lastPublishedWatcherBatchGenerationByRoute = new Map<string, number>();
387
478
  let pendingWatcherBareChange = false;
388
479
  let pendingWatcherOverflowed = false;
389
480
  let pendingWatcherDroppedPaths = 0;
@@ -391,7 +482,30 @@ export async function runWatchLoop(
391
482
  const addPendingWatcherChange = (
392
483
  changedRelPath?: string,
393
484
  batch?: TreeChangeBatch,
394
- ): void => {
485
+ ): boolean => {
486
+ if (batch) {
487
+ const paths = new Map<string, string>();
488
+ const changes = new Map<string, TreeChange>();
489
+ for (const [absolutePath, relativePath] of batch.paths.entries()) {
490
+ const route = routeChangeToTarget(relativePath);
491
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection)) continue;
492
+ paths.set(absolutePath, relativePath);
493
+ const change = batch.changes?.get(absolutePath);
494
+ if (change) changes.set(absolutePath, change);
495
+ }
496
+ if (paths.size === 0) return false;
497
+ batch = {
498
+ ...batch,
499
+ paths,
500
+ changes,
501
+ };
502
+ } else if (changedRelPath) {
503
+ const route = routeChangeToTarget(changedRelPath);
504
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection)) {
505
+ return false;
506
+ }
507
+ }
508
+ pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
395
509
  if (batch) {
396
510
  pendingWatcherOriginalBatch =
397
511
  pendingWatcherPaths.size === 0 &&
@@ -410,7 +524,7 @@ export async function runWatchLoop(
410
524
  pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
411
525
  pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
412
526
  }
413
- return;
527
+ return true;
414
528
  }
415
529
  if (changedRelPath) {
416
530
  pendingWatcherOriginalBatch = null;
@@ -424,14 +538,16 @@ export async function runWatchLoop(
424
538
  // a prior batch left for this path so the stale intent cannot leak
425
539
  // into the coalesced batch (#227).
426
540
  pendingWatcherChanges.delete(absolutePath);
427
- return;
541
+ return true;
428
542
  }
429
543
  pendingWatcherOriginalBatch = null;
430
544
  pendingWatcherBareChange = true;
545
+ return true;
431
546
  };
432
547
  const takePendingWatcherChange = (): {
433
548
  batch: TreeChangeBatch | null;
434
549
  bare: boolean;
550
+ generation: number | null;
435
551
  } => {
436
552
  const batch =
437
553
  pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
@@ -452,6 +568,7 @@ export async function runWatchLoop(
452
568
  }
453
569
  : null;
454
570
  const bare = pendingWatcherBareChange;
571
+ const generation = pendingWatcherBatchGeneration;
455
572
  pendingWatcherPaths.clear();
456
573
  pendingWatcherChanges.clear();
457
574
  pendingWatcherOriginalBatch = null;
@@ -459,9 +576,15 @@ export async function runWatchLoop(
459
576
  pendingWatcherOverflowed = false;
460
577
  pendingWatcherDroppedPaths = 0;
461
578
  pendingWatcherDroppedBytes = 0;
462
- return { batch, bare };
579
+ pendingWatcherBatchGeneration = null;
580
+ return { batch, bare, generation };
463
581
  };
464
- const restorePendingWatcherPaths = (paths: Map<string, string>): void => {
582
+ const restorePendingWatcherPaths = (
583
+ paths: Map<string, string>,
584
+ generation: number | null,
585
+ ): void => {
586
+ pendingWatcherBatchGeneration ??=
587
+ generation ?? ++nextWatcherBatchGeneration;
465
588
  pendingWatcherOriginalBatch = null;
466
589
  for (const [absolutePath, relativePath] of paths.entries()) {
467
590
  pendingWatcherPaths.set(absolutePath, relativePath);
@@ -512,7 +635,7 @@ export async function runWatchLoop(
512
635
  relativePath: string,
513
636
  ): { slug: string; key: string } | null => {
514
637
  const route = routeChangeToTarget(relativePath);
515
- if (!route) return null;
638
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection)) return null;
516
639
  if (route.kind === "personal") {
517
640
  return { slug: PERSONAL_VAULT_JOURNAL_SLUG, key: relativePath };
518
641
  }
@@ -786,14 +909,17 @@ export async function runWatchLoop(
786
909
  return grouped;
787
910
  };
788
911
 
789
- const publishBatchIfAvailable = (paths: Map<string, string>): void => {
912
+ const publishBatchIfAvailable = (paths: Map<string, string>): boolean => {
790
913
  if (!stopped && eventSync && paths.size > 0) {
791
914
  eventSync.publishBatch({ paths: new Map(paths) });
915
+ return true;
792
916
  }
917
+ return false;
793
918
  };
794
919
 
795
920
  const runScopedDrain = async (
796
921
  batch: TreeChangeBatch | null,
922
+ watcherBatchGeneration: number | null,
797
923
  ): Promise<number> => {
798
924
  if (drainRunsPush && batch && batch.paths.size > 0) {
799
925
  const skipCompanies = resolveSkipCompanies();
@@ -828,26 +954,35 @@ export async function runWatchLoop(
828
954
  .filter((pathResult) => pathResult.status === "refused")
829
955
  .map((pathResult) => pathResult.relativePath),
830
956
  );
831
- publishBatchIfAvailable(
832
- new Map(
833
- [...group.paths.entries()].filter(
834
- ([absolutePath, relativePath]) => {
835
- if (refusedPaths.has(relativePath)) return false;
836
- if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
837
- return true;
838
- }
839
- return ![...refusedPaths].some((refusedPath) =>
840
- refusedPath.startsWith(`${relativePath}/`),
841
- );
842
- },
957
+ const route = routeKey(group.route);
958
+ const generation = watcherBatchGeneration ?? 0;
959
+ if (
960
+ lastPublishedWatcherBatchGenerationByRoute.get(route) !== generation
961
+ ) {
962
+ const published = publishBatchIfAvailable(
963
+ new Map(
964
+ [...group.paths.entries()].filter(
965
+ ([absolutePath, relativePath]) => {
966
+ if (refusedPaths.has(relativePath)) return false;
967
+ if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
968
+ return true;
969
+ }
970
+ return ![...refusedPaths].some((refusedPath) =>
971
+ refusedPath.startsWith(`${relativePath}/`),
972
+ );
973
+ },
974
+ ),
843
975
  ),
844
- ),
845
- );
976
+ );
977
+ if (published) {
978
+ lastPublishedWatcherBatchGenerationByRoute.set(route, generation);
979
+ }
980
+ }
846
981
  } else {
847
- restorePendingWatcherPaths(group.paths);
982
+ restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
848
983
  }
849
984
  } catch (err) {
850
- restorePendingWatcherPaths(group.paths);
985
+ restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
851
986
  process.stderr.write(
852
987
  `watch scoped push failed, will retry next tick: ${describeError(err)}\n`,
853
988
  );
@@ -901,12 +1036,17 @@ export async function runWatchLoop(
901
1036
 
902
1037
  watcher.onChange((changedRelPath, batch) => {
903
1038
  if (stopped) return;
904
- addPendingWatcherChange(changedRelPath, batch);
1039
+ if (addPendingWatcherChange(changedRelPath, batch)) interruptWait();
905
1040
  });
906
1041
  watcher.start();
907
1042
 
908
1043
  const receiverSyncFn: SyncEngineFn = async (ctx) => {
909
1044
  if (stopped) return;
1045
+ // A received wake/high-water signal means there is bounded work now.
1046
+ // It wakes the scheduler but is still funneled through runGuarded below,
1047
+ // so it cannot overlap the pass that may currently own the operation
1048
+ // lock.
1049
+ interruptWait();
910
1050
  const route = routeChangeToTarget(ctx.event.relativePath);
911
1051
  if (!route) return;
912
1052
  const targetedArgv = buildTargetedPullArgv(route, passArgv);
@@ -921,8 +1061,7 @@ export async function runWatchLoop(
921
1061
  receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
922
1062
  void Promise.resolve(receiver.start()).catch(() => undefined);
923
1063
 
924
- const email = claims?.email;
925
- if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
1064
+ if (resolveEventSync(process.env.HQ_SYNC_EVENT_SYNC)) {
926
1065
  const startES = deps.startEventSync ?? defaultStartEventSync;
927
1066
  void (async () => {
928
1067
  const eventSyncClient = new VaultClient({
@@ -962,10 +1101,6 @@ export async function runWatchLoop(
962
1101
  }
963
1102
  }
964
1103
 
965
- let resolveStopped: (() => void) | null = null;
966
- const stoppedSignal = new Promise<void>((resolve) => {
967
- resolveStopped = resolve;
968
- });
969
1104
  const shutdown = (): void => {
970
1105
  if (stopped) return;
971
1106
  stopped = true;
@@ -1001,6 +1136,9 @@ export async function runWatchLoop(
1001
1136
  });
1002
1137
  detachSignal = onShutdownSignal(shutdown);
1003
1138
 
1139
+ // The initial reconcile is due immediately. Later passes advance from this
1140
+ // deadline, not from pass completion, so cadence stays monotonic.
1141
+ let nextDueAt = monotonicNow();
1004
1142
  let pollTick = 0;
1005
1143
  try {
1006
1144
  while (!stopped) {
@@ -1022,7 +1160,7 @@ export async function runWatchLoop(
1022
1160
  pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
1023
1161
  const result = shouldRunFull
1024
1162
  ? await runGuarded(passArgv)
1025
- : await runScopedDrain(pending.batch);
1163
+ : await runScopedDrain(pending.batch, pending.generation);
1026
1164
  // This batch's episode has now been prepared and pushed, so a later
1027
1165
  // removal under the same path is judged fresh — a root that never
1028
1166
  // expired would silently refuse ordinary deletes from here on.
@@ -1081,7 +1219,13 @@ export async function runWatchLoop(
1081
1219
  }
1082
1220
  const nextPollMs =
1083
1221
  explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
1084
- await Promise.race([sleep(nextPollMs), stoppedSignal]);
1222
+ const schedule = advanceMonotonicDeadline(
1223
+ nextDueAt,
1224
+ monotonicNow(),
1225
+ nextPollMs,
1226
+ );
1227
+ nextDueAt = schedule.nextDueAt;
1228
+ await waitForNextPass(schedule.delayMs);
1085
1229
  }
1086
1230
  return 0;
1087
1231
  } finally {
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isWatchRouteSelected,
4
+ routeChangeToTarget,
5
+ type WatchRouteSelection,
6
+ } from "./sync-runner-watch-routes.js";
7
+
8
+ const companies: WatchRouteSelection = {
9
+ companies: true,
10
+ company: false,
11
+ personal: false,
12
+ skipPersonal: false,
13
+ };
14
+
15
+ describe("watch route selection", () => {
16
+ it("routes personal and company changes for an ordinary companies run", () => {
17
+ const personal = routeChangeToTarget("knowledge/me.md");
18
+ const company = routeChangeToTarget("companies/indigo/knowledge/team.md");
19
+
20
+ expect(personal).toEqual({ kind: "personal" });
21
+ expect(company).toEqual({ kind: "company", slug: "indigo" });
22
+ expect(isWatchRouteSelected(personal!, companies)).toBe(true);
23
+ expect(isWatchRouteSelected(company!, companies)).toBe(true);
24
+ });
25
+
26
+ it("rejects personal changes before queueing when companies skips personal", () => {
27
+ const personal = routeChangeToTarget("knowledge/me.md");
28
+
29
+ expect(
30
+ isWatchRouteSelected(personal!, { ...companies, skipPersonal: true }),
31
+ ).toBe(false);
32
+ });
33
+
34
+ it("keeps route selection scoped for personal and single-company runs", () => {
35
+ const personal = { kind: "personal" } as const;
36
+ const company = { kind: "company", slug: "indigo" } as const;
37
+
38
+ expect(
39
+ isWatchRouteSelected(personal, {
40
+ companies: false,
41
+ company: false,
42
+ personal: true,
43
+ skipPersonal: false,
44
+ }),
45
+ ).toBe(true);
46
+ expect(
47
+ isWatchRouteSelected(company, {
48
+ companies: false,
49
+ company: false,
50
+ personal: true,
51
+ skipPersonal: false,
52
+ }),
53
+ ).toBe(false);
54
+ expect(
55
+ isWatchRouteSelected(personal, {
56
+ companies: false,
57
+ company: true,
58
+ personal: false,
59
+ skipPersonal: false,
60
+ }),
61
+ ).toBe(false);
62
+ expect(
63
+ isWatchRouteSelected(company, {
64
+ companies: false,
65
+ company: true,
66
+ personal: false,
67
+ skipPersonal: false,
68
+ }),
69
+ ).toBe(true);
70
+ });
71
+ });
@@ -5,6 +5,18 @@ export type WatchRoute =
5
5
  | { kind: "company"; slug: string }
6
6
  | { kind: "personal" };
7
7
 
8
+ /**
9
+ * The sync target selected for a watcher run. Watcher events must be rejected
10
+ * at ingress when they cannot belong to this target; deferring this decision
11
+ * until the pass planner would still queue a needless guarded pass.
12
+ */
13
+ export interface WatchRouteSelection {
14
+ companies: boolean;
15
+ company: boolean;
16
+ personal: boolean;
17
+ skipPersonal: boolean;
18
+ }
19
+
8
20
  /**
9
21
  * Route a changed relative path to the push target that owns it.
10
22
  *
@@ -25,6 +37,21 @@ export function routeChangeToTarget(relPath: string): WatchRoute | null {
25
37
  return { kind: "personal" };
26
38
  }
27
39
 
40
+ /**
41
+ * Whether a routed watcher change belongs to the command's selected sync
42
+ * target. This is deliberately evaluated before watcher work is queued, so a
43
+ * disabled personal scope cannot create a pass or event-sync publication.
44
+ */
45
+ export function isWatchRouteSelected(
46
+ route: WatchRoute,
47
+ selection: WatchRouteSelection,
48
+ ): boolean {
49
+ if (route.kind === "personal") {
50
+ return selection.personal || (selection.companies && !selection.skipPersonal);
51
+ }
52
+ return selection.companies || selection.company;
53
+ }
54
+
28
55
  /**
29
56
  * Build the argv for a targeted push pass from a routed change.
30
57
  */