@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.
@@ -84,6 +88,12 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
84
88
  import type { ConflictStrategy } from "../cli/conflict.js";
85
89
  import type { UploadAuthor } from "../s3.js";
86
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
+ import {
92
+ TreeWatcher,
93
+ WatchPushDriver,
94
+ systemClock,
95
+ type Clock,
96
+ } from "../watcher.js";
87
97
 
88
98
  /**
89
99
  * Sync direction for a run.
@@ -122,21 +132,24 @@ const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
122
132
 
123
133
  /**
124
134
  * Delete-propagation policy honored by the push leg of bidirectional sync.
125
- * Default `"owned-only"` in 5.24 — the pre-5.24 behavior is preserved so
126
- * existing users see zero change in delete-propagation semantics. The
127
- * stricter-and-safer `"currency-gated"` policy ships in 5.24 as opt-in
128
- * (set `HQ_SYNC_DELETE_POLICY=currency-gated` to enable per-file ETag
129
- * verification before any local-delete propagates to S3). Default flip
130
- * to `"currency-gated"` is scheduled for 5.25 after a soak period during
131
- * which currency-gated behavior is observed on at least one active
132
- * machine.
135
+ *
136
+ * Default `"currency-gated"` in 5.25 flipped from `"owned-only"` after
137
+ * one machine (Indigo / corey) ran the 5.24 code path through real syncs
138
+ * for a week without surfacing surprise behavior. Currency-gated does a
139
+ * per-file ETag HEAD before propagating any local-delete to S3: if the
140
+ * remote object's current ETag no longer matches the journal's last-
141
+ * recorded one, the delete is refused and the next pull leg re-pulls the
142
+ * file via the standard 3-way merge path. This is strictly safer than
143
+ * `owned-only` (which propagates any local-delete the journal can prove
144
+ * came from this device) — the only delete-class that changes behavior
145
+ * is "deleted locally + modified remotely by another device", which
146
+ * previously destroyed remote work and now becomes a pull-and-conflict.
133
147
  *
134
148
  * Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
135
- * also the rollback knob — once 5.25 flips the default, anyone surprised
136
- * by the new behavior flips the env back to `owned-only` without
137
- * re-deploying. `all` is the unsafe-mirror mode previously used by the
138
- * runner pre-5.20 — included only as an emergency reconcile lever, not a
139
- * recommended default.
149
+ * also the rollback knob — anyone surprised by 5.25's flip can revert
150
+ * to `owned-only` without redeploying. `all` is the unsafe-mirror mode
151
+ * previously used by the runner pre-5.20 included only as an
152
+ * emergency reconcile lever, not a recommended default.
140
153
  */
141
154
  export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
142
155
 
@@ -145,7 +158,33 @@ export function resolveDeletePolicy(): DeletePropagationPolicy {
145
158
  if (env === "owned-only" || env === "all" || env === "currency-gated") {
146
159
  return env;
147
160
  }
148
- return "owned-only";
161
+ return "currency-gated";
162
+ }
163
+
164
+ /**
165
+ * Resolve whether to skip the personal target in a `--companies` fanout.
166
+ *
167
+ * Two inputs combine: the `--skip-personal` CLI flag (parsed into
168
+ * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
169
+ * being truthy skips the personal target — flag wins on conflict (CLI
170
+ * flag is the explicit-for-this-invocation knob, env is the persistent
171
+ * default usually set by the menubar in the spawned child process).
172
+ *
173
+ * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
174
+ * (including missing) is treated as falsy — same shape as classic
175
+ * Unix opt-in env conventions; conservative to avoid surprising opt-outs.
176
+ *
177
+ * Use case: the menubar app exposes a "Sync personal vault" toggle in
178
+ * Settings (default ON, matching the auto-provisioning UX). When the user
179
+ * flips it off, the menubar spawns `hq sync` with this env set so the
180
+ * fanout drops the personal target before walking the user's entire HQ
181
+ * tree (a sync that would otherwise scan thousands of files, including
182
+ * the new personal-vault default exclusions, just to do nothing useful).
183
+ */
184
+ export function resolveSkipPersonal(flag: boolean): boolean {
185
+ if (flag) return true;
186
+ const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
187
+ return env === "1" || env === "true" || env === "yes";
149
188
  }
150
189
 
151
190
  // Personal-vault scope (exclusion list + path computer) lives in
@@ -197,12 +236,19 @@ export type RunnerEvent =
197
236
  /**
198
237
  * Upload counters. Always emitted (0 when the run was pull-only) so
199
238
  * downstream consumers don't need to conditionally read the field.
200
- * Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
201
- * to the Rust struct is a follow-up when the UI needs to surface push
202
- * totals.
203
239
  */
204
240
  filesUploaded: number;
205
241
  bytesUploaded: number;
242
+ /**
243
+ * Push-side counters added in 5.25. Always emitted as numbers (0
244
+ * when no push leg ran). Tauri's `SyncCompleteEvent` carries them
245
+ * as Option<u32> for back-compat with <5.25 engines that don't
246
+ * include them; structural-typing-wise, the union just adds
247
+ * properties on top of `SyncResult`.
248
+ */
249
+ filesTombstoned: number;
250
+ filesRefusedStale: number;
251
+ filesExcludedByPolicy: number;
206
252
  } & SyncResult)
207
253
  | {
208
254
  type: "all-complete";
@@ -402,6 +448,22 @@ interface ParsedArgs {
402
448
  watch: boolean;
403
449
  /** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
404
450
  pollRemoteMs?: number;
451
+ /**
452
+ * Event-driven push (Phase 1). When set (and `--watch` is on), the runner
453
+ * starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
454
+ * company/subtree within the debounce window of a local edit — instead of
455
+ * waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
456
+ * menubar passes it only for `@getindigo.ai` identities (see PRD decision).
457
+ * No-op without `--watch` (the one-shot path has nothing to keep alive).
458
+ */
459
+ eventPush: boolean;
460
+ /**
461
+ * Drop the personal target from the fanout. Combined with the
462
+ * `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
463
+ * truthy disables personal sync for this run. No-op outside `--companies`
464
+ * mode (single-company runs never visit the personal target).
465
+ */
466
+ skipPersonal: boolean;
405
467
  }
406
468
 
407
469
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
@@ -412,6 +474,8 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
412
474
  let direction: Direction = "pull";
413
475
  let watch = false;
414
476
  let pollRemoteMs: number | undefined;
477
+ let skipPersonal = false;
478
+ let eventPush = false;
415
479
 
416
480
  for (let i = 0; i < argv.length; i++) {
417
481
  const arg = argv[i];
@@ -465,6 +529,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
465
529
  case "--json":
466
530
  // Accepted but ignored — ndjson is the only output mode.
467
531
  break;
532
+ case "--skip-personal":
533
+ // Drop the personal target from the fanout. No-op outside
534
+ // --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
535
+ // resolveSkipPersonal().
536
+ skipPersonal = true;
537
+ break;
538
+ case "--event-push":
539
+ // Phase 1 event-driven push enable flag. Requires --watch (validated
540
+ // below). Gated OFF by default; the menubar only passes it for
541
+ // @getindigo.ai identities for the first release.
542
+ eventPush = true;
543
+ break;
468
544
  default:
469
545
  return { error: `Unknown argument: ${arg}` };
470
546
  }
@@ -479,8 +555,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
479
555
  if (pollRemoteMs !== undefined && !watch) {
480
556
  return { error: "--poll-remote-ms requires --watch" };
481
557
  }
558
+ if (eventPush && !watch) {
559
+ return { error: "--event-push requires --watch" };
560
+ }
482
561
 
483
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
562
+ return {
563
+ companies,
564
+ company,
565
+ onConflict,
566
+ hqRoot,
567
+ direction,
568
+ watch,
569
+ pollRemoteMs,
570
+ skipPersonal,
571
+ eventPush,
572
+ };
484
573
  }
485
574
 
486
575
  // ---------------------------------------------------------------------------
@@ -698,7 +787,15 @@ export async function runRunner(
698
787
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
699
788
  }
700
789
 
701
- if (parsed.companies) {
790
+ if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
791
+ // Personal-target fanout slot. Skipped entirely when --skip-personal
792
+ // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
793
+ // the rationale (menubar opt-out for users who only want company sync).
794
+ // When skipped, the fanout-plan event below carries only company
795
+ // memberships and no "personal" slug; downstream consumers (menubar
796
+ // workspaces row, status surfaces) should already tolerate that
797
+ // shape since pre-5.25 fanout often had it (a user with no person
798
+ // entity yet, or before the canonical-person-entity machinery landed).
702
799
  const persons = await client.entity.listByType("person");
703
800
  const pick = pickCanonicalPersonEntity(persons);
704
801
  if (pick?.bucketName) {
@@ -829,6 +926,7 @@ export async function runRunner(
829
926
  filesDeleted: 0,
830
927
  filesTombstoned: 0,
831
928
  filesRefusedStale: 0,
929
+ filesExcludedByPolicy: 0,
832
930
  conflictPaths: [],
833
931
  aborted: false,
834
932
  };
@@ -930,6 +1028,17 @@ export async function runRunner(
930
1028
  filesUploaded: pushResult.filesUploaded,
931
1029
  bytesUploaded: pushResult.bytesUploaded,
932
1030
  filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
1031
+ // Push-side counters surfaced on `complete` so the menubar's
1032
+ // `SyncCompleteEvent` (which carries them as Option<u32> for
1033
+ // back-compat with pre-5.25 engines) can render the new totals.
1034
+ // Always emitted as numbers (0 when no push leg ran) so Rust's
1035
+ // serde decodes them as `Some(0)` rather than `None` — distinct
1036
+ // from the legacy-engine `None` and useful when the UI wants to
1037
+ // distinguish "engine ran, nothing tombstoned" from "engine
1038
+ // didn't report".
1039
+ filesTombstoned: pushResult.filesTombstoned,
1040
+ filesRefusedStale: pushResult.filesRefusedStale,
1041
+ filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
933
1042
  // Sourced from the merged path list so push-side conflicts are
934
1043
  // counted too — `ShareResult` doesn't expose a numeric counter,
935
1044
  // and using `pullResult.conflicts` alone silently dropped any
@@ -967,6 +1076,13 @@ export async function runRunner(
967
1076
  filesUploaded: state.filesUploaded,
968
1077
  bytesUploaded: state.bytesUploaded,
969
1078
  filesSkipped: 0,
1079
+ // Mid-flight throw: we have no clean ShareResult to read these
1080
+ // from. Report 0 so the event shape stays stable; the partial
1081
+ // counts above already reflect what actually moved before the
1082
+ // throw.
1083
+ filesTombstoned: 0,
1084
+ filesRefusedStale: 0,
1085
+ filesExcludedByPolicy: 0,
970
1086
  conflicts: 0,
971
1087
  conflictPaths: [],
972
1088
  aborted: true,
@@ -1088,29 +1204,290 @@ const isDirectInvocation = (() => {
1088
1204
  * exit 0 today and so will retry — acceptable noise for the beta; deal with
1089
1205
  * it via a richer return shape if it shows up in Sentry.
1090
1206
  */
1091
- export async function runRunnerWithLoop(argv: string[]): Promise<number> {
1207
+ /**
1208
+ * Test/event-driven seam (US-001).
1209
+ *
1210
+ * `runRunnerWithLoop` performs an unbounded poll loop in production. To make
1211
+ * the loop deterministically testable (US-003 wires the event-driven watcher
1212
+ * into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
1213
+ * The default uses the host timer and preserves exact production behavior.
1214
+ *
1215
+ * A test (or US-003's wiring) injects a fake sleep that resolves immediately
1216
+ * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
1217
+ * so the loop can be exercised without a real 10-minute wait.
1218
+ */
1219
+ export interface RunnerLoopDeps {
1220
+ /** Sleep `ms` between passes. Default: host setTimeout. */
1221
+ sleep?: (ms: number) => Promise<void>;
1222
+ /**
1223
+ * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
1224
+ * (and the event-push wiring) so the poll loop and the watcher-triggered
1225
+ * targeted push share one seam and one in-flight guard. The default ignores
1226
+ * `deps` and forwards just the argv to `runRunner`.
1227
+ */
1228
+ runPass?: (passArgv: string[]) => Promise<number>;
1229
+ /**
1230
+ * Clock seam for the event-push watcher's debounce window. Defaults to
1231
+ * {@link systemClock}; tests inject a `FakeClock` to advance the window
1232
+ * deterministically. Only consulted when `--event-push` is on.
1233
+ */
1234
+ clock?: Clock;
1235
+ /**
1236
+ * Factory for the file watcher used in event-push mode. Defaults to a real
1237
+ * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
1238
+ * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
1239
+ */
1240
+ createWatcher?: (opts: {
1241
+ hqRoot: string;
1242
+ debounceMs: number;
1243
+ clock: Clock;
1244
+ }) => WatcherSurface;
1245
+ /**
1246
+ * Register a one-shot shutdown signal handler. Defaults to listening for
1247
+ * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
1248
+ * clean teardown without sending real signals. The returned fn detaches the
1249
+ * handler (called during teardown so tests don't leak listeners).
1250
+ */
1251
+ onShutdownSignal?: (handler: () => void) => () => void;
1252
+ }
1253
+
1254
+ /**
1255
+ * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
1256
+ * it; tests inject a lighter stub. Kept narrow so the loop never reaches past
1257
+ * the lifecycle + change-subscription contract.
1258
+ *
1259
+ * `onChange`'s listener receives an OPTIONAL changed relative path. The real
1260
+ * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
1261
+ * the loop routes the targeted push to the personal vault. A path-aware
1262
+ * watcher (or a test stub) can pass the changed `companies/<slug>/...`
1263
+ * relative path so the loop targets just that company.
1264
+ */
1265
+ export interface WatcherSurface {
1266
+ onChange(listener: (changedRelPath?: string) => void): () => void;
1267
+ start(): void;
1268
+ stop(): void;
1269
+ dispose(): void;
1270
+ }
1271
+
1272
+ /**
1273
+ * Route a changed relative path to the push target that owns it.
1274
+ *
1275
+ * - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
1276
+ * pass per PRD decision — push only the changed company, not a full
1277
+ * `--companies` fanout).
1278
+ * - anything else under hqRoot → the personal target (a `--companies` push
1279
+ * restricted to personal via the personal-vault scope; modeled here as the
1280
+ * "personal" route the caller maps to the right argv).
1281
+ *
1282
+ * Returns `null` for paths the routing cannot attribute (defensive — the
1283
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
1284
+ * suspenders for synthetic events).
1285
+ */
1286
+ export function routeChangeToTarget(
1287
+ relPath: string,
1288
+ ): { kind: "company"; slug: string } | { kind: "personal" } | null {
1289
+ const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
1290
+ if (norm === "" || norm.startsWith("..")) return null;
1291
+ const segments = norm.split("/").filter((s) => s.length > 0);
1292
+ if (segments.length === 0) return null;
1293
+ if (segments[0] === "companies") {
1294
+ // companies/<slug>/... — need at least the slug segment to target.
1295
+ if (segments.length < 2 || segments[1].length === 0) return null;
1296
+ return { kind: "company", slug: segments[1] };
1297
+ }
1298
+ return { kind: "personal" };
1299
+ }
1300
+
1301
+ /**
1302
+ * Build the argv for a targeted push pass from a routed change. The push runs
1303
+ * `--direction push` for just the routed target so a local edit propagates in
1304
+ * seconds without a full fanout. Company routes use `--company <slug>`;
1305
+ * personal routes use `--companies --direction push` (the personal-vault scope
1306
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
1307
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
1308
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
1309
+ */
1310
+ export function buildTargetedPushArgv(
1311
+ route: { kind: "company"; slug: string } | { kind: "personal" },
1312
+ baseArgv: string[],
1313
+ ): string[] {
1314
+ const carried = carriedFlags(baseArgv);
1315
+ if (route.kind === "company") {
1316
+ return ["--company", route.slug, "--direction", "push", ...carried];
1317
+ }
1318
+ return ["--companies", "--direction", "push", ...carried];
1319
+ }
1320
+
1321
+ export async function runRunnerWithLoop(
1322
+ argv: string[],
1323
+ deps: RunnerLoopDeps = {},
1324
+ ): Promise<number> {
1092
1325
  if (!argv.includes("--watch")) {
1093
1326
  return runRunner(argv);
1094
1327
  }
1328
+ const sleep =
1329
+ deps.sleep ??
1330
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1331
+ const runPass = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
1095
1332
  const pollIdx = argv.indexOf("--poll-remote-ms");
1096
1333
  const pollMs =
1097
1334
  pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
1335
+ const eventPush = argv.includes("--event-push");
1336
+ const hqIdx = argv.indexOf("--hq-root");
1337
+ const hqRoot =
1338
+ hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
1098
1339
 
1099
- // Strip --watch / --poll-remote-ms before delegating: the parser inside
1100
- // runRunner accepts them, but we don't want runRunner to think it's
1101
- // re-entering watch mode each iteration.
1340
+ // Strip the loop-only flags before delegating: the parser inside runRunner
1341
+ // accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
1342
+ // iteration pass to think it's re-entering watch mode.
1102
1343
  const passArgv = argv.filter((a, i) => {
1103
1344
  if (a === "--watch") return false;
1104
1345
  if (a === "--poll-remote-ms") return false;
1346
+ if (a === "--event-push") return false;
1105
1347
  if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
1106
1348
  return true;
1107
1349
  });
1108
1350
 
1109
- while (true) {
1110
- const code = await runRunner(passArgv);
1111
- if (code !== 0) return code;
1112
- await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
1351
+ // ---- shared in-flight guard ------------------------------------------
1352
+ // The poll loop AND watcher-triggered targeted pushes funnel through this
1353
+ // mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
1354
+ // trigger that arrives while a pass runs is collapsed by WatchPushDriver's
1355
+ // own pending-while-pushing logic, then re-armed after the pass settles.
1356
+ let inFlight = false;
1357
+ let stopped = false;
1358
+ const runGuarded = async (
1359
+ pass: () => Promise<number>,
1360
+ ): Promise<number | "skipped"> => {
1361
+ if (inFlight) return "skipped";
1362
+ inFlight = true;
1363
+ try {
1364
+ return await pass();
1365
+ } finally {
1366
+ inFlight = false;
1367
+ }
1368
+ };
1369
+
1370
+ // ---- event-push wiring (Phase 1) -------------------------------------
1371
+ let watcher: WatcherSurface | null = null;
1372
+ let driver: WatchPushDriver | null = null;
1373
+ let detachSignal: (() => void) | null = null;
1374
+ let lastChangedRel: string | null = null;
1375
+
1376
+ if (eventPush) {
1377
+ const clock = deps.clock ?? systemClock;
1378
+ const debounceMs = 2000;
1379
+ const createWatcher =
1380
+ deps.createWatcher ??
1381
+ ((opts) =>
1382
+ new TreeWatcher({
1383
+ hqRoot: opts.hqRoot,
1384
+ debounceMs: opts.debounceMs,
1385
+ clock: opts.clock,
1386
+ personalMode: true,
1387
+ }));
1388
+ watcher = createWatcher({ hqRoot, debounceMs, clock });
1389
+
1390
+ // The driver runs the targeted push when a debounced burst settles. Its
1391
+ // concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
1392
+ // through `runGuarded` so a poll pass in flight is never overlapped.
1393
+ driver = new WatchPushDriver({
1394
+ debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
1395
+ clock,
1396
+ push: async () => {
1397
+ if (stopped) return;
1398
+ const rel = lastChangedRel;
1399
+ const route = rel
1400
+ ? routeChangeToTarget(rel)
1401
+ : { kind: "personal" as const };
1402
+ if (!route) return;
1403
+ const targetedArgv = buildTargetedPushArgv(route, passArgv);
1404
+ await runGuarded(() => runPass(targetedArgv));
1405
+ },
1406
+ });
1407
+
1408
+ // A debounced TreeWatcher 'changed' signal feeds the driver, which fires
1409
+ // the targeted push after its own (zero) window — i.e. immediately, but
1410
+ // still serialized behind any in-flight pass. A path-aware watcher passes
1411
+ // the changed relative path so the push targets just its owning company;
1412
+ // the bare-signal TreeWatcher leaves it null → personal-vault route.
1413
+ watcher.onChange((changedRelPath) => {
1414
+ if (stopped) return;
1415
+ lastChangedRel = changedRelPath ?? null;
1416
+ driver?.notifyChange();
1417
+ });
1418
+ watcher.start();
1419
+ }
1420
+
1421
+ // ---- clean shutdown --------------------------------------------------
1422
+ // SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
1423
+ // the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
1424
+ // watcher + driver, and let the poll loop observe `stopped` on its next tick.
1425
+ let resolveStopped: (() => void) | null = null;
1426
+ const stoppedSignal = new Promise<void>((resolve) => {
1427
+ resolveStopped = resolve;
1428
+ });
1429
+ const shutdown = (): void => {
1430
+ if (stopped) return;
1431
+ stopped = true;
1432
+ try {
1433
+ driver?.dispose();
1434
+ } catch {
1435
+ /* ignore */
1436
+ }
1437
+ try {
1438
+ watcher?.dispose();
1439
+ } catch {
1440
+ /* ignore */
1441
+ }
1442
+ resolveStopped?.();
1443
+ };
1444
+ const onShutdownSignal =
1445
+ deps.onShutdownSignal ??
1446
+ ((handler: () => void) => {
1447
+ const wrapped = () => handler();
1448
+ process.on("SIGTERM", wrapped);
1449
+ process.on("SIGINT", wrapped);
1450
+ return () => {
1451
+ process.off("SIGTERM", wrapped);
1452
+ process.off("SIGINT", wrapped);
1453
+ };
1454
+ });
1455
+ detachSignal = onShutdownSignal(shutdown);
1456
+
1457
+ try {
1458
+ while (!stopped) {
1459
+ const result = await runGuarded(() => runPass(passArgv));
1460
+ // A poll pass that was skipped because a watcher push held the guard is
1461
+ // benign — the next iteration retries after the poll interval.
1462
+ if (typeof result === "number" && result !== 0) {
1463
+ return result;
1464
+ }
1465
+ // Sleep the poll interval, but wake early on shutdown so SIGTERM stops
1466
+ // the loop promptly instead of waiting out a 10-minute cycle.
1467
+ await Promise.race([sleep(pollMs), stoppedSignal]);
1468
+ }
1469
+ return 0;
1470
+ } finally {
1471
+ shutdown();
1472
+ detachSignal?.();
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
1478
+ * re-targeted push pass inherits the same root and conflict policy. Pure
1479
+ * helper used by the event-push targeted-push composer.
1480
+ */
1481
+ function carriedFlags(baseArgv: string[]): string[] {
1482
+ const carried: string[] = [];
1483
+ for (let i = 0; i < baseArgv.length; i++) {
1484
+ const a = baseArgv[i];
1485
+ if (a === "--hq-root" || a === "--on-conflict") {
1486
+ carried.push(a);
1487
+ if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
1488
+ }
1113
1489
  }
1490
+ return carried;
1114
1491
  }
1115
1492
 
1116
1493
  if (isDirectInvocation) {
@@ -595,9 +595,14 @@ describe("share", () => {
595
595
  vaultConfig: mockConfig,
596
596
  hqRoot: tmpDir,
597
597
  onEvent: (e) => {
598
- // Only file-level events carry `.path`. The Stage-1 `plan` event is
599
- // surfaced separately and tested in its own block.
600
- if (e.type === "plan" || e.type === "new-files") return;
598
+ // Only file-level events carry `.path`. The Stage-1 `plan` event +
599
+ // the new-files event + the personal-vault-out-of-policy summary
600
+ // event are surfaced separately and tested in their own blocks.
601
+ if (
602
+ e.type === "plan" ||
603
+ e.type === "new-files" ||
604
+ e.type === "personal-vault-out-of-policy"
605
+ ) return;
601
606
  events.push({
602
607
  type: e.type,
603
608
  path: e.path,
package/src/cli/share.ts CHANGED
@@ -21,6 +21,10 @@ import {
21
21
  normalizeEtag,
22
22
  } from "../journal.js";
23
23
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
24
+ import {
25
+ wrapFilterWithPersonalVaultDefaults,
26
+ type PersonalVaultExclusion,
27
+ } from "../personal-vault-exclusions.js";
24
28
  import { resolveConflict } from "./conflict.js";
25
29
  import type { ConflictStrategy } from "./conflict.js";
26
30
  import type { SyncProgressEvent } from "./sync.js";
@@ -386,6 +390,15 @@ export interface ShareResult {
386
390
  * `currency-gated`.
387
391
  */
388
392
  filesRefusedStale: number;
393
+ /**
394
+ * Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
395
+ * run (push leg, personalMode=true). Includes both files that would have
396
+ * uploaded and journal entries that would have been included in the delete
397
+ * plan; deduplicated across walks. Always 0 outside personalMode. Mirrors
398
+ * the `count` field of the `personal-vault-out-of-policy` event (which is
399
+ * emitted exactly once if this is > 0).
400
+ */
401
+ filesExcludedByPolicy: number;
389
402
  /**
390
403
  * Paths (company-relative) that were detected as push conflicts. Mirrors
391
404
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -463,7 +476,34 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
463
476
  const syncRoot = options.personalMode === true
464
477
  ? hqRoot
465
478
  : path.join(hqRoot, "companies", ctx.slug);
466
- const shouldSync = createIgnoreFilter(hqRoot);
479
+
480
+ // Personal-vault default exclusions (introduced in 5.25): wrap the base
481
+ // ignore filter so paths matching `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` are
482
+ // rejected before they upload OR enter the delete plan. Refuses & warns —
483
+ // an already-leaked remote object stays put as an orphan; a separate one-
484
+ // shot purge handles legacy litter.
485
+ //
486
+ // Out-of-policy hits are deduplicated in `excludedSet` so the same path
487
+ // hitting the filter from both the upload walk and the delete-plan walk
488
+ // counts once. `excludedById` powers the per-rule breakdown on the
489
+ // `personal-vault-out-of-policy` event so UI can render which class
490
+ // (secret / machine-local / scratch / …) did the work.
491
+ //
492
+ // Company-mode syncs skip this wrap entirely — company vaults have their
493
+ // own first-push protection (settings/, data/, workers/, .git/) defined
494
+ // in hq-sync's Rust util/ignore.rs, and a company may legitimately ship
495
+ // `output/` or `.env*` paths inside its `companies/{slug}/data/` folder.
496
+ const ignoreFilter = createIgnoreFilter(hqRoot);
497
+ const excludedSet = new Set<string>();
498
+ const excludedById: Record<string, number> = {};
499
+ const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
500
+ if (excludedSet.has(rel)) return;
501
+ excludedSet.add(rel);
502
+ excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
503
+ };
504
+ const shouldSync = options.personalMode === true
505
+ ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
506
+ : ignoreFilter;
467
507
  const journalSlug = options.journalSlug ?? ctx.slug;
468
508
  const journal = readJournal(journalSlug);
469
509
 
@@ -599,6 +639,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
599
639
  // ShareResult shape stable for consumers that destructure.
600
640
  filesTombstoned,
601
641
  filesRefusedStale,
642
+ // Exclusions are computed during the upload walk which has
643
+ // already completed by the time we hit a per-file conflict-
644
+ // abort, so the count is meaningful here. No event emit on
645
+ // abort (matches the existing convention: abort short-circuits
646
+ // before the end-of-run telemetry emits).
647
+ filesExcludedByPolicy: excludedSet.size,
602
648
  conflictPaths,
603
649
  aborted: true,
604
650
  };
@@ -727,6 +773,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
727
773
  journal.lastSync = new Date().toISOString();
728
774
  writeJournal(journalSlug, journal);
729
775
 
776
+ // Personal-vault out-of-policy summary. Emit at most once, only when at
777
+ // least one path was excluded. Sample is capped at 10 to keep the event
778
+ // small (Set iteration order = insertion order, so samples are the first
779
+ // ten paths encountered during the walk — deterministic, not random).
780
+ if (excludedSet.size > 0) {
781
+ const samplePaths: string[] = [];
782
+ for (const p of excludedSet) {
783
+ samplePaths.push(p);
784
+ if (samplePaths.length >= 10) break;
785
+ }
786
+ emit({
787
+ type: "personal-vault-out-of-policy",
788
+ count: excludedSet.size,
789
+ samplePaths,
790
+ byId: { ...excludedById },
791
+ });
792
+ }
793
+
730
794
  return {
731
795
  filesUploaded,
732
796
  bytesUploaded,
@@ -734,6 +798,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
734
798
  filesDeleted,
735
799
  filesTombstoned,
736
800
  filesRefusedStale,
801
+ filesExcludedByPolicy: excludedSet.size,
737
802
  conflictPaths,
738
803
  aborted: false,
739
804
  };