@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
@@ -88,6 +88,17 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
88
88
  import type { ConflictStrategy } from "../cli/conflict.js";
89
89
  import type { UploadAuthor } from "../s3.js";
90
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
+ import {
92
+ TreeWatcher,
93
+ WatchPushDriver,
94
+ systemClock,
95
+ type Clock,
96
+ } from "../watcher.js";
97
+ import {
98
+ NoopPushReceiver,
99
+ type PushReceiver,
100
+ type SyncEngineFn,
101
+ } from "../sync/push-receiver.js";
91
102
 
92
103
  /**
93
104
  * Sync direction for a run.
@@ -442,6 +453,15 @@ interface ParsedArgs {
442
453
  watch: boolean;
443
454
  /** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
444
455
  pollRemoteMs?: number;
456
+ /**
457
+ * Event-driven push (Phase 1). When set (and `--watch` is on), the runner
458
+ * starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
459
+ * company/subtree within the debounce window of a local edit — instead of
460
+ * waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
461
+ * menubar passes it only for `@getindigo.ai` identities (see PRD decision).
462
+ * No-op without `--watch` (the one-shot path has nothing to keep alive).
463
+ */
464
+ eventPush: boolean;
445
465
  /**
446
466
  * Drop the personal target from the fanout. Combined with the
447
467
  * `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
@@ -460,6 +480,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
460
480
  let watch = false;
461
481
  let pollRemoteMs: number | undefined;
462
482
  let skipPersonal = false;
483
+ let eventPush = false;
463
484
 
464
485
  for (let i = 0; i < argv.length; i++) {
465
486
  const arg = argv[i];
@@ -519,6 +540,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
519
540
  // resolveSkipPersonal().
520
541
  skipPersonal = true;
521
542
  break;
543
+ case "--event-push":
544
+ // Phase 1 event-driven push enable flag. Requires --watch (validated
545
+ // below). Gated OFF by default; the menubar only passes it for
546
+ // @getindigo.ai identities for the first release.
547
+ eventPush = true;
548
+ break;
522
549
  default:
523
550
  return { error: `Unknown argument: ${arg}` };
524
551
  }
@@ -533,8 +560,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
533
560
  if (pollRemoteMs !== undefined && !watch) {
534
561
  return { error: "--poll-remote-ms requires --watch" };
535
562
  }
563
+ if (eventPush && !watch) {
564
+ return { error: "--event-push requires --watch" };
565
+ }
536
566
 
537
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs, skipPersonal };
567
+ return {
568
+ companies,
569
+ company,
570
+ onConflict,
571
+ hqRoot,
572
+ direction,
573
+ watch,
574
+ pollRemoteMs,
575
+ skipPersonal,
576
+ eventPush,
577
+ };
538
578
  }
539
579
 
540
580
  // ---------------------------------------------------------------------------
@@ -1169,29 +1209,386 @@ const isDirectInvocation = (() => {
1169
1209
  * exit 0 today and so will retry — acceptable noise for the beta; deal with
1170
1210
  * it via a richer return shape if it shows up in Sentry.
1171
1211
  */
1172
- export async function runRunnerWithLoop(argv: string[]): Promise<number> {
1212
+ /**
1213
+ * Test/event-driven seam (US-001).
1214
+ *
1215
+ * `runRunnerWithLoop` performs an unbounded poll loop in production. To make
1216
+ * the loop deterministically testable (US-003 wires the event-driven watcher
1217
+ * into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
1218
+ * The default uses the host timer and preserves exact production behavior.
1219
+ *
1220
+ * A test (or US-003's wiring) injects a fake sleep that resolves immediately
1221
+ * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
1222
+ * so the loop can be exercised without a real 10-minute wait.
1223
+ */
1224
+ export interface RunnerLoopDeps {
1225
+ /** Sleep `ms` between passes. Default: host setTimeout. */
1226
+ sleep?: (ms: number) => Promise<void>;
1227
+ /**
1228
+ * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
1229
+ * (and the event-push wiring) so the poll loop and the watcher-triggered
1230
+ * targeted push share one seam and one in-flight guard. The default ignores
1231
+ * `deps` and forwards just the argv to `runRunner`.
1232
+ */
1233
+ runPass?: (passArgv: string[]) => Promise<number>;
1234
+ /**
1235
+ * Clock seam for the event-push watcher's debounce window. Defaults to
1236
+ * {@link systemClock}; tests inject a `FakeClock` to advance the window
1237
+ * deterministically. Only consulted when `--event-push` is on.
1238
+ */
1239
+ clock?: Clock;
1240
+ /**
1241
+ * Factory for the file watcher used in event-push mode. Defaults to a real
1242
+ * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
1243
+ * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
1244
+ */
1245
+ createWatcher?: (opts: {
1246
+ hqRoot: string;
1247
+ debounceMs: number;
1248
+ clock: Clock;
1249
+ }) => WatcherSurface;
1250
+ /**
1251
+ * Register a one-shot shutdown signal handler. Defaults to listening for
1252
+ * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
1253
+ * clean teardown without sending real signals. The returned fn detaches the
1254
+ * handler (called during teardown so tests don't leak listeners).
1255
+ */
1256
+ onShutdownSignal?: (handler: () => void) => () => void;
1257
+ /**
1258
+ * Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
1259
+ * {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
1260
+ * the lifecycle (start after the watcher, dispose before exit) but stays
1261
+ * DORMANT by default: the per-client SQS queue is provisioned server-side
1262
+ * (an unbuilt follow-up) and the receiver is feature-flag gated. A future
1263
+ * menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
1264
+ * URL is available. Only consulted when `--event-push` is on.
1265
+ *
1266
+ * The factory is handed a {@link SyncEngineFn} that bridges a received
1267
+ * PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
1268
+ * or a personal `--companies --direction pull`) routed by the event's
1269
+ * `relativePath`, funneled through the same in-flight guard as the poll
1270
+ * loop and the watcher push so a pull-on-event never overlaps an in-flight
1271
+ * pass.
1272
+ */
1273
+ createReceiver?: (opts: {
1274
+ syncFn: SyncEngineFn;
1275
+ hqRoot: string;
1276
+ }) => PushReceiver;
1277
+ }
1278
+
1279
+ /**
1280
+ * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
1281
+ * it; tests inject a lighter stub. Kept narrow so the loop never reaches past
1282
+ * the lifecycle + change-subscription contract.
1283
+ *
1284
+ * `onChange`'s listener receives an OPTIONAL changed relative path. The real
1285
+ * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
1286
+ * the loop routes the targeted push to the personal vault. A path-aware
1287
+ * watcher (or a test stub) can pass the changed `companies/<slug>/...`
1288
+ * relative path so the loop targets just that company.
1289
+ */
1290
+ export interface WatcherSurface {
1291
+ onChange(listener: (changedRelPath?: string) => void): () => void;
1292
+ start(): void;
1293
+ stop(): void;
1294
+ dispose(): void;
1295
+ }
1296
+
1297
+ /**
1298
+ * Route a changed relative path to the push target that owns it.
1299
+ *
1300
+ * - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
1301
+ * pass per PRD decision — push only the changed company, not a full
1302
+ * `--companies` fanout).
1303
+ * - anything else under hqRoot → the personal target (a `--companies` push
1304
+ * restricted to personal via the personal-vault scope; modeled here as the
1305
+ * "personal" route the caller maps to the right argv).
1306
+ *
1307
+ * Returns `null` for paths the routing cannot attribute (defensive — the
1308
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
1309
+ * suspenders for synthetic events).
1310
+ */
1311
+ export function routeChangeToTarget(
1312
+ relPath: string,
1313
+ ): { kind: "company"; slug: string } | { kind: "personal" } | null {
1314
+ const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
1315
+ if (norm === "" || norm.startsWith("..")) return null;
1316
+ const segments = norm.split("/").filter((s) => s.length > 0);
1317
+ if (segments.length === 0) return null;
1318
+ if (segments[0] === "companies") {
1319
+ // companies/<slug>/... — need at least the slug segment to target.
1320
+ if (segments.length < 2 || segments[1].length === 0) return null;
1321
+ return { kind: "company", slug: segments[1] };
1322
+ }
1323
+ return { kind: "personal" };
1324
+ }
1325
+
1326
+ /**
1327
+ * Build the argv for a targeted push pass from a routed change. The push runs
1328
+ * `--direction push` for just the routed target so a local edit propagates in
1329
+ * seconds without a full fanout. Company routes use `--company <slug>`;
1330
+ * personal routes use `--companies --direction push` (the personal-vault scope
1331
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
1332
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
1333
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
1334
+ */
1335
+ export function buildTargetedPushArgv(
1336
+ route: { kind: "company"; slug: string } | { kind: "personal" },
1337
+ baseArgv: string[],
1338
+ ): string[] {
1339
+ const carried = carriedFlags(baseArgv);
1340
+ if (route.kind === "company") {
1341
+ return ["--company", route.slug, "--direction", "push", ...carried];
1342
+ }
1343
+ return ["--companies", "--direction", "push", ...carried];
1344
+ }
1345
+
1346
+ /**
1347
+ * Build the argv for a targeted PULL pass from a routed change (US-009 — the
1348
+ * receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
1349
+ * with `--direction pull`: a peer device pushed a change, so this device pulls
1350
+ * just the affected company/subtree instead of waiting for the next
1351
+ * `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
1352
+ * routes use `--companies` (the personal-vault scope is resolved inside
1353
+ * runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
1354
+ * Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
1355
+ * exported for unit testing the event→argv map.
1356
+ */
1357
+ export function buildTargetedPullArgv(
1358
+ route: { kind: "company"; slug: string } | { kind: "personal" },
1359
+ baseArgv: string[],
1360
+ ): string[] {
1361
+ const carried = carriedFlags(baseArgv);
1362
+ if (route.kind === "company") {
1363
+ return ["--company", route.slug, "--direction", "pull", ...carried];
1364
+ }
1365
+ return ["--companies", "--direction", "pull", ...carried];
1366
+ }
1367
+
1368
+ export async function runRunnerWithLoop(
1369
+ argv: string[],
1370
+ deps: RunnerLoopDeps = {},
1371
+ ): Promise<number> {
1173
1372
  if (!argv.includes("--watch")) {
1174
1373
  return runRunner(argv);
1175
1374
  }
1375
+ const sleep =
1376
+ deps.sleep ??
1377
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1378
+ const runPass = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
1176
1379
  const pollIdx = argv.indexOf("--poll-remote-ms");
1177
1380
  const pollMs =
1178
1381
  pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
1382
+ const eventPush = argv.includes("--event-push");
1383
+ // In `--companies` mode the sync scope is companies/*/{knowledge,projects,…}
1384
+ // (per .hqinclude), so the watcher must NOT apply the personal-vault
1385
+ // top-level exclusions (PERSONAL_VAULT_EXCLUDED_TOP_LEVEL drops `companies/`
1386
+ // and `workspace/`) — doing so would exclude exactly the paths being synced,
1387
+ // and no local edit would ever trigger an instant push. The shared ignore
1388
+ // stack (createIgnoreFilter / .hqignore / .hqinclude) already scopes the
1389
+ // watch filter correctly in companies mode. personalMode is only for a
1390
+ // personal-vault-as-root run, where companies/ et al. genuinely aren't synced.
1391
+ const companiesMode = argv.includes("--companies");
1392
+ const hqIdx = argv.indexOf("--hq-root");
1393
+ const hqRoot =
1394
+ hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
1179
1395
 
1180
- // Strip --watch / --poll-remote-ms before delegating: the parser inside
1181
- // runRunner accepts them, but we don't want runRunner to think it's
1182
- // re-entering watch mode each iteration.
1396
+ // Strip the loop-only flags before delegating: the parser inside runRunner
1397
+ // accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
1398
+ // iteration pass to think it's re-entering watch mode.
1183
1399
  const passArgv = argv.filter((a, i) => {
1184
1400
  if (a === "--watch") return false;
1185
1401
  if (a === "--poll-remote-ms") return false;
1402
+ if (a === "--event-push") return false;
1186
1403
  if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
1187
1404
  return true;
1188
1405
  });
1189
1406
 
1190
- while (true) {
1191
- const code = await runRunner(passArgv);
1192
- if (code !== 0) return code;
1193
- await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
1407
+ // ---- shared in-flight guard ------------------------------------------
1408
+ // The poll loop AND watcher-triggered targeted pushes funnel through this
1409
+ // mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
1410
+ // trigger that arrives while a pass runs is collapsed by WatchPushDriver's
1411
+ // own pending-while-pushing logic, then re-armed after the pass settles.
1412
+ let inFlight = false;
1413
+ let stopped = false;
1414
+ const runGuarded = async (
1415
+ pass: () => Promise<number>,
1416
+ ): Promise<number | "skipped"> => {
1417
+ if (inFlight) return "skipped";
1418
+ inFlight = true;
1419
+ try {
1420
+ return await pass();
1421
+ } finally {
1422
+ inFlight = false;
1423
+ }
1424
+ };
1425
+
1426
+ // ---- event-push wiring (Phase 1) -------------------------------------
1427
+ let watcher: WatcherSurface | null = null;
1428
+ let driver: WatchPushDriver | null = null;
1429
+ let detachSignal: (() => void) | null = null;
1430
+ let lastChangedRel: string | null = null;
1431
+ // ---- pull-on-event receiver (Phase 2, US-009) ------------------------
1432
+ // Started after the watcher, disposed before the watcher (mirror of the
1433
+ // PushTransport ordering). Dormant by default: the default factory returns
1434
+ // a NoopPushReceiver, and even a real receiver stays dormant unless the
1435
+ // per-tenant feature flag is on AND a queue URL is provisioned server-side.
1436
+ let receiver: PushReceiver | null = null;
1437
+
1438
+ if (eventPush) {
1439
+ const clock = deps.clock ?? systemClock;
1440
+ const debounceMs = 2000;
1441
+ const createWatcher =
1442
+ deps.createWatcher ??
1443
+ ((opts) =>
1444
+ new TreeWatcher({
1445
+ hqRoot: opts.hqRoot,
1446
+ debounceMs: opts.debounceMs,
1447
+ clock: opts.clock,
1448
+ // false in --companies mode so the watch filter matches the sync
1449
+ // scope (companies/* are included via .hqinclude); true only for a
1450
+ // personal-vault-as-root run.
1451
+ personalMode: !companiesMode,
1452
+ }));
1453
+ watcher = createWatcher({ hqRoot, debounceMs, clock });
1454
+
1455
+ // The driver runs the targeted push when a debounced burst settles. Its
1456
+ // concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
1457
+ // through `runGuarded` so a poll pass in flight is never overlapped.
1458
+ driver = new WatchPushDriver({
1459
+ debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
1460
+ clock,
1461
+ push: async () => {
1462
+ if (stopped) return;
1463
+ const rel = lastChangedRel;
1464
+ const route = rel
1465
+ ? routeChangeToTarget(rel)
1466
+ : { kind: "personal" as const };
1467
+ if (!route) return;
1468
+ const targetedArgv = buildTargetedPushArgv(route, passArgv);
1469
+ await runGuarded(() => runPass(targetedArgv));
1470
+ },
1471
+ });
1472
+
1473
+ // A debounced TreeWatcher 'changed' signal feeds the driver, which fires
1474
+ // the targeted push after its own (zero) window — i.e. immediately, but
1475
+ // still serialized behind any in-flight pass. A path-aware watcher passes
1476
+ // the changed relative path so the push targets just its owning company;
1477
+ // the bare-signal TreeWatcher leaves it null → personal-vault route.
1478
+ watcher.onChange((changedRelPath) => {
1479
+ if (stopped) return;
1480
+ lastChangedRel = changedRelPath ?? null;
1481
+ driver?.notifyChange();
1482
+ });
1483
+ watcher.start();
1484
+
1485
+ // Pull-on-event receiver (US-009). The injected SyncEngineFn bridges a
1486
+ // received PushEvent → a TARGETED pull pass routed by relativePath, funneled
1487
+ // through the SAME `runGuarded` mutex as the poll loop + watcher push so a
1488
+ // pull-on-event never overlaps an in-flight pass. Started AFTER the watcher
1489
+ // so a live event can't race a half-built daemon. Default factory = noop
1490
+ // (dormant); a real SqsPushReceiver is injected by a later release once the
1491
+ // server-side per-client SQS queue is provisioned.
1492
+ const receiverSyncFn: SyncEngineFn = async (ctx) => {
1493
+ if (stopped) return;
1494
+ const route = routeChangeToTarget(ctx.event.relativePath);
1495
+ if (!route) return;
1496
+ const targetedArgv = buildTargetedPullArgv(route, passArgv);
1497
+ await runGuarded(() => runPass(targetedArgv));
1498
+ };
1499
+ const createReceiver =
1500
+ deps.createReceiver ?? (() => new NoopPushReceiver());
1501
+ receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
1502
+ // Fire-and-forget start: a receiver's start() kicks off its own poll loop
1503
+ // (SqsPushReceiver) or trivially flips connected (noop) — it must NOT block
1504
+ // the runner's poll loop from entering. Errors are swallowed; the cadence
1505
+ // poll is the safety net regardless of receiver health. (The await-free
1506
+ // start also keeps the poll loop's microtask timing identical to the
1507
+ // pre-US-009 wiring.)
1508
+ void Promise.resolve(receiver.start()).catch(() => undefined);
1509
+ }
1510
+
1511
+ // ---- clean shutdown --------------------------------------------------
1512
+ // SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
1513
+ // the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
1514
+ // watcher + driver, and let the poll loop observe `stopped` on its next tick.
1515
+ let resolveStopped: (() => void) | null = null;
1516
+ const stoppedSignal = new Promise<void>((resolve) => {
1517
+ resolveStopped = resolve;
1518
+ });
1519
+ const shutdown = (): void => {
1520
+ if (stopped) return;
1521
+ stopped = true;
1522
+ // Dispose the receiver FIRST (mirror of the PushTransport ordering:
1523
+ // inbound subscription torn down before the watcher) so no new
1524
+ // pull-on-event fires mid-teardown. dispose() is async (it drains the
1525
+ // in-flight pull up to its own deadline); fire-and-forget here — the
1526
+ // receiver's internal drain + the runGuarded mutex bound the work, and
1527
+ // SIGTERM teardown must not block. Errors are swallowed.
1528
+ try {
1529
+ void receiver?.dispose();
1530
+ } catch {
1531
+ /* ignore */
1532
+ }
1533
+ try {
1534
+ driver?.dispose();
1535
+ } catch {
1536
+ /* ignore */
1537
+ }
1538
+ try {
1539
+ watcher?.dispose();
1540
+ } catch {
1541
+ /* ignore */
1542
+ }
1543
+ resolveStopped?.();
1544
+ };
1545
+ const onShutdownSignal =
1546
+ deps.onShutdownSignal ??
1547
+ ((handler: () => void) => {
1548
+ const wrapped = () => handler();
1549
+ process.on("SIGTERM", wrapped);
1550
+ process.on("SIGINT", wrapped);
1551
+ return () => {
1552
+ process.off("SIGTERM", wrapped);
1553
+ process.off("SIGINT", wrapped);
1554
+ };
1555
+ });
1556
+ detachSignal = onShutdownSignal(shutdown);
1557
+
1558
+ try {
1559
+ while (!stopped) {
1560
+ const result = await runGuarded(() => runPass(passArgv));
1561
+ // A poll pass that was skipped because a watcher push held the guard is
1562
+ // benign — the next iteration retries after the poll interval.
1563
+ if (typeof result === "number" && result !== 0) {
1564
+ return result;
1565
+ }
1566
+ // Sleep the poll interval, but wake early on shutdown so SIGTERM stops
1567
+ // the loop promptly instead of waiting out a 10-minute cycle.
1568
+ await Promise.race([sleep(pollMs), stoppedSignal]);
1569
+ }
1570
+ return 0;
1571
+ } finally {
1572
+ shutdown();
1573
+ detachSignal?.();
1574
+ }
1575
+ }
1576
+
1577
+ /**
1578
+ * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
1579
+ * re-targeted push pass inherits the same root and conflict policy. Pure
1580
+ * helper used by the event-push targeted-push composer.
1581
+ */
1582
+ function carriedFlags(baseArgv: string[]): string[] {
1583
+ const carried: string[] = [];
1584
+ for (let i = 0; i < baseArgv.length; i++) {
1585
+ const a = baseArgv[i];
1586
+ if (a === "--hq-root" || a === "--on-conflict") {
1587
+ carried.push(a);
1588
+ if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
1589
+ }
1194
1590
  }
1591
+ return carried;
1195
1592
  }
1196
1593
 
1197
1594
  if (isDirectInvocation) {
package/src/index.ts CHANGED
@@ -281,3 +281,41 @@ export {
281
281
  _setSignalsS3Factory,
282
282
  _resetSignalsS3Factory,
283
283
  } from "./signals/internals.js";
284
+
285
+ // Event-driven sync — PushEvent wire contract + PushTransport shipping seam
286
+ // (ported from hq-pro PR #112 per project event-driven-sync-menubar US-007).
287
+ export {
288
+ CONTENT_HASH_PATTERN,
289
+ ISO8601_DATETIME_PATTERN,
290
+ PushEventSchema,
291
+ PushEventDecodeError,
292
+ encodePushEvent,
293
+ decodePushEvent,
294
+ NoopPushTransport,
295
+ HttpPushTransport,
296
+ FEATURE_FLAG_TENANTS_ENV_VAR,
297
+ FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
298
+ EnvTenantListFlagProvider,
299
+ StaticFlagProvider,
300
+ defaultFlagProvider,
301
+ } from "./sync/index.js";
302
+ export type {
303
+ PushEvent,
304
+ PushEventDecodeIssue,
305
+ PushTransport,
306
+ HttpPushTransportOptions,
307
+ AuthTokenSource,
308
+ FetchLike,
309
+ EventDrivenPushFlagProvider,
310
+ FlagProviderWarnFn,
311
+ EnvTenantListFlagProviderOptions,
312
+ } from "./sync/index.js";
313
+
314
+ // US-008 — watcher PushEvent emitter (bridges TreeWatcher → PushTransport,
315
+ // flag-gated, failure-safe).
316
+ export { PushEventEmitter } from "./watcher.js";
317
+ export type {
318
+ PushEventEmitterOptions,
319
+ TreeChangeBatch,
320
+ TreeChangeListener,
321
+ } from "./watcher.js";