@indigoai-us/hq-cloud 5.25.0 → 5.27.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 (87) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/dist/bin/sync-runner.d.ts +138 -1
  3. package/dist/bin/sync-runner.d.ts.map +1 -1
  4. package/dist/bin/sync-runner.js +288 -16
  5. package/dist/bin/sync-runner.js.map +1 -1
  6. package/dist/bin/sync-runner.test.js +372 -1
  7. package/dist/bin/sync-runner.test.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/sync/feature-flags.d.ts +136 -0
  13. package/dist/sync/feature-flags.d.ts.map +1 -0
  14. package/dist/sync/feature-flags.js +160 -0
  15. package/dist/sync/feature-flags.js.map +1 -0
  16. package/dist/sync/feature-flags.test.d.ts +24 -0
  17. package/dist/sync/feature-flags.test.d.ts.map +1 -0
  18. package/dist/sync/feature-flags.test.js +330 -0
  19. package/dist/sync/feature-flags.test.js.map +1 -0
  20. package/dist/sync/index.d.ts +19 -0
  21. package/dist/sync/index.d.ts.map +1 -0
  22. package/dist/sync/index.js +13 -0
  23. package/dist/sync/index.js.map +1 -0
  24. package/dist/sync/logger.d.ts +61 -0
  25. package/dist/sync/logger.d.ts.map +1 -0
  26. package/dist/sync/logger.js +51 -0
  27. package/dist/sync/logger.js.map +1 -0
  28. package/dist/sync/logger.test.d.ts +19 -0
  29. package/dist/sync/logger.test.d.ts.map +1 -0
  30. package/dist/sync/logger.test.js +199 -0
  31. package/dist/sync/logger.test.js.map +1 -0
  32. package/dist/sync/metrics.d.ts +89 -0
  33. package/dist/sync/metrics.d.ts.map +1 -0
  34. package/dist/sync/metrics.js +105 -0
  35. package/dist/sync/metrics.js.map +1 -0
  36. package/dist/sync/metrics.test.d.ts +19 -0
  37. package/dist/sync/metrics.test.d.ts.map +1 -0
  38. package/dist/sync/metrics.test.js +280 -0
  39. package/dist/sync/metrics.test.js.map +1 -0
  40. package/dist/sync/push-event.d.ts +110 -0
  41. package/dist/sync/push-event.d.ts.map +1 -0
  42. package/dist/sync/push-event.js +153 -0
  43. package/dist/sync/push-event.js.map +1 -0
  44. package/dist/sync/push-event.test.d.ts +15 -0
  45. package/dist/sync/push-event.test.d.ts.map +1 -0
  46. package/dist/sync/push-event.test.js +188 -0
  47. package/dist/sync/push-event.test.js.map +1 -0
  48. package/dist/sync/push-receiver.d.ts +442 -0
  49. package/dist/sync/push-receiver.d.ts.map +1 -0
  50. package/dist/sync/push-receiver.js +782 -0
  51. package/dist/sync/push-receiver.js.map +1 -0
  52. package/dist/sync/push-receiver.test.d.ts +25 -0
  53. package/dist/sync/push-receiver.test.d.ts.map +1 -0
  54. package/dist/sync/push-receiver.test.js +477 -0
  55. package/dist/sync/push-receiver.test.js.map +1 -0
  56. package/dist/sync/push-transport.d.ts +150 -0
  57. package/dist/sync/push-transport.d.ts.map +1 -0
  58. package/dist/sync/push-transport.js +150 -0
  59. package/dist/sync/push-transport.js.map +1 -0
  60. package/dist/watcher.d.ts +271 -0
  61. package/dist/watcher.d.ts.map +1 -1
  62. package/dist/watcher.js +480 -3
  63. package/dist/watcher.js.map +1 -1
  64. package/dist/watcher.test.d.ts +2 -0
  65. package/dist/watcher.test.d.ts.map +1 -0
  66. package/dist/watcher.test.js +334 -0
  67. package/dist/watcher.test.js.map +1 -0
  68. package/package.json +10 -5
  69. package/src/bin/sync-runner.test.ts +487 -1
  70. package/src/bin/sync-runner.ts +406 -9
  71. package/src/index.ts +38 -0
  72. package/src/sync/feature-flags.test.ts +392 -0
  73. package/src/sync/feature-flags.ts +229 -0
  74. package/src/sync/index.ts +74 -0
  75. package/src/sync/logger.test.ts +241 -0
  76. package/src/sync/logger.ts +79 -0
  77. package/src/sync/metrics.test.ts +380 -0
  78. package/src/sync/metrics.ts +158 -0
  79. package/src/sync/push-event.test.ts +224 -0
  80. package/src/sync/push-event.ts +208 -0
  81. package/src/sync/push-receiver.test.ts +545 -0
  82. package/src/sync/push-receiver.ts +1077 -0
  83. package/src/sync/push-transport.ts +231 -0
  84. package/src/watcher.test.ts +388 -0
  85. package/src/watcher.ts +672 -4
  86. package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
  87. package/test/e2e/watcher-real-chokidar.test.ts +105 -0
@@ -21,3 +21,37 @@ jobs:
21
21
  - run: pnpm run build
22
22
  - run: pnpm run typecheck
23
23
  - run: pnpm test
24
+
25
+ # ─────────────────────────────────────────────────────────────────────────
26
+ # US-010 — Cross-tenant isolation invariant (BLOCKING)
27
+ # ─────────────────────────────────────────────────────────────────────────
28
+ # Runs the e2e that pins the P0 client-side invariant: NO tenant-A data may
29
+ # ever reach a tenant-B device via the event-driven push OR pull path. The
30
+ # job runs on every pull_request (the workflow trigger above). A FAILURE IS
31
+ # A P0 incident — do not merge/deploy/flag-flip until green again (see the
32
+ # test file's header banner and the project journal).
33
+ #
34
+ # Mirrors hq-pro PR #112's server-side `cross-tenant-isolation` gate on the
35
+ # hq-cloud CLIENT side. Runs in parallel with `build` (no `needs:`) so a
36
+ # security-invariant regression surfaces independent of any unrelated build
37
+ # noise. The test lives under test/ and is run via the project's `test:e2e`
38
+ # script (vitest run test/), narrowed to this one file so the gate is fast
39
+ # and unambiguous.
40
+ cross-tenant-isolation:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: pnpm/action-setup@v4
45
+ with:
46
+ version: 10
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: 22
50
+ cache: pnpm
51
+ - run: pnpm install --frozen-lockfile --config.minimumReleaseAge=1440
52
+ # Fail-fast: a broken type surface would make the e2e run produce a
53
+ # confusing runtime error — typecheck up front so this BLOCKING job
54
+ # fails loudly and early instead.
55
+ - run: pnpm run typecheck
56
+ - name: Cross-tenant isolation E2E (blocking)
57
+ run: pnpm vitest run test/e2e/sync/cross-tenant-isolation.test.ts
@@ -59,6 +59,8 @@ import { type VaultServiceConfig, type Membership, type EntityInfo, type Pending
59
59
  import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths } from "../personal-vault.js";
60
60
  import type { SyncOptions, SyncResult, SyncProgressEvent } from "../cli/sync.js";
61
61
  import type { ShareOptions, ShareResult } from "../cli/share.js";
62
+ import { type Clock } from "../watcher.js";
63
+ import { type PushReceiver, type SyncEngineFn } from "../sync/push-receiver.js";
62
64
  /**
63
65
  * Sync direction for a run.
64
66
  *
@@ -324,5 +326,140 @@ export declare function runRunner(argv: string[], deps?: RunnerDeps): Promise<nu
324
326
  * exit 0 today and so will retry — acceptable noise for the beta; deal with
325
327
  * it via a richer return shape if it shows up in Sentry.
326
328
  */
327
- export declare function runRunnerWithLoop(argv: string[]): Promise<number>;
329
+ /**
330
+ * Test/event-driven seam (US-001).
331
+ *
332
+ * `runRunnerWithLoop` performs an unbounded poll loop in production. To make
333
+ * the loop deterministically testable (US-003 wires the event-driven watcher
334
+ * into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
335
+ * The default uses the host timer and preserves exact production behavior.
336
+ *
337
+ * A test (or US-003's wiring) injects a fake sleep that resolves immediately
338
+ * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
339
+ * so the loop can be exercised without a real 10-minute wait.
340
+ */
341
+ export interface RunnerLoopDeps {
342
+ /** Sleep `ms` between passes. Default: host setTimeout. */
343
+ sleep?: (ms: number) => Promise<void>;
344
+ /**
345
+ * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
346
+ * (and the event-push wiring) so the poll loop and the watcher-triggered
347
+ * targeted push share one seam and one in-flight guard. The default ignores
348
+ * `deps` and forwards just the argv to `runRunner`.
349
+ */
350
+ runPass?: (passArgv: string[]) => Promise<number>;
351
+ /**
352
+ * Clock seam for the event-push watcher's debounce window. Defaults to
353
+ * {@link systemClock}; tests inject a `FakeClock` to advance the window
354
+ * deterministically. Only consulted when `--event-push` is on.
355
+ */
356
+ clock?: Clock;
357
+ /**
358
+ * Factory for the file watcher used in event-push mode. Defaults to a real
359
+ * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
360
+ * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
361
+ */
362
+ createWatcher?: (opts: {
363
+ hqRoot: string;
364
+ debounceMs: number;
365
+ clock: Clock;
366
+ }) => WatcherSurface;
367
+ /**
368
+ * Register a one-shot shutdown signal handler. Defaults to listening for
369
+ * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
370
+ * clean teardown without sending real signals. The returned fn detaches the
371
+ * handler (called during teardown so tests don't leak listeners).
372
+ */
373
+ onShutdownSignal?: (handler: () => void) => () => void;
374
+ /**
375
+ * Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
376
+ * {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
377
+ * the lifecycle (start after the watcher, dispose before exit) but stays
378
+ * DORMANT by default: the per-client SQS queue is provisioned server-side
379
+ * (an unbuilt follow-up) and the receiver is feature-flag gated. A future
380
+ * menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
381
+ * URL is available. Only consulted when `--event-push` is on.
382
+ *
383
+ * The factory is handed a {@link SyncEngineFn} that bridges a received
384
+ * PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
385
+ * or a personal `--companies --direction pull`) routed by the event's
386
+ * `relativePath`, funneled through the same in-flight guard as the poll
387
+ * loop and the watcher push so a pull-on-event never overlaps an in-flight
388
+ * pass.
389
+ */
390
+ createReceiver?: (opts: {
391
+ syncFn: SyncEngineFn;
392
+ hqRoot: string;
393
+ }) => PushReceiver;
394
+ }
395
+ /**
396
+ * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
397
+ * it; tests inject a lighter stub. Kept narrow so the loop never reaches past
398
+ * the lifecycle + change-subscription contract.
399
+ *
400
+ * `onChange`'s listener receives an OPTIONAL changed relative path. The real
401
+ * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
402
+ * the loop routes the targeted push to the personal vault. A path-aware
403
+ * watcher (or a test stub) can pass the changed `companies/<slug>/...`
404
+ * relative path so the loop targets just that company.
405
+ */
406
+ export interface WatcherSurface {
407
+ onChange(listener: (changedRelPath?: string) => void): () => void;
408
+ start(): void;
409
+ stop(): void;
410
+ dispose(): void;
411
+ }
412
+ /**
413
+ * Route a changed relative path to the push target that owns it.
414
+ *
415
+ * - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
416
+ * pass per PRD decision — push only the changed company, not a full
417
+ * `--companies` fanout).
418
+ * - anything else under hqRoot → the personal target (a `--companies` push
419
+ * restricted to personal via the personal-vault scope; modeled here as the
420
+ * "personal" route the caller maps to the right argv).
421
+ *
422
+ * Returns `null` for paths the routing cannot attribute (defensive — the
423
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
424
+ * suspenders for synthetic events).
425
+ */
426
+ export declare function routeChangeToTarget(relPath: string): {
427
+ kind: "company";
428
+ slug: string;
429
+ } | {
430
+ kind: "personal";
431
+ } | null;
432
+ /**
433
+ * Build the argv for a targeted push pass from a routed change. The push runs
434
+ * `--direction push` for just the routed target so a local edit propagates in
435
+ * seconds without a full fanout. Company routes use `--company <slug>`;
436
+ * personal routes use `--companies --direction push` (the personal-vault scope
437
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
438
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
439
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
440
+ */
441
+ export declare function buildTargetedPushArgv(route: {
442
+ kind: "company";
443
+ slug: string;
444
+ } | {
445
+ kind: "personal";
446
+ }, baseArgv: string[]): string[];
447
+ /**
448
+ * Build the argv for a targeted PULL pass from a routed change (US-009 — the
449
+ * receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
450
+ * with `--direction pull`: a peer device pushed a change, so this device pulls
451
+ * just the affected company/subtree instead of waiting for the next
452
+ * `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
453
+ * routes use `--companies` (the personal-vault scope is resolved inside
454
+ * runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
455
+ * Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
456
+ * exported for unit testing the event→argv map.
457
+ */
458
+ export declare function buildTargetedPullArgv(route: {
459
+ kind: "company";
460
+ slug: string;
461
+ } | {
462
+ kind: "personal";
463
+ }, baseArgv: string[]): string[];
464
+ export declare function runRunnerWithLoop(argv: string[], deps?: RunnerLoopDeps): Promise<number>;
328
465
  //# sourceMappingURL=sync-runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sync-runner.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sync-runner.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner.ts"],"names":[],"mappings":"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"}
@@ -65,6 +65,8 @@ import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths, } from ".
65
65
  import { sync as defaultSync } from "../cli/sync.js";
66
66
  import { share as defaultShare } from "../cli/share.js";
67
67
  import { collectAndSendTelemetry } from "../telemetry.js";
68
+ import { TreeWatcher, WatchPushDriver, systemClock, } from "../watcher.js";
69
+ import { NoopPushReceiver, } from "../sync/push-receiver.js";
68
70
  // ---------------------------------------------------------------------------
69
71
  // Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
70
72
  // imported) to avoid a circular dep between hq-cli and hq-cloud. If these
@@ -190,6 +192,7 @@ function parseArgs(argv) {
190
192
  let watch = false;
191
193
  let pollRemoteMs;
192
194
  let skipPersonal = false;
195
+ let eventPush = false;
193
196
  for (let i = 0; i < argv.length; i++) {
194
197
  const arg = argv[i];
195
198
  switch (arg) {
@@ -251,6 +254,12 @@ function parseArgs(argv) {
251
254
  // resolveSkipPersonal().
252
255
  skipPersonal = true;
253
256
  break;
257
+ case "--event-push":
258
+ // Phase 1 event-driven push enable flag. Requires --watch (validated
259
+ // below). Gated OFF by default; the menubar only passes it for
260
+ // @getindigo.ai identities for the first release.
261
+ eventPush = true;
262
+ break;
254
263
  default:
255
264
  return { error: `Unknown argument: ${arg}` };
256
265
  }
@@ -264,7 +273,20 @@ function parseArgs(argv) {
264
273
  if (pollRemoteMs !== undefined && !watch) {
265
274
  return { error: "--poll-remote-ms requires --watch" };
266
275
  }
267
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs, skipPersonal };
276
+ if (eventPush && !watch) {
277
+ return { error: "--event-push requires --watch" };
278
+ }
279
+ return {
280
+ companies,
281
+ company,
282
+ onConflict,
283
+ hqRoot,
284
+ direction,
285
+ watch,
286
+ pollRemoteMs,
287
+ skipPersonal,
288
+ eventPush,
289
+ };
268
290
  }
269
291
  // ---------------------------------------------------------------------------
270
292
  // Telemetry default — closes over the runner's vault client. Skipped when
@@ -831,37 +853,287 @@ const isDirectInvocation = (() => {
831
853
  }
832
854
  })();
833
855
  /**
834
- * Auto-sync (Beta) watch loop. Re-runs the one-shot runner every
835
- * `pollRemoteMs` until the process is killed (SIGTERM from the menubar's
836
- * stop_daemon command) or until a pass returns a non-zero exit code (hard
837
- * error worth surfacing to the operator). `setup-needed` and `auth-error`
838
- * exit 0 today and so will retry — acceptable noise for the beta; deal with
839
- * it via a richer return shape if it shows up in Sentry.
856
+ * Route a changed relative path to the push target that owns it.
857
+ *
858
+ * - `companies/<slug>/...` a single-company push for `<slug>` (the targeted
859
+ * pass per PRD decision push only the changed company, not a full
860
+ * `--companies` fanout).
861
+ * - anything else under hqRoot the personal target (a `--companies` push
862
+ * restricted to personal via the personal-vault scope; modeled here as the
863
+ * "personal" route the caller maps to the right argv).
864
+ *
865
+ * Returns `null` for paths the routing cannot attribute (defensive — the
866
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
867
+ * suspenders for synthetic events).
868
+ */
869
+ export function routeChangeToTarget(relPath) {
870
+ const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
871
+ if (norm === "" || norm.startsWith(".."))
872
+ return null;
873
+ const segments = norm.split("/").filter((s) => s.length > 0);
874
+ if (segments.length === 0)
875
+ return null;
876
+ if (segments[0] === "companies") {
877
+ // companies/<slug>/... — need at least the slug segment to target.
878
+ if (segments.length < 2 || segments[1].length === 0)
879
+ return null;
880
+ return { kind: "company", slug: segments[1] };
881
+ }
882
+ return { kind: "personal" };
883
+ }
884
+ /**
885
+ * Build the argv for a targeted push pass from a routed change. The push runs
886
+ * `--direction push` for just the routed target so a local edit propagates in
887
+ * seconds without a full fanout. Company routes use `--company <slug>`;
888
+ * personal routes use `--companies --direction push` (the personal-vault scope
889
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
890
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
891
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
840
892
  */
841
- export async function runRunnerWithLoop(argv) {
893
+ export function buildTargetedPushArgv(route, baseArgv) {
894
+ const carried = carriedFlags(baseArgv);
895
+ if (route.kind === "company") {
896
+ return ["--company", route.slug, "--direction", "push", ...carried];
897
+ }
898
+ return ["--companies", "--direction", "push", ...carried];
899
+ }
900
+ /**
901
+ * Build the argv for a targeted PULL pass from a routed change (US-009 — the
902
+ * receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
903
+ * with `--direction pull`: a peer device pushed a change, so this device pulls
904
+ * just the affected company/subtree instead of waiting for the next
905
+ * `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
906
+ * routes use `--companies` (the personal-vault scope is resolved inside
907
+ * runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
908
+ * Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
909
+ * exported for unit testing the event→argv map.
910
+ */
911
+ export function buildTargetedPullArgv(route, baseArgv) {
912
+ const carried = carriedFlags(baseArgv);
913
+ if (route.kind === "company") {
914
+ return ["--company", route.slug, "--direction", "pull", ...carried];
915
+ }
916
+ return ["--companies", "--direction", "pull", ...carried];
917
+ }
918
+ export async function runRunnerWithLoop(argv, deps = {}) {
842
919
  if (!argv.includes("--watch")) {
843
920
  return runRunner(argv);
844
921
  }
922
+ const sleep = deps.sleep ??
923
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
924
+ const runPass = deps.runPass ?? ((passArgv) => runRunner(passArgv));
845
925
  const pollIdx = argv.indexOf("--poll-remote-ms");
846
926
  const pollMs = pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
847
- // Strip --watch / --poll-remote-ms before delegating: the parser inside
848
- // runRunner accepts them, but we don't want runRunner to think it's
849
- // re-entering watch mode each iteration.
927
+ const eventPush = argv.includes("--event-push");
928
+ // In `--companies` mode the sync scope is companies/*/{knowledge,projects,…}
929
+ // (per .hqinclude), so the watcher must NOT apply the personal-vault
930
+ // top-level exclusions (PERSONAL_VAULT_EXCLUDED_TOP_LEVEL drops `companies/`
931
+ // and `workspace/`) — doing so would exclude exactly the paths being synced,
932
+ // and no local edit would ever trigger an instant push. The shared ignore
933
+ // stack (createIgnoreFilter / .hqignore / .hqinclude) already scopes the
934
+ // watch filter correctly in companies mode. personalMode is only for a
935
+ // personal-vault-as-root run, where companies/ et al. genuinely aren't synced.
936
+ const companiesMode = argv.includes("--companies");
937
+ const hqIdx = argv.indexOf("--hq-root");
938
+ const hqRoot = hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
939
+ // Strip the loop-only flags before delegating: the parser inside runRunner
940
+ // accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
941
+ // iteration pass to think it's re-entering watch mode.
850
942
  const passArgv = argv.filter((a, i) => {
851
943
  if (a === "--watch")
852
944
  return false;
853
945
  if (a === "--poll-remote-ms")
854
946
  return false;
947
+ if (a === "--event-push")
948
+ return false;
855
949
  if (i > 0 && argv[i - 1] === "--poll-remote-ms")
856
950
  return false;
857
951
  return true;
858
952
  });
859
- while (true) {
860
- const code = await runRunner(passArgv);
861
- if (code !== 0)
862
- return code;
863
- await new Promise((resolve) => setTimeout(resolve, pollMs));
953
+ // ---- shared in-flight guard ------------------------------------------
954
+ // The poll loop AND watcher-triggered targeted pushes funnel through this
955
+ // mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
956
+ // trigger that arrives while a pass runs is collapsed by WatchPushDriver's
957
+ // own pending-while-pushing logic, then re-armed after the pass settles.
958
+ let inFlight = false;
959
+ let stopped = false;
960
+ const runGuarded = async (pass) => {
961
+ if (inFlight)
962
+ return "skipped";
963
+ inFlight = true;
964
+ try {
965
+ return await pass();
966
+ }
967
+ finally {
968
+ inFlight = false;
969
+ }
970
+ };
971
+ // ---- event-push wiring (Phase 1) -------------------------------------
972
+ let watcher = null;
973
+ let driver = null;
974
+ let detachSignal = null;
975
+ let lastChangedRel = null;
976
+ // ---- pull-on-event receiver (Phase 2, US-009) ------------------------
977
+ // Started after the watcher, disposed before the watcher (mirror of the
978
+ // PushTransport ordering). Dormant by default: the default factory returns
979
+ // a NoopPushReceiver, and even a real receiver stays dormant unless the
980
+ // per-tenant feature flag is on AND a queue URL is provisioned server-side.
981
+ let receiver = null;
982
+ if (eventPush) {
983
+ const clock = deps.clock ?? systemClock;
984
+ const debounceMs = 2000;
985
+ const createWatcher = deps.createWatcher ??
986
+ ((opts) => new TreeWatcher({
987
+ hqRoot: opts.hqRoot,
988
+ debounceMs: opts.debounceMs,
989
+ clock: opts.clock,
990
+ // false in --companies mode so the watch filter matches the sync
991
+ // scope (companies/* are included via .hqinclude); true only for a
992
+ // personal-vault-as-root run.
993
+ personalMode: !companiesMode,
994
+ }));
995
+ watcher = createWatcher({ hqRoot, debounceMs, clock });
996
+ // The driver runs the targeted push when a debounced burst settles. Its
997
+ // concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
998
+ // through `runGuarded` so a poll pass in flight is never overlapped.
999
+ driver = new WatchPushDriver({
1000
+ debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
1001
+ clock,
1002
+ push: async () => {
1003
+ if (stopped)
1004
+ return;
1005
+ const rel = lastChangedRel;
1006
+ const route = rel
1007
+ ? routeChangeToTarget(rel)
1008
+ : { kind: "personal" };
1009
+ if (!route)
1010
+ return;
1011
+ const targetedArgv = buildTargetedPushArgv(route, passArgv);
1012
+ await runGuarded(() => runPass(targetedArgv));
1013
+ },
1014
+ });
1015
+ // A debounced TreeWatcher 'changed' signal feeds the driver, which fires
1016
+ // the targeted push after its own (zero) window — i.e. immediately, but
1017
+ // still serialized behind any in-flight pass. A path-aware watcher passes
1018
+ // the changed relative path so the push targets just its owning company;
1019
+ // the bare-signal TreeWatcher leaves it null → personal-vault route.
1020
+ watcher.onChange((changedRelPath) => {
1021
+ if (stopped)
1022
+ return;
1023
+ lastChangedRel = changedRelPath ?? null;
1024
+ driver?.notifyChange();
1025
+ });
1026
+ watcher.start();
1027
+ // Pull-on-event receiver (US-009). The injected SyncEngineFn bridges a
1028
+ // received PushEvent → a TARGETED pull pass routed by relativePath, funneled
1029
+ // through the SAME `runGuarded` mutex as the poll loop + watcher push so a
1030
+ // pull-on-event never overlaps an in-flight pass. Started AFTER the watcher
1031
+ // so a live event can't race a half-built daemon. Default factory = noop
1032
+ // (dormant); a real SqsPushReceiver is injected by a later release once the
1033
+ // server-side per-client SQS queue is provisioned.
1034
+ const receiverSyncFn = async (ctx) => {
1035
+ if (stopped)
1036
+ return;
1037
+ const route = routeChangeToTarget(ctx.event.relativePath);
1038
+ if (!route)
1039
+ return;
1040
+ const targetedArgv = buildTargetedPullArgv(route, passArgv);
1041
+ await runGuarded(() => runPass(targetedArgv));
1042
+ };
1043
+ const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
1044
+ receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
1045
+ // Fire-and-forget start: a receiver's start() kicks off its own poll loop
1046
+ // (SqsPushReceiver) or trivially flips connected (noop) — it must NOT block
1047
+ // the runner's poll loop from entering. Errors are swallowed; the cadence
1048
+ // poll is the safety net regardless of receiver health. (The await-free
1049
+ // start also keeps the poll loop's microtask timing identical to the
1050
+ // pre-US-009 wiring.)
1051
+ void Promise.resolve(receiver.start()).catch(() => undefined);
1052
+ }
1053
+ // ---- clean shutdown --------------------------------------------------
1054
+ // SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
1055
+ // the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
1056
+ // watcher + driver, and let the poll loop observe `stopped` on its next tick.
1057
+ let resolveStopped = null;
1058
+ const stoppedSignal = new Promise((resolve) => {
1059
+ resolveStopped = resolve;
1060
+ });
1061
+ const shutdown = () => {
1062
+ if (stopped)
1063
+ return;
1064
+ stopped = true;
1065
+ // Dispose the receiver FIRST (mirror of the PushTransport ordering:
1066
+ // inbound subscription torn down before the watcher) so no new
1067
+ // pull-on-event fires mid-teardown. dispose() is async (it drains the
1068
+ // in-flight pull up to its own deadline); fire-and-forget here — the
1069
+ // receiver's internal drain + the runGuarded mutex bound the work, and
1070
+ // SIGTERM teardown must not block. Errors are swallowed.
1071
+ try {
1072
+ void receiver?.dispose();
1073
+ }
1074
+ catch {
1075
+ /* ignore */
1076
+ }
1077
+ try {
1078
+ driver?.dispose();
1079
+ }
1080
+ catch {
1081
+ /* ignore */
1082
+ }
1083
+ try {
1084
+ watcher?.dispose();
1085
+ }
1086
+ catch {
1087
+ /* ignore */
1088
+ }
1089
+ resolveStopped?.();
1090
+ };
1091
+ const onShutdownSignal = deps.onShutdownSignal ??
1092
+ ((handler) => {
1093
+ const wrapped = () => handler();
1094
+ process.on("SIGTERM", wrapped);
1095
+ process.on("SIGINT", wrapped);
1096
+ return () => {
1097
+ process.off("SIGTERM", wrapped);
1098
+ process.off("SIGINT", wrapped);
1099
+ };
1100
+ });
1101
+ detachSignal = onShutdownSignal(shutdown);
1102
+ try {
1103
+ while (!stopped) {
1104
+ const result = await runGuarded(() => runPass(passArgv));
1105
+ // A poll pass that was skipped because a watcher push held the guard is
1106
+ // benign — the next iteration retries after the poll interval.
1107
+ if (typeof result === "number" && result !== 0) {
1108
+ return result;
1109
+ }
1110
+ // Sleep the poll interval, but wake early on shutdown so SIGTERM stops
1111
+ // the loop promptly instead of waiting out a 10-minute cycle.
1112
+ await Promise.race([sleep(pollMs), stoppedSignal]);
1113
+ }
1114
+ return 0;
1115
+ }
1116
+ finally {
1117
+ shutdown();
1118
+ detachSignal?.();
1119
+ }
1120
+ }
1121
+ /**
1122
+ * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
1123
+ * re-targeted push pass inherits the same root and conflict policy. Pure
1124
+ * helper used by the event-push targeted-push composer.
1125
+ */
1126
+ function carriedFlags(baseArgv) {
1127
+ const carried = [];
1128
+ for (let i = 0; i < baseArgv.length; i++) {
1129
+ const a = baseArgv[i];
1130
+ if (a === "--hq-root" || a === "--on-conflict") {
1131
+ carried.push(a);
1132
+ if (baseArgv[i + 1] !== undefined)
1133
+ carried.push(baseArgv[++i]);
1134
+ }
864
1135
  }
1136
+ return carried;
865
1137
  }
866
1138
  if (isDirectInvocation) {
867
1139
  runRunnerWithLoop(process.argv.slice(2))