@indigoai-us/hq-cloud 5.24.0 → 5.26.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 (66) hide show
  1. package/dist/bin/sync-runner.d.ts +151 -17
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +280 -18
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +429 -15
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +9 -0
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +54 -1
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +6 -3
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +21 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +6 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/personal-vault-exclusions.d.ts +128 -0
  21. package/dist/personal-vault-exclusions.d.ts.map +1 -0
  22. package/dist/personal-vault-exclusions.js +231 -0
  23. package/dist/personal-vault-exclusions.js.map +1 -0
  24. package/dist/personal-vault-exclusions.test.d.ts +22 -0
  25. package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
  26. package/dist/personal-vault-exclusions.test.js +198 -0
  27. package/dist/personal-vault-exclusions.test.js.map +1 -0
  28. package/dist/sync/index.d.ts +11 -0
  29. package/dist/sync/index.d.ts.map +1 -0
  30. package/dist/sync/index.js +9 -0
  31. package/dist/sync/index.js.map +1 -0
  32. package/dist/sync/push-event.d.ts +110 -0
  33. package/dist/sync/push-event.d.ts.map +1 -0
  34. package/dist/sync/push-event.js +153 -0
  35. package/dist/sync/push-event.js.map +1 -0
  36. package/dist/sync/push-event.test.d.ts +15 -0
  37. package/dist/sync/push-event.test.d.ts.map +1 -0
  38. package/dist/sync/push-event.test.js +188 -0
  39. package/dist/sync/push-event.test.js.map +1 -0
  40. package/dist/sync/push-transport.d.ts +67 -0
  41. package/dist/sync/push-transport.d.ts.map +1 -0
  42. package/dist/sync/push-transport.js +66 -0
  43. package/dist/sync/push-transport.js.map +1 -0
  44. package/dist/watcher.d.ts +160 -0
  45. package/dist/watcher.d.ts.map +1 -1
  46. package/dist/watcher.js +298 -0
  47. package/dist/watcher.js.map +1 -1
  48. package/dist/watcher.test.d.ts +2 -0
  49. package/dist/watcher.test.d.ts.map +1 -0
  50. package/dist/watcher.test.js +334 -0
  51. package/dist/watcher.test.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/bin/sync-runner.test.ts +557 -15
  54. package/src/bin/sync-runner.ts +404 -27
  55. package/src/cli/share.test.ts +8 -3
  56. package/src/cli/share.ts +66 -1
  57. package/src/cli/sync.ts +22 -0
  58. package/src/index.ts +27 -0
  59. package/src/personal-vault-exclusions.test.ts +256 -0
  60. package/src/personal-vault-exclusions.ts +277 -0
  61. package/src/sync/index.ts +19 -0
  62. package/src/sync/push-event.test.ts +224 -0
  63. package/src/sync/push-event.ts +208 -0
  64. package/src/sync/push-transport.ts +84 -0
  65. package/src/watcher.test.ts +388 -0
  66. package/src/watcher.ts +386 -0
@@ -14,6 +14,10 @@
14
14
  * --company <slug-or-uid> Sync a single company (alternative to --companies)
15
15
  * --on-conflict <strategy> abort | overwrite | keep (default: abort)
16
16
  * --hq-root <path> Local HQ directory (default: $HOME/hq)
17
+ * --skip-personal Drop the personal target from the --companies
18
+ * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
19
+ * (either truthy disables personal sync). No-op
20
+ * outside --companies mode.
17
21
  * --json Ignored — ndjson on stdout is the default and
18
22
  * only output mode. Accepted for symmetry with the
19
23
  * AppBar's argv in case someone passes it.
@@ -55,6 +59,7 @@ import { type VaultServiceConfig, type Membership, type EntityInfo, type Pending
55
59
  import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths } from "../personal-vault.js";
56
60
  import type { SyncOptions, SyncResult, SyncProgressEvent } from "../cli/sync.js";
57
61
  import type { ShareOptions, ShareResult } from "../cli/share.js";
62
+ import { type Clock } from "../watcher.js";
58
63
  /**
59
64
  * Sync direction for a run.
60
65
  *
@@ -70,24 +75,48 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
70
75
  export type Direction = "pull" | "push" | "both";
71
76
  /**
72
77
  * Delete-propagation policy honored by the push leg of bidirectional sync.
73
- * Default `"owned-only"` in 5.24 — the pre-5.24 behavior is preserved so
74
- * existing users see zero change in delete-propagation semantics. The
75
- * stricter-and-safer `"currency-gated"` policy ships in 5.24 as opt-in
76
- * (set `HQ_SYNC_DELETE_POLICY=currency-gated` to enable per-file ETag
77
- * verification before any local-delete propagates to S3). Default flip
78
- * to `"currency-gated"` is scheduled for 5.25 after a soak period during
79
- * which currency-gated behavior is observed on at least one active
80
- * machine.
78
+ *
79
+ * Default `"currency-gated"` in 5.25 flipped from `"owned-only"` after
80
+ * one machine (Indigo / corey) ran the 5.24 code path through real syncs
81
+ * for a week without surfacing surprise behavior. Currency-gated does a
82
+ * per-file ETag HEAD before propagating any local-delete to S3: if the
83
+ * remote object's current ETag no longer matches the journal's last-
84
+ * recorded one, the delete is refused and the next pull leg re-pulls the
85
+ * file via the standard 3-way merge path. This is strictly safer than
86
+ * `owned-only` (which propagates any local-delete the journal can prove
87
+ * came from this device) — the only delete-class that changes behavior
88
+ * is "deleted locally + modified remotely by another device", which
89
+ * previously destroyed remote work and now becomes a pull-and-conflict.
81
90
  *
82
91
  * Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
83
- * also the rollback knob — once 5.25 flips the default, anyone surprised
84
- * by the new behavior flips the env back to `owned-only` without
85
- * re-deploying. `all` is the unsafe-mirror mode previously used by the
86
- * runner pre-5.20 — included only as an emergency reconcile lever, not a
87
- * recommended default.
92
+ * also the rollback knob — anyone surprised by 5.25's flip can revert
93
+ * to `owned-only` without redeploying. `all` is the unsafe-mirror mode
94
+ * previously used by the runner pre-5.20 included only as an
95
+ * emergency reconcile lever, not a recommended default.
88
96
  */
89
97
  export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
90
98
  export declare function resolveDeletePolicy(): DeletePropagationPolicy;
99
+ /**
100
+ * Resolve whether to skip the personal target in a `--companies` fanout.
101
+ *
102
+ * Two inputs combine: the `--skip-personal` CLI flag (parsed into
103
+ * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
104
+ * being truthy skips the personal target — flag wins on conflict (CLI
105
+ * flag is the explicit-for-this-invocation knob, env is the persistent
106
+ * default usually set by the menubar in the spawned child process).
107
+ *
108
+ * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
109
+ * (including missing) is treated as falsy — same shape as classic
110
+ * Unix opt-in env conventions; conservative to avoid surprising opt-outs.
111
+ *
112
+ * Use case: the menubar app exposes a "Sync personal vault" toggle in
113
+ * Settings (default ON, matching the auto-provisioning UX). When the user
114
+ * flips it off, the menubar spawns `hq sync` with this env set so the
115
+ * fanout drops the personal target before walking the user's entire HQ
116
+ * tree (a sync that would otherwise scan thousands of files, including
117
+ * the new personal-vault default exclusions, just to do nothing useful).
118
+ */
119
+ export declare function resolveSkipPersonal(flag: boolean): boolean;
91
120
  export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
92
121
  /**
93
122
  * Every event the runner emits. Channel routing (stdout vs stderr) is
@@ -153,12 +182,19 @@ export type RunnerEvent = {
153
182
  /**
154
183
  * Upload counters. Always emitted (0 when the run was pull-only) so
155
184
  * downstream consumers don't need to conditionally read the field.
156
- * Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
157
- * to the Rust struct is a follow-up when the UI needs to surface push
158
- * totals.
159
185
  */
160
186
  filesUploaded: number;
161
187
  bytesUploaded: number;
188
+ /**
189
+ * Push-side counters added in 5.25. Always emitted as numbers (0
190
+ * when no push leg ran). Tauri's `SyncCompleteEvent` carries them
191
+ * as Option<u32> for back-compat with <5.25 engines that don't
192
+ * include them; structural-typing-wise, the union just adds
193
+ * properties on top of `SyncResult`.
194
+ */
195
+ filesTombstoned: number;
196
+ filesRefusedStale: number;
197
+ filesExcludedByPolicy: number;
162
198
  } & SyncResult) | {
163
199
  type: "all-complete";
164
200
  companiesAttempted: number;
@@ -289,5 +325,103 @@ export declare function runRunner(argv: string[], deps?: RunnerDeps): Promise<nu
289
325
  * exit 0 today and so will retry — acceptable noise for the beta; deal with
290
326
  * it via a richer return shape if it shows up in Sentry.
291
327
  */
292
- export declare function runRunnerWithLoop(argv: string[]): Promise<number>;
328
+ /**
329
+ * Test/event-driven seam (US-001).
330
+ *
331
+ * `runRunnerWithLoop` performs an unbounded poll loop in production. To make
332
+ * the loop deterministically testable (US-003 wires the event-driven watcher
333
+ * into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
334
+ * The default uses the host timer and preserves exact production behavior.
335
+ *
336
+ * A test (or US-003's wiring) injects a fake sleep that resolves immediately
337
+ * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
338
+ * so the loop can be exercised without a real 10-minute wait.
339
+ */
340
+ export interface RunnerLoopDeps {
341
+ /** Sleep `ms` between passes. Default: host setTimeout. */
342
+ sleep?: (ms: number) => Promise<void>;
343
+ /**
344
+ * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
345
+ * (and the event-push wiring) so the poll loop and the watcher-triggered
346
+ * targeted push share one seam and one in-flight guard. The default ignores
347
+ * `deps` and forwards just the argv to `runRunner`.
348
+ */
349
+ runPass?: (passArgv: string[]) => Promise<number>;
350
+ /**
351
+ * Clock seam for the event-push watcher's debounce window. Defaults to
352
+ * {@link systemClock}; tests inject a `FakeClock` to advance the window
353
+ * deterministically. Only consulted when `--event-push` is on.
354
+ */
355
+ clock?: Clock;
356
+ /**
357
+ * Factory for the file watcher used in event-push mode. Defaults to a real
358
+ * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
359
+ * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
360
+ */
361
+ createWatcher?: (opts: {
362
+ hqRoot: string;
363
+ debounceMs: number;
364
+ clock: Clock;
365
+ }) => WatcherSurface;
366
+ /**
367
+ * Register a one-shot shutdown signal handler. Defaults to listening for
368
+ * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
369
+ * clean teardown without sending real signals. The returned fn detaches the
370
+ * handler (called during teardown so tests don't leak listeners).
371
+ */
372
+ onShutdownSignal?: (handler: () => void) => () => void;
373
+ }
374
+ /**
375
+ * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
376
+ * it; tests inject a lighter stub. Kept narrow so the loop never reaches past
377
+ * the lifecycle + change-subscription contract.
378
+ *
379
+ * `onChange`'s listener receives an OPTIONAL changed relative path. The real
380
+ * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
381
+ * the loop routes the targeted push to the personal vault. A path-aware
382
+ * watcher (or a test stub) can pass the changed `companies/<slug>/...`
383
+ * relative path so the loop targets just that company.
384
+ */
385
+ export interface WatcherSurface {
386
+ onChange(listener: (changedRelPath?: string) => void): () => void;
387
+ start(): void;
388
+ stop(): void;
389
+ dispose(): void;
390
+ }
391
+ /**
392
+ * Route a changed relative path to the push target that owns it.
393
+ *
394
+ * - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
395
+ * pass per PRD decision — push only the changed company, not a full
396
+ * `--companies` fanout).
397
+ * - anything else under hqRoot → the personal target (a `--companies` push
398
+ * restricted to personal via the personal-vault scope; modeled here as the
399
+ * "personal" route the caller maps to the right argv).
400
+ *
401
+ * Returns `null` for paths the routing cannot attribute (defensive — the
402
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
403
+ * suspenders for synthetic events).
404
+ */
405
+ export declare function routeChangeToTarget(relPath: string): {
406
+ kind: "company";
407
+ slug: string;
408
+ } | {
409
+ kind: "personal";
410
+ } | null;
411
+ /**
412
+ * Build the argv for a targeted push pass from a routed change. The push runs
413
+ * `--direction push` for just the routed target so a local edit propagates in
414
+ * seconds without a full fanout. Company routes use `--company <slug>`;
415
+ * personal routes use `--companies --direction push` (the personal-vault scope
416
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
417
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
418
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
419
+ */
420
+ export declare function buildTargetedPushArgv(route: {
421
+ kind: "company";
422
+ slug: string;
423
+ } | {
424
+ kind: "personal";
425
+ }, baseArgv: string[]): string[];
426
+ export declare function runRunnerWithLoop(argv: string[], deps?: RunnerLoopDeps): Promise<number>;
293
427
  //# 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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"}
@@ -14,6 +14,10 @@
14
14
  * --company <slug-or-uid> Sync a single company (alternative to --companies)
15
15
  * --on-conflict <strategy> abort | overwrite | keep (default: abort)
16
16
  * --hq-root <path> Local HQ directory (default: $HOME/hq)
17
+ * --skip-personal Drop the personal target from the --companies
18
+ * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
19
+ * (either truthy disables personal sync). No-op
20
+ * outside --companies mode.
17
21
  * --json Ignored — ndjson on stdout is the default and
18
22
  * only output mode. Accepted for symmetry with the
19
23
  * AppBar's argv in case someone passes it.
@@ -61,6 +65,7 @@ import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths, } from ".
61
65
  import { sync as defaultSync } from "../cli/sync.js";
62
66
  import { share as defaultShare } from "../cli/share.js";
63
67
  import { collectAndSendTelemetry } from "../telemetry.js";
68
+ import { TreeWatcher, WatchPushDriver, systemClock, } from "../watcher.js";
64
69
  // ---------------------------------------------------------------------------
65
70
  // Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
66
71
  // imported) to avoid a circular dep between hq-cli and hq-cloud. If these
@@ -82,7 +87,33 @@ export function resolveDeletePolicy() {
82
87
  if (env === "owned-only" || env === "all" || env === "currency-gated") {
83
88
  return env;
84
89
  }
85
- return "owned-only";
90
+ return "currency-gated";
91
+ }
92
+ /**
93
+ * Resolve whether to skip the personal target in a `--companies` fanout.
94
+ *
95
+ * Two inputs combine: the `--skip-personal` CLI flag (parsed into
96
+ * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
97
+ * being truthy skips the personal target — flag wins on conflict (CLI
98
+ * flag is the explicit-for-this-invocation knob, env is the persistent
99
+ * default usually set by the menubar in the spawned child process).
100
+ *
101
+ * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
102
+ * (including missing) is treated as falsy — same shape as classic
103
+ * Unix opt-in env conventions; conservative to avoid surprising opt-outs.
104
+ *
105
+ * Use case: the menubar app exposes a "Sync personal vault" toggle in
106
+ * Settings (default ON, matching the auto-provisioning UX). When the user
107
+ * flips it off, the menubar spawns `hq sync` with this env set so the
108
+ * fanout drops the personal target before walking the user's entire HQ
109
+ * tree (a sync that would otherwise scan thousands of files, including
110
+ * the new personal-vault default exclusions, just to do nothing useful).
111
+ */
112
+ export function resolveSkipPersonal(flag) {
113
+ if (flag)
114
+ return true;
115
+ const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
116
+ return env === "1" || env === "true" || env === "yes";
86
117
  }
87
118
  // Personal-vault scope (exclusion list + path computer) lives in
88
119
  // `../personal-vault.ts` so the `hq sync` CLI and this runner share the same
@@ -159,6 +190,8 @@ function parseArgs(argv) {
159
190
  let direction = "pull";
160
191
  let watch = false;
161
192
  let pollRemoteMs;
193
+ let skipPersonal = false;
194
+ let eventPush = false;
162
195
  for (let i = 0; i < argv.length; i++) {
163
196
  const arg = argv[i];
164
197
  switch (arg) {
@@ -214,6 +247,18 @@ function parseArgs(argv) {
214
247
  case "--json":
215
248
  // Accepted but ignored — ndjson is the only output mode.
216
249
  break;
250
+ case "--skip-personal":
251
+ // Drop the personal target from the fanout. No-op outside
252
+ // --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
253
+ // resolveSkipPersonal().
254
+ skipPersonal = true;
255
+ break;
256
+ case "--event-push":
257
+ // Phase 1 event-driven push enable flag. Requires --watch (validated
258
+ // below). Gated OFF by default; the menubar only passes it for
259
+ // @getindigo.ai identities for the first release.
260
+ eventPush = true;
261
+ break;
217
262
  default:
218
263
  return { error: `Unknown argument: ${arg}` };
219
264
  }
@@ -227,7 +272,20 @@ function parseArgs(argv) {
227
272
  if (pollRemoteMs !== undefined && !watch) {
228
273
  return { error: "--poll-remote-ms requires --watch" };
229
274
  }
230
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
275
+ if (eventPush && !watch) {
276
+ return { error: "--event-push requires --watch" };
277
+ }
278
+ return {
279
+ companies,
280
+ company,
281
+ onConflict,
282
+ hqRoot,
283
+ direction,
284
+ watch,
285
+ pollRemoteMs,
286
+ skipPersonal,
287
+ eventPush,
288
+ };
231
289
  }
232
290
  // ---------------------------------------------------------------------------
233
291
  // Telemetry default — closes over the runner's vault client. Skipped when
@@ -421,7 +479,15 @@ export async function runRunner(argv, deps = {}) {
421
479
  }
422
480
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
423
481
  }
424
- if (parsed.companies) {
482
+ if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
483
+ // Personal-target fanout slot. Skipped entirely when --skip-personal
484
+ // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
485
+ // the rationale (menubar opt-out for users who only want company sync).
486
+ // When skipped, the fanout-plan event below carries only company
487
+ // memberships and no "personal" slug; downstream consumers (menubar
488
+ // workspaces row, status surfaces) should already tolerate that
489
+ // shape since pre-5.25 fanout often had it (a user with no person
490
+ // entity yet, or before the canonical-person-entity machinery landed).
425
491
  const persons = await client.entity.listByType("person");
426
492
  const pick = pickCanonicalPersonEntity(persons);
427
493
  if (pick?.bucketName) {
@@ -529,6 +595,7 @@ export async function runRunner(argv, deps = {}) {
529
595
  filesDeleted: 0,
530
596
  filesTombstoned: 0,
531
597
  filesRefusedStale: 0,
598
+ filesExcludedByPolicy: 0,
532
599
  conflictPaths: [],
533
600
  aborted: false,
534
601
  };
@@ -625,6 +692,17 @@ export async function runRunner(argv, deps = {}) {
625
692
  filesUploaded: pushResult.filesUploaded,
626
693
  bytesUploaded: pushResult.bytesUploaded,
627
694
  filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
695
+ // Push-side counters surfaced on `complete` so the menubar's
696
+ // `SyncCompleteEvent` (which carries them as Option<u32> for
697
+ // back-compat with pre-5.25 engines) can render the new totals.
698
+ // Always emitted as numbers (0 when no push leg ran) so Rust's
699
+ // serde decodes them as `Some(0)` rather than `None` — distinct
700
+ // from the legacy-engine `None` and useful when the UI wants to
701
+ // distinguish "engine ran, nothing tombstoned" from "engine
702
+ // didn't report".
703
+ filesTombstoned: pushResult.filesTombstoned,
704
+ filesRefusedStale: pushResult.filesRefusedStale,
705
+ filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
628
706
  // Sourced from the merged path list so push-side conflicts are
629
707
  // counted too — `ShareResult` doesn't expose a numeric counter,
630
708
  // and using `pullResult.conflicts` alone silently dropped any
@@ -663,6 +741,13 @@ export async function runRunner(argv, deps = {}) {
663
741
  filesUploaded: state.filesUploaded,
664
742
  bytesUploaded: state.bytesUploaded,
665
743
  filesSkipped: 0,
744
+ // Mid-flight throw: we have no clean ShareResult to read these
745
+ // from. Report 0 so the event shape stays stable; the partial
746
+ // counts above already reflect what actually moved before the
747
+ // throw.
748
+ filesTombstoned: 0,
749
+ filesRefusedStale: 0,
750
+ filesExcludedByPolicy: 0,
666
751
  conflicts: 0,
667
752
  conflictPaths: [],
668
753
  aborted: true,
@@ -767,37 +852,214 @@ const isDirectInvocation = (() => {
767
852
  }
768
853
  })();
769
854
  /**
770
- * Auto-sync (Beta) watch loop. Re-runs the one-shot runner every
771
- * `pollRemoteMs` until the process is killed (SIGTERM from the menubar's
772
- * stop_daemon command) or until a pass returns a non-zero exit code (hard
773
- * error worth surfacing to the operator). `setup-needed` and `auth-error`
774
- * exit 0 today and so will retry — acceptable noise for the beta; deal with
775
- * it via a richer return shape if it shows up in Sentry.
855
+ * Route a changed relative path to the push target that owns it.
856
+ *
857
+ * - `companies/<slug>/...` a single-company push for `<slug>` (the targeted
858
+ * pass per PRD decision push only the changed company, not a full
859
+ * `--companies` fanout).
860
+ * - anything else under hqRoot the personal target (a `--companies` push
861
+ * restricted to personal via the personal-vault scope; modeled here as the
862
+ * "personal" route the caller maps to the right argv).
863
+ *
864
+ * Returns `null` for paths the routing cannot attribute (defensive — the
865
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
866
+ * suspenders for synthetic events).
867
+ */
868
+ export function routeChangeToTarget(relPath) {
869
+ const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
870
+ if (norm === "" || norm.startsWith(".."))
871
+ return null;
872
+ const segments = norm.split("/").filter((s) => s.length > 0);
873
+ if (segments.length === 0)
874
+ return null;
875
+ if (segments[0] === "companies") {
876
+ // companies/<slug>/... — need at least the slug segment to target.
877
+ if (segments.length < 2 || segments[1].length === 0)
878
+ return null;
879
+ return { kind: "company", slug: segments[1] };
880
+ }
881
+ return { kind: "personal" };
882
+ }
883
+ /**
884
+ * Build the argv for a targeted push pass from a routed change. The push runs
885
+ * `--direction push` for just the routed target so a local edit propagates in
886
+ * seconds without a full fanout. Company routes use `--company <slug>`;
887
+ * personal routes use `--companies --direction push` (the personal-vault scope
888
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
889
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
890
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
776
891
  */
777
- export async function runRunnerWithLoop(argv) {
892
+ export function buildTargetedPushArgv(route, baseArgv) {
893
+ const carried = carriedFlags(baseArgv);
894
+ if (route.kind === "company") {
895
+ return ["--company", route.slug, "--direction", "push", ...carried];
896
+ }
897
+ return ["--companies", "--direction", "push", ...carried];
898
+ }
899
+ export async function runRunnerWithLoop(argv, deps = {}) {
778
900
  if (!argv.includes("--watch")) {
779
901
  return runRunner(argv);
780
902
  }
903
+ const sleep = deps.sleep ??
904
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
905
+ const runPass = deps.runPass ?? ((passArgv) => runRunner(passArgv));
781
906
  const pollIdx = argv.indexOf("--poll-remote-ms");
782
907
  const pollMs = pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
783
- // Strip --watch / --poll-remote-ms before delegating: the parser inside
784
- // runRunner accepts them, but we don't want runRunner to think it's
785
- // re-entering watch mode each iteration.
908
+ const eventPush = argv.includes("--event-push");
909
+ const hqIdx = argv.indexOf("--hq-root");
910
+ const hqRoot = hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
911
+ // Strip the loop-only flags before delegating: the parser inside runRunner
912
+ // accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
913
+ // iteration pass to think it's re-entering watch mode.
786
914
  const passArgv = argv.filter((a, i) => {
787
915
  if (a === "--watch")
788
916
  return false;
789
917
  if (a === "--poll-remote-ms")
790
918
  return false;
919
+ if (a === "--event-push")
920
+ return false;
791
921
  if (i > 0 && argv[i - 1] === "--poll-remote-ms")
792
922
  return false;
793
923
  return true;
794
924
  });
795
- while (true) {
796
- const code = await runRunner(passArgv);
797
- if (code !== 0)
798
- return code;
799
- await new Promise((resolve) => setTimeout(resolve, pollMs));
925
+ // ---- shared in-flight guard ------------------------------------------
926
+ // The poll loop AND watcher-triggered targeted pushes funnel through this
927
+ // mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
928
+ // trigger that arrives while a pass runs is collapsed by WatchPushDriver's
929
+ // own pending-while-pushing logic, then re-armed after the pass settles.
930
+ let inFlight = false;
931
+ let stopped = false;
932
+ const runGuarded = async (pass) => {
933
+ if (inFlight)
934
+ return "skipped";
935
+ inFlight = true;
936
+ try {
937
+ return await pass();
938
+ }
939
+ finally {
940
+ inFlight = false;
941
+ }
942
+ };
943
+ // ---- event-push wiring (Phase 1) -------------------------------------
944
+ let watcher = null;
945
+ let driver = null;
946
+ let detachSignal = null;
947
+ let lastChangedRel = null;
948
+ if (eventPush) {
949
+ const clock = deps.clock ?? systemClock;
950
+ const debounceMs = 2000;
951
+ const createWatcher = deps.createWatcher ??
952
+ ((opts) => new TreeWatcher({
953
+ hqRoot: opts.hqRoot,
954
+ debounceMs: opts.debounceMs,
955
+ clock: opts.clock,
956
+ personalMode: true,
957
+ }));
958
+ watcher = createWatcher({ hqRoot, debounceMs, clock });
959
+ // The driver runs the targeted push when a debounced burst settles. Its
960
+ // concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
961
+ // through `runGuarded` so a poll pass in flight is never overlapped.
962
+ driver = new WatchPushDriver({
963
+ debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
964
+ clock,
965
+ push: async () => {
966
+ if (stopped)
967
+ return;
968
+ const rel = lastChangedRel;
969
+ const route = rel
970
+ ? routeChangeToTarget(rel)
971
+ : { kind: "personal" };
972
+ if (!route)
973
+ return;
974
+ const targetedArgv = buildTargetedPushArgv(route, passArgv);
975
+ await runGuarded(() => runPass(targetedArgv));
976
+ },
977
+ });
978
+ // A debounced TreeWatcher 'changed' signal feeds the driver, which fires
979
+ // the targeted push after its own (zero) window — i.e. immediately, but
980
+ // still serialized behind any in-flight pass. A path-aware watcher passes
981
+ // the changed relative path so the push targets just its owning company;
982
+ // the bare-signal TreeWatcher leaves it null → personal-vault route.
983
+ watcher.onChange((changedRelPath) => {
984
+ if (stopped)
985
+ return;
986
+ lastChangedRel = changedRelPath ?? null;
987
+ driver?.notifyChange();
988
+ });
989
+ watcher.start();
990
+ }
991
+ // ---- clean shutdown --------------------------------------------------
992
+ // SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
993
+ // the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
994
+ // watcher + driver, and let the poll loop observe `stopped` on its next tick.
995
+ let resolveStopped = null;
996
+ const stoppedSignal = new Promise((resolve) => {
997
+ resolveStopped = resolve;
998
+ });
999
+ const shutdown = () => {
1000
+ if (stopped)
1001
+ return;
1002
+ stopped = true;
1003
+ try {
1004
+ driver?.dispose();
1005
+ }
1006
+ catch {
1007
+ /* ignore */
1008
+ }
1009
+ try {
1010
+ watcher?.dispose();
1011
+ }
1012
+ catch {
1013
+ /* ignore */
1014
+ }
1015
+ resolveStopped?.();
1016
+ };
1017
+ const onShutdownSignal = deps.onShutdownSignal ??
1018
+ ((handler) => {
1019
+ const wrapped = () => handler();
1020
+ process.on("SIGTERM", wrapped);
1021
+ process.on("SIGINT", wrapped);
1022
+ return () => {
1023
+ process.off("SIGTERM", wrapped);
1024
+ process.off("SIGINT", wrapped);
1025
+ };
1026
+ });
1027
+ detachSignal = onShutdownSignal(shutdown);
1028
+ try {
1029
+ while (!stopped) {
1030
+ const result = await runGuarded(() => runPass(passArgv));
1031
+ // A poll pass that was skipped because a watcher push held the guard is
1032
+ // benign — the next iteration retries after the poll interval.
1033
+ if (typeof result === "number" && result !== 0) {
1034
+ return result;
1035
+ }
1036
+ // Sleep the poll interval, but wake early on shutdown so SIGTERM stops
1037
+ // the loop promptly instead of waiting out a 10-minute cycle.
1038
+ await Promise.race([sleep(pollMs), stoppedSignal]);
1039
+ }
1040
+ return 0;
1041
+ }
1042
+ finally {
1043
+ shutdown();
1044
+ detachSignal?.();
1045
+ }
1046
+ }
1047
+ /**
1048
+ * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
1049
+ * re-targeted push pass inherits the same root and conflict policy. Pure
1050
+ * helper used by the event-push targeted-push composer.
1051
+ */
1052
+ function carriedFlags(baseArgv) {
1053
+ const carried = [];
1054
+ for (let i = 0; i < baseArgv.length; i++) {
1055
+ const a = baseArgv[i];
1056
+ if (a === "--hq-root" || a === "--on-conflict") {
1057
+ carried.push(a);
1058
+ if (baseArgv[i + 1] !== undefined)
1059
+ carried.push(baseArgv[++i]);
1060
+ }
800
1061
  }
1062
+ return carried;
801
1063
  }
802
1064
  if (isDirectInvocation) {
803
1065
  runRunnerWithLoop(process.argv.slice(2))