@indigoai-us/hq-cloud 6.11.12 → 6.11.13

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 (107) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +3 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +73 -1154
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/cli/reindex.d.ts.map +1 -1
  34. package/dist/cli/reindex.js +34 -17
  35. package/dist/cli/reindex.js.map +1 -1
  36. package/dist/cli/reindex.test.js +39 -5
  37. package/dist/cli/reindex.test.js.map +1 -1
  38. package/dist/cli/rescue-classify-ordering.test.js +17 -0
  39. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  40. package/dist/cli/rescue-core.d.ts +45 -0
  41. package/dist/cli/rescue-core.d.ts.map +1 -1
  42. package/dist/cli/rescue-core.js +197 -170
  43. package/dist/cli/rescue-core.js.map +1 -1
  44. package/dist/cli/share.d.ts.map +1 -1
  45. package/dist/cli/share.js +224 -676
  46. package/dist/cli/share.js.map +1 -1
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +399 -726
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +20 -0
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/daemon-worker.d.ts +2 -2
  53. package/dist/daemon-worker.js +3 -3
  54. package/dist/daemon-worker.js.map +1 -1
  55. package/dist/object-io.js +1 -1
  56. package/dist/object-io.js.map +1 -1
  57. package/dist/remote-pull.d.ts +2 -2
  58. package/dist/remote-pull.d.ts.map +1 -1
  59. package/dist/remote-pull.js +23 -3
  60. package/dist/remote-pull.js.map +1 -1
  61. package/dist/remote-pull.test.js +24 -2
  62. package/dist/remote-pull.test.js.map +1 -1
  63. package/dist/sync/push-receiver.d.ts +6 -0
  64. package/dist/sync/push-receiver.d.ts.map +1 -1
  65. package/dist/sync/push-receiver.js +32 -2
  66. package/dist/sync/push-receiver.js.map +1 -1
  67. package/dist/sync/push-receiver.test.js +31 -0
  68. package/dist/sync/push-receiver.test.js.map +1 -1
  69. package/dist/sync-core.d.ts +27 -0
  70. package/dist/sync-core.d.ts.map +1 -0
  71. package/dist/sync-core.js +54 -0
  72. package/dist/sync-core.js.map +1 -0
  73. package/dist/vault-client.d.ts.map +1 -1
  74. package/dist/vault-client.js +284 -36
  75. package/dist/vault-client.js.map +1 -1
  76. package/dist/vault-client.test.js +59 -0
  77. package/dist/vault-client.test.js.map +1 -1
  78. package/dist/watcher.d.ts +2 -20
  79. package/dist/watcher.d.ts.map +1 -1
  80. package/dist/watcher.js +3 -113
  81. package/dist/watcher.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/bin/sync-runner-company.ts +350 -0
  84. package/src/bin/sync-runner-events.ts +25 -0
  85. package/src/bin/sync-runner-planning.ts +121 -0
  86. package/src/bin/sync-runner-rollup.ts +72 -0
  87. package/src/bin/sync-runner-telemetry.ts +8 -0
  88. package/src/bin/sync-runner-watch-loop.ts +443 -0
  89. package/src/bin/sync-runner-watch-routes.ts +86 -0
  90. package/src/bin/sync-runner.ts +96 -1253
  91. package/src/cli/reindex.test.ts +41 -3
  92. package/src/cli/reindex.ts +35 -19
  93. package/src/cli/rescue-classify-ordering.test.ts +20 -0
  94. package/src/cli/rescue-core.ts +252 -176
  95. package/src/cli/share.ts +363 -705
  96. package/src/cli/sync.test.ts +25 -0
  97. package/src/cli/sync.ts +612 -802
  98. package/src/daemon-worker.ts +3 -3
  99. package/src/object-io.ts +1 -1
  100. package/src/remote-pull.test.ts +30 -1
  101. package/src/remote-pull.ts +29 -4
  102. package/src/sync/push-receiver.test.ts +35 -0
  103. package/src/sync/push-receiver.ts +41 -2
  104. package/src/sync-core.ts +58 -0
  105. package/src/vault-client.test.ts +74 -0
  106. package/src/vault-client.ts +395 -43
  107. package/src/watcher.ts +6 -141
@@ -106,36 +106,32 @@ import { collectAndSendTelemetry } from "../telemetry.js";
106
106
  import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
107
107
  import { reindexAfterSync } from "../qmd-reindex.js";
108
108
  import { pruneConflictIndex } from "../lib/conflict-index.js";
109
- import {
110
- acquireOperationLock,
111
- withOperationLock,
112
- OperationLockedError,
113
- OPERATION_LOCKED_EXIT,
114
- } from "../operation-lock.js";
115
- import { describeError } from "../lib/describe-error.js";
116
109
  import { getOrCreateMachineId } from "../lib/machine-id.js";
110
+ import type { Clock, TreeChangeBatch } from "../watcher.js";
111
+ import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
117
112
  import {
118
- TreeWatcher,
119
- WatchPushDriver,
120
- systemClock,
121
- type Clock,
122
- type TreeChangeBatch,
123
- } from "../watcher.js";
124
- import {
125
- NoopPushReceiver,
126
- type PushReceiver,
127
- type SyncEngineFn,
128
- } from "../sync/push-receiver.js";
129
- import {
130
- resolveEventSync,
131
- startEventSync as defaultStartEventSync,
132
113
  type EventSyncHandles,
133
114
  type StartEventSyncOptions,
134
115
  } from "../sync/event-sync.js";
116
+ import { migratePersonalVaultJournal } from "../journal.js";
117
+ import { createRunnerEmitter } from "./sync-runner-events.js";
135
118
  import {
136
- PERSONAL_VAULT_JOURNAL_SLUG,
137
- migratePersonalVaultJournal,
138
- } from "../journal.js";
119
+ buildFanoutPlan,
120
+ emitFanoutPlan,
121
+ resolveMembershipsForRun,
122
+ } from "./sync-runner-planning.js";
123
+ import { executeCompanyFanout } from "./sync-runner-company.js";
124
+ import { rollupAllComplete } from "./sync-runner-rollup.js";
125
+ import { emitTelemetry } from "./sync-runner-telemetry.js";
126
+ import {
127
+ runOneShotWithOperationLock,
128
+ runWatchLoop,
129
+ } from "./sync-runner-watch-loop.js";
130
+ export {
131
+ buildTargetedPullArgv,
132
+ buildTargetedPushArgv,
133
+ routeChangeToTarget,
134
+ } from "./sync-runner-watch-routes.js";
139
135
 
140
136
  /**
141
137
  * Sync direction for a run.
@@ -894,14 +890,7 @@ export async function runRunner(
894
890
  // events. The menubar's `HQ_CLOUD_VERSION` pin gates which runner
895
891
  // they spawn, so old menubars stay on the previous runner version
896
892
  // even after this one is published.
897
- const ERROR_TYPES: ReadonlySet<RunnerEvent["type"]> = new Set([
898
- "error",
899
- "auth-error",
900
- ]);
901
- const emit = (event: RunnerEvent): void => {
902
- const stream = ERROR_TYPES.has(event.type) ? stderr : stdout;
903
- stream.write(`${JSON.stringify(event)}\n`);
904
- };
893
+ const emit = createRunnerEmitter({ stdout, stderr });
905
894
 
906
895
  // ---- argv -------------------------------------------------------------
907
896
  const parsed = parseArgs(argv);
@@ -993,36 +982,21 @@ export async function runRunner(
993
982
  // ---- resolve targets --------------------------------------------------
994
983
  let memberships: Pick<Membership, "companyUid">[];
995
984
  try {
996
- if (parsed.personal) {
997
- // Personal-vault-only mode: skip listMyMemberships entirely (and
998
- // therefore the claim-dance). The fanout plan is built solely from
999
- // the person-entity lookup below — no cloud-company targets, no
1000
- // /membership/me round-trip. setup-needed is deferred to the
1001
- // person-entity check (firing only when the personal entity is
1002
- // also absent, not when memberships are empty by design).
1003
- memberships = [];
1004
- } else if (parsed.companies) {
1005
- // Before giving up on memberships, run the claim-dance: new users signed
1006
- // in via the tray may have email-keyed invites waiting for them. Without
1007
- // this, an invited user would see "setup-needed" on every tray click.
1008
- if (claims) {
1009
- await runClaimDance(client, claims, stderr);
1010
- }
1011
-
1012
- memberships = await listMembershipsWithRetry(client);
1013
- if (memberships.length === 0) {
1014
- // Truly empty — still a valid state (no memberships = nothing to
1015
- // sync). The tray will show a friendly "create your first company"
1016
- // CTA rather than an alarm banner.
1017
- emit({ type: "setup-needed" });
1018
- return 0;
1019
- }
1020
- } else {
1021
- // Single-company mode: fabricate a minimal membership so the fanout
1022
- // loop below treats it uniformly. We don't need to hit
1023
- // /membership/me — the caller already told us which company.
1024
- memberships = [{ companyUid: parsed.company! }];
985
+ const resolution = await resolveMembershipsForRun({
986
+ personal: parsed.personal,
987
+ companies: parsed.companies,
988
+ company: parsed.company,
989
+ client,
990
+ claims,
991
+ stderr,
992
+ runClaimDance,
993
+ listMemberships: listMembershipsWithRetry,
994
+ });
995
+ if (resolution.status === "setup-needed") {
996
+ emit({ type: "setup-needed" });
997
+ return 0;
1025
998
  }
999
+ memberships = resolution.memberships;
1026
1000
  } catch (err) {
1027
1001
  if (err instanceof VaultAuthError) {
1028
1002
  emit({
@@ -1042,73 +1016,21 @@ export async function runRunner(
1042
1016
  return 1;
1043
1017
  }
1044
1018
 
1045
- // ---- resolve slugs for the fanout plan --------------------------------
1046
- // The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
1047
- // If the entity fetch fails for some row (entity deleted, scoping issue),
1048
- // degrade to using the UID as the slug rather than aborting the run.
1049
- const plan: Array<{
1050
- uid: string;
1051
- slug: string;
1052
- name?: string;
1053
- bucketName?: string;
1054
- personalMode?: boolean;
1055
- journalSlug?: string;
1056
- }> = [];
1057
- for (const m of memberships) {
1058
- let slug = m.companyUid;
1059
- let name: string | undefined;
1060
- try {
1061
- const info = await client.entity.get(m.companyUid);
1062
- slug = info.slug || m.companyUid;
1063
- name = info.name;
1064
- } catch {
1065
- // Best-effort — keep UID as the display identifier.
1066
- }
1067
- plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
1068
- }
1069
-
1070
- if (
1071
- (parsed.companies || parsed.personal) &&
1072
- !resolveSkipPersonal(parsed.skipPersonal)
1073
- ) {
1074
- // Personal-target fanout slot. Skipped entirely when --skip-personal
1075
- // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
1076
- // the rationale (menubar opt-out for users who only want company sync).
1077
- // When skipped, the fanout-plan event below carries only company
1078
- // memberships and no "personal" slug; downstream consumers (menubar
1079
- // workspaces row, status surfaces) should already tolerate that
1080
- // shape since pre-5.25 fanout often had it (a user with no person
1081
- // entity yet, or before the canonical-person-entity machinery landed).
1082
- //
1083
- // `--personal` mode reaches this block with an empty `plan` and
1084
- // empty `memberships`; only the personal target gets added below.
1085
- const persons = await client.entity.listByType("person");
1086
- const pick = pickCanonicalPersonEntity(persons);
1087
- if (pick?.bucketName) {
1088
- plan.push({
1089
- slug: "personal",
1090
- uid: pick.uid,
1091
- bucketName: pick.bucketName,
1092
- personalMode: true,
1093
- // Reserved sentinel slug — decoupled from the `companies/personal`
1094
- // company slug to avoid sharing `sync-journal.personal.json`. The
1095
- // display `slug` stays "personal" (menubar UI); only the journal
1096
- // shard changes. See PERSONAL_VAULT_JOURNAL_SLUG for the full
1097
- // root-cause writeup.
1098
- journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
1099
- });
1100
- } else if (parsed.personal) {
1101
- // --personal mode with no canonical personal entity → setup-needed.
1102
- // (In --companies mode this state is silent — companies still sync
1103
- // and the missing personal target just shows an empty workspaces row.
1104
- // In --personal mode it's the ONLY signal, so it gets surfaced as
1105
- // setup-needed with the same shape as the empty-memberships case.)
1106
- emit({ type: "setup-needed" });
1107
- return 0;
1108
- }
1019
+ const targetPlan = await buildFanoutPlan({
1020
+ memberships,
1021
+ companies: parsed.companies,
1022
+ personal: parsed.personal,
1023
+ skipPersonal: parsed.skipPersonal,
1024
+ client,
1025
+ resolveSkipPersonal,
1026
+ });
1027
+ if (targetPlan.status === "setup-needed") {
1028
+ emit({ type: "setup-needed" });
1029
+ return 0;
1109
1030
  }
1031
+ const plan = targetPlan.plan;
1110
1032
 
1111
- emit({ type: "fanout-plan", companies: plan });
1033
+ emitFanoutPlan(emit, plan);
1112
1034
 
1113
1035
  // One-time seed of the reserved personal-vault journal from the legacy
1114
1036
  // "personal" journal, so switching the vault slot off the colliding slug
@@ -1120,552 +1042,46 @@ export async function runRunner(
1120
1042
  // ---- fanout -----------------------------------------------------------
1121
1043
  const syncFn = deps.sync ?? defaultSync;
1122
1044
  const shareFn = deps.share ?? defaultShare;
1123
- const doPush = parsed.direction === "push" || parsed.direction === "both";
1124
- const doPull = parsed.direction === "pull" || parsed.direction === "both";
1125
- const errors: Array<{ company: string; message: string }> = [];
1126
- const allConflicts: Array<{ company: string; path: string; direction: "pull" | "push" }> = [];
1127
-
1128
- // Per-company state, keyed by the company label (slug or UID-fallback) so
1129
- // both `progress` (which streams) and `complete`/throw (which lands once)
1130
- // can update the same row. The rollup at the bottom of the function walks
1131
- // every entry this is the source of truth that closes the bug where an
1132
- // aborted company's partial counts were dropped from `all-complete`.
1133
- //
1134
- // We seed `direction` from the parsed flag so we know whether a `progress`
1135
- // event without a clear phase should bump downloaded or uploaded counters.
1136
- // For `direction: "both"` runs we lean on the path of the in-flight phase
1137
- // push runs first and sets `phaseRef.current = "push"` while shareFn runs,
1138
- // pull sets it to "pull". The closure shared by tagAndEmit reads `.current`
1139
- // at event time, so progress events route to the right column.
1140
- type CompanyStatus = "complete" | "aborted" | "errored";
1141
- interface CompanyState {
1142
- company: string;
1143
- status: CompanyStatus;
1144
- filesDownloaded: number;
1145
- bytesDownloaded: number;
1146
- filesUploaded: number;
1147
- bytesUploaded: number;
1148
- }
1149
- const stateByCompany = new Map<string, CompanyState>();
1150
-
1151
- for (const target of plan) {
1152
- const companyLabel = target.slug;
1153
- const state: CompanyState = {
1154
- company: companyLabel,
1155
- // Default to "errored" so a throw before any complete-or-clean-abort
1156
- // path (the original bug) leaves the entry flagged as not-clean. The
1157
- // success/clean-abort paths overwrite this before the loop body exits.
1158
- status: "errored",
1159
- filesDownloaded: 0,
1160
- bytesDownloaded: 0,
1161
- filesUploaded: 0,
1162
- bytesUploaded: 0,
1163
- };
1164
- stateByCompany.set(companyLabel, state);
1165
-
1166
- // Which phase is currently emitting `progress` events. Mutable closure so
1167
- // tagAndEmit (defined once below) reads the latest value when each event
1168
- // fires. "pull" is the default for back-compat with pull-only runs.
1169
- let activePhase: "pull" | "push" = doPush && !doPull ? "push" : "pull";
1170
-
1171
- // Per-company event tagger — shared by push and pull phases so progress
1172
- // rows land on the right company regardless of which phase emitted them.
1173
- // Also updates `state` for `progress` events so the rollup has accurate
1174
- // partial counts even if the sync function throws before returning.
1175
- let companyHadTransferError = false;
1176
- const tagAndEmit = (event: SyncProgressEvent): void => {
1177
- if (event.type === "plan") {
1178
- emit({
1179
- type: "plan",
1180
- company: companyLabel,
1181
- filesToDownload: event.filesToDownload,
1182
- bytesToDownload: event.bytesToDownload,
1183
- filesToUpload: event.filesToUpload,
1184
- bytesToUpload: event.bytesToUpload,
1185
- filesToSkip: event.filesToSkip,
1186
- filesToConflict: event.filesToConflict,
1187
- filesToDelete: event.filesToDelete,
1188
- });
1189
- } else if (event.type === "progress") {
1190
- if (activePhase === "push") {
1191
- state.filesUploaded += 1;
1192
- state.bytesUploaded += event.bytes;
1193
- } else {
1194
- state.filesDownloaded += 1;
1195
- state.bytesDownloaded += event.bytes;
1196
- }
1197
- emit({
1198
- type: "progress",
1199
- company: companyLabel,
1200
- path: event.path,
1201
- bytes: event.bytes,
1202
- // Stamp the transfer direction from the in-flight phase so the
1203
- // menubar can label each file uploaded vs downloaded. The inner
1204
- // share()/sync() emitters don't know the phase — only this tagger,
1205
- // which mirrors the up/down counter bump above.
1206
- direction: activePhase === "push" ? "up" : "down",
1207
- ...(event.message ? { message: event.message } : {}),
1208
- ...(event.deleted ? { deleted: event.deleted } : {}),
1209
- });
1210
- } else if (event.type === "conflict") {
1211
- emit({
1212
- type: "conflict",
1213
- company: companyLabel,
1214
- path: event.path,
1215
- direction: event.direction,
1216
- resolution: event.resolution,
1217
- });
1218
- } else if (event.type === "error") {
1219
- companyHadTransferError = true;
1220
- errors.push({
1221
- company: companyLabel,
1222
- message: event.path ? `${event.path}: ${event.message}` : event.message,
1223
- });
1224
- emit({
1225
- type: "error",
1226
- company: companyLabel,
1227
- path: event.path,
1228
- message: event.message,
1229
- });
1230
- } else if (event.type === "new-files") {
1231
- emit({
1232
- type: "new-files",
1233
- company: companyLabel,
1234
- files: event.files,
1235
- });
1236
- } else if (event.type === "scope-excluded") {
1237
- // Push-side ACL scope exclusions — surface the named paths tagged to
1238
- // this company so the menubar/CLI can show "N skipped, outside your
1239
- // access" instead of the file silently never uploading.
1240
- emit({
1241
- type: "scope-excluded",
1242
- company: companyLabel,
1243
- count: event.count,
1244
- samplePaths: event.samplePaths,
1245
- });
1246
- }
1247
- };
1248
-
1249
- try {
1250
- let pushResult: ShareResult = {
1251
- filesUploaded: 0,
1252
- bytesUploaded: 0,
1253
- filesSkipped: 0,
1254
- filesDeleted: 0,
1255
- filesTombstoned: 0,
1256
- filesRefusedStale: 0,
1257
- filesRefusedStalePaths: [],
1258
- filesSuppressedByTombstone: 0,
1259
- filesExcludedByPolicy: 0,
1260
- filesExcludedByScope: 0,
1261
- conflictPaths: [],
1262
- aborted: false,
1263
- };
1264
- let pullResult: SyncResult = {
1265
- filesDownloaded: 0,
1266
- bytesDownloaded: 0,
1267
- filesSkipped: 0,
1268
- conflicts: 0,
1269
- conflictPaths: [],
1270
- aborted: false,
1271
- newFiles: [],
1272
- newFilesCount: 0,
1273
- filesExcludedByPolicy: 0,
1274
- filesTombstoned: 0,
1275
- filesOutOfScope: 0,
1276
- scopeOrphansRemoved: 0,
1277
- scopeOrphansBlocked: 0,
1278
- };
1279
-
1280
- // Push first so a subsequent pull doesn't overwrite files we were about
1281
- // to broadcast. Company targets walk `companies/{slug}/`; the personal
1282
- // target walks every top-level entry under hqRoot minus the exclusion
1283
- // list (see PERSONAL_VAULT_EXCLUDED_TOP_LEVEL). `skipUnchanged: true`
1284
- // keeps both cases efficient on re-runs.
1285
- // Shared between push and pull for the personal slot. Hoisted out of
1286
- // the if-blocks below so doPush + doPull see the same set:
1287
- //
1288
- // `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` opts INTO the
1289
- // cloud:false → personal-bucket fallback. Default OFF keeps the
1290
- // personal vault identical to the pre-5.20 shape until operators
1291
- // explicitly enable it.
1292
- //
1293
- // `teamSyncedSlugs` is the slug set the operator currently has
1294
- // active team-bucket Memberships for, derived from the live plan.
1295
- // Used by:
1296
- // • push: filter `companies/` subdir enumeration so a team-synced
1297
- // company never rides the personal slot; build
1298
- // `decommissionPrefixes` so share() sweeps orphan keys for
1299
- // any slug that's now team-backed.
1300
- // • pull: filter `listRemoteFiles` so pre-decommission orphan
1301
- // keys at `companies/{team-synced-slug}/...` don't get
1302
- // re-downloaded into the disk paths the team-bucket pull
1303
- // now manages.
1304
- const includeLocalCompanies =
1305
- process.env.HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL === "1";
1306
- const teamSyncedSlugs = new Set(
1307
- plan
1308
- .filter((p) => p.personalMode !== true)
1309
- .map((p) => p.slug),
1310
- );
1311
-
1312
- // Resolve the membership's effective ACL scope ONCE so BOTH the push and
1313
- // pull legs respect the granted prefixes. The vault vends a child
1314
- // credential scoped to these prefixes; without filtering the PUSH plan to
1315
- // them, share() would HEAD/PUT keys outside the grant and the server's
1316
- // correct 403 (SCOPE_EXCEEDS_PARENT) would abort the WHOLE company with a
1317
- // generic error + exit 2. Personal-vault legs have no membership
1318
- // sync-config — they stay full-scope ("all"). Degrades to "all" on any
1319
- // error (a transient failure must never silently filter/prune the tree).
1320
- // Hoisted above the push block (it used to be resolved only for pull) so
1321
- // push gets the same scope; the pull leg below reuses this value.
1322
- const scope: PullScope =
1323
- target.personalMode === true
1324
- ? { syncMode: "all" }
1325
- : await resolvePullScope(
1326
- client,
1327
- target.uid,
1328
- target.slug,
1329
- parsed.hqRoot,
1330
- );
1331
-
1332
- if (doPush) {
1333
- activePhase = "push";
1334
- // For the personal slot we hand share() both (a) the top-level
1335
- // hqRoot entries that are part of the personal vault and (b) any
1336
- // `companies/{slug}/` subdirs the user has opted into the personal
1337
- // bucket via `cloud: false` in `company.yaml`. Slugs that already
1338
- // own a team bucket (anything else in `plan`) are excluded so a
1339
- // company is never double-written.
1340
- const pushPaths = target.personalMode === true
1341
- ? computePersonalVaultPaths(parsed.hqRoot, {
1342
- includeLocalCompanies,
1343
- teamSyncedSlugs,
1344
- })
1345
- : [path.join(parsed.hqRoot, "companies", target.slug)];
1346
- // For the personal slot, hand share() a list of `companies/{slug}/`
1347
- // prefixes for every team-synced slug. If the personal-bucket
1348
- // journal still holds entries under any of those prefixes — i.e.
1349
- // the company used to ride the personal-bucket fallback and was
1350
- // since promoted to its own team bucket — share() will sweep
1351
- // those orphans via DeleteObject + journal removal. Unconditional
1352
- // (not gated on `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL`) so cleanup
1353
- // still runs after an operator turns the feature off: the on-ramp
1354
- // is opt-in but the off-ramp is always safe to traverse.
1355
- const decommissionPrefixes = target.personalMode === true
1356
- ? Array.from(teamSyncedSlugs).map((slug) => `companies/${slug}`)
1357
- : undefined;
1358
- pushResult = await shareFn({
1359
- paths: pushPaths,
1360
- company: target.uid,
1361
- vaultConfig,
1362
- hqRoot: parsed.hqRoot,
1363
- onConflict: parsed.onConflict,
1364
- skipUnchanged: true,
1365
- // Local deletes propagate to S3 as soft deletes (versioning is on
1366
- // — DeleteObject writes a delete-marker, prior versions remain
1367
- // recoverable). Without this, a deleted file resurfaces on the
1368
- // next pull because the remote object is still listable.
1369
- //
1370
- // Policy default in 5.24 is `owned-only` (pre-5.24 behavior;
1371
- // preserved for the soak window). `HQ_SYNC_DELETE_POLICY` env
1372
- // can opt INTO the safer `currency-gated` (per-file HEAD + ETag
1373
- // verification) or the unsafe `all` (emergency reconcile only).
1374
- // Default flips to `currency-gated` in 5.25 after at least one
1375
- // machine has soaked the new path. Both personal and company
1376
- // targets use the same resolver — same engine, same flip.
1377
- propagateDeletes: true,
1378
- propagateDeletePolicy: resolveDeletePolicy(),
1379
- onEvent: tagAndEmit,
1380
- ...(uploadAuthor ? { author: uploadAuthor } : {}),
1381
- // Mirror the pull-side seam: only spread these for the personal
1382
- // slot so company-target args stay identical to the pre-Slice-2
1383
- // shape (the "no personalMode/journalSlug keys" regression test
1384
- // in sync-runner.test.ts pins that contract).
1385
- ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1386
- ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1387
- ...(decommissionPrefixes && decommissionPrefixes.length > 0
1388
- ? { decommissionPrefixes }
1389
- : {}),
1390
- // US-005 symmetry: scope the PUSH plan to the membership's granted
1391
- // ACL prefixes so out-of-scope keys are skipped (and surfaced via a
1392
- // `scope-excluded` event) instead of drawing the server's 403 and
1393
- // aborting the company. `undefined` for `syncMode: "all"` (owner /
1394
- // personal) → no scope filter, identical to the pre-fix shape so the
1395
- // "company-target args" contract test stays green.
1396
- ...(scope.prefixSet !== undefined ? { prefixSet: scope.prefixSet } : {}),
1397
- });
1398
- }
1399
-
1400
- // Pull runs unless the push phase aborted on conflict — aborted means
1401
- // the user has local edits + remote drift; blindly pulling would erase
1402
- // whichever side `--on-conflict abort` just protected.
1403
- if (doPull && !pushResult.aborted) {
1404
- activePhase = "pull";
1405
- // US-005: the pull only materializes in-scope keys (and prunes clean
1406
- // orphans when scope shrank). Reuse the `scope` resolved once above so
1407
- // push and pull apply the SAME granted prefixes and we avoid a second
1408
- // `listMyExplicitGrants` round-trip per company.
1409
- const pullScope: PullScope = scope;
1410
- pullResult = await syncFn({
1411
- company: target.uid,
1412
- vaultConfig,
1413
- hqRoot: parsed.hqRoot,
1414
- onConflict: parsed.onConflict,
1415
- syncMode: pullScope.syncMode,
1416
- // The menubar runner can take no interactive flag, so a scope shrink
1417
- // must NEVER throw here (the old `ScopeShrinkBlockedError` → exit 2
1418
- // was the permanent wedge in DEV-1768). Self-heal non-destructively:
1419
- // dirty out-of-scope files stay on disk + un-tracked, clean ones are
1420
- // quarantined (recoverable). This also clears an already-wedged
1421
- // journal — seeded by a buggy `all`-mode CLI pull — on the next sync.
1422
- scopeShrinkPolicy: "auto-recover",
1423
- // Scope-shrink authorship guard: pass the caller's own sub (the very
1424
- // sub stamped onto uploads as `created-by-sub`) so a scope shrink
1425
- // never prunes content this owner authored. Owners hold their whole
1426
- // vault by role-bypass, so without this a `shared`/`custom` pull
1427
- // would treat their own un-granted work as a foreign orphan.
1428
- ...(uploadAuthor?.userSub !== undefined
1429
- ? { callerSub: uploadAuthor.userSub }
1430
- : {}),
1431
- ...(pullScope.prefixSet !== undefined
1432
- ? { prefixSet: pullScope.prefixSet }
1433
- : {}),
1434
- ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1435
- ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1436
- // Symmetric to the push side: for the personal slot, tell sync()
1437
- // how to interpret `companies/{slug}/...` keys in the personal
1438
- // bucket. With `includeLocalCompanies: true`, keys for slugs NOT
1439
- // in `teamSyncedSlugs` are downloaded (legitimate cloud:false
1440
- // opt-in content from another machine); keys for slugs IN that
1441
- // set are dropped as orphans (pre-decommission residue from a
1442
- // promoted company). With `includeLocalCompanies: false` the
1443
- // legacy pre-5.20 behavior holds: all `companies/...` keys are
1444
- // dropped. Only spread for the personal slot so company-target
1445
- // args stay identical to pre-Slice-2 shape (test C pin).
1446
- ...(target.personalMode === true
1447
- ? {
1448
- includeLocalCompanies,
1449
- teamSyncedSlugs,
1450
- }
1451
- : {}),
1452
- ...(deps.operationLockAlreadyHeld
1453
- ? { operationLockAlreadyHeld: true }
1454
- : {}),
1455
- onEvent: tagAndEmit,
1456
- });
1457
- }
1458
-
1459
- // Concat push + pull conflict paths into a single per-company list,
1460
- // then dedupe — a key that legitimately conflicts on both halves of
1461
- // a bidirectional run (e.g. `.hq/install-manifest.json` round-trip
1462
- // before Fix #1) appears twice in the concat but represents a single
1463
- // logical conflict for the operator. Bug #3 in the 5.33.0 deep-test:
1464
- // every round produced `conflictPaths: [X, X]` and `conflicts: 2`,
1465
- // double-counting the conflict in every metric downstream. The push
1466
- // and pull halves each emit a `conflict` event in their own direction
1467
- // (preserved on the event stream for tracing); only the merged
1468
- // result list is collapsed. Stable first-seen order is preserved so
1469
- // consumers can rely on the pull entry coming before its push twin.
1470
- const seenConflictPaths = new Set<string>();
1471
- const mergedConflictPaths: string[] = [];
1472
- for (const p of [...pullResult.conflictPaths, ...pushResult.conflictPaths]) {
1473
- if (seenConflictPaths.has(p)) continue;
1474
- seenConflictPaths.add(p);
1475
- mergedConflictPaths.push(p);
1476
- }
1477
- const aborted = pullResult.aborted || pushResult.aborted;
1478
-
1479
- // Overwrite the progress-derived counts with the authoritative numbers
1480
- // from the sync/share return values. The `progress` stream over-counts
1481
- // when the inner walker emits a progress row for a file it then skips
1482
- // due to a journal hit — a clean return value is the source of truth.
1483
- // For the throw case below this overwrite never runs, so `state` keeps
1484
- // its progress-derived counts (which is exactly what we want there).
1485
- state.filesDownloaded = pullResult.filesDownloaded;
1486
- state.bytesDownloaded = pullResult.bytesDownloaded;
1487
- state.filesUploaded = pushResult.filesUploaded;
1488
- state.bytesUploaded = pushResult.bytesUploaded;
1489
- state.status = companyHadTransferError
1490
- ? "errored"
1491
- : aborted
1492
- ? "aborted"
1493
- : "complete";
1494
-
1495
- emit({
1496
- type: "complete",
1497
- company: companyLabel,
1498
- filesDownloaded: pullResult.filesDownloaded,
1499
- bytesDownloaded: pullResult.bytesDownloaded,
1500
- filesUploaded: pushResult.filesUploaded,
1501
- bytesUploaded: pushResult.bytesUploaded,
1502
- filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
1503
- // Push-side counters surfaced on `complete` so the menubar's
1504
- // `SyncCompleteEvent` (which carries them as Option<u32> for
1505
- // back-compat with pre-5.25 engines) can render the new totals.
1506
- // Always emitted as numbers (0 when no push leg ran) so Rust's
1507
- // serde decodes them as `Some(0)` rather than `None` — distinct
1508
- // from the legacy-engine `None` and useful when the UI wants to
1509
- // distinguish "engine ran, nothing tombstoned" from "engine
1510
- // didn't report".
1511
- // Tombstones now flow on both legs:
1512
- // - push side: `ShareResult.filesTombstoned` (remote was already
1513
- // 404 at HEAD time, journal entry dropped).
1514
- // - pull side: `SyncResult.filesTombstoned` (Bug #9 — journal-
1515
- // known key missing from remote LIST, applied as local delete).
1516
- // Sum them so the menubar's `SyncCompleteEvent` reflects the total
1517
- // delete-propagation activity for that company across the run.
1518
- filesTombstoned: pushResult.filesTombstoned + pullResult.filesTombstoned,
1519
- filesRefusedStale: pushResult.filesRefusedStale,
1520
- // Bonus diagnostic: surface the paths so operators can triage the
1521
- // recurring `filesRefusedStale: N` signal — the count alone was
1522
- // untriageable per the 5.33.0 deep-test report's "205 issue".
1523
- // Pre-capped at 50 by share() itself.
1524
- filesRefusedStalePaths: pushResult.filesRefusedStalePaths,
1525
- // Pull side now reports an `filesExcludedByPolicy` too (Bug #2 —
1526
- // ephemeral conflict-mirror refusals in the pull walker). Sum
1527
- // both legs so the `complete` event reports total excluded across
1528
- // the full bidirectional pass; pre-fix the pull half silently
1529
- // pushed legacy `.conflict-*` litter into clean trees with the
1530
- // same counter showing 0.
1531
- filesExcludedByPolicy:
1532
- pushResult.filesExcludedByPolicy + pullResult.filesExcludedByPolicy,
1533
- // Sourced from the merged path list so push-side conflicts are
1534
- // counted too — `ShareResult` doesn't expose a numeric counter,
1535
- // and using `pullResult.conflicts` alone silently dropped any
1536
- // push conflict from the count while leaving its path in
1537
- // `conflictPaths`.
1538
- conflicts: mergedConflictPaths.length,
1539
- conflictPaths: mergedConflictPaths,
1540
- // Either phase aborting marks the company aborted — the UI treats
1541
- // `aborted: true` as "sync didn't complete cleanly for this company".
1542
- aborted,
1543
- newFiles: pullResult.newFiles,
1544
- newFilesCount: pullResult.newFilesCount,
1545
- // Scope-aware download counters (US-005). Pull-only — the push leg
1546
- // has no scope concept — so they pass through from `pullResult`.
1547
- filesOutOfScope: pullResult.filesOutOfScope,
1548
- scopeOrphansRemoved: pullResult.scopeOrphansRemoved,
1549
- scopeOrphansBlocked: pullResult.scopeOrphansBlocked,
1550
- });
1551
- for (const p of pullResult.conflictPaths) {
1552
- allConflicts.push({ company: companyLabel, path: p, direction: "pull" });
1553
- }
1554
- for (const p of pushResult.conflictPaths) {
1555
- allConflicts.push({ company: companyLabel, path: p, direction: "push" });
1556
- }
1557
- } catch (err) {
1558
- // describeError walks the cause chain so AWS SDK v3's "UnknownError"
1559
- // wrapper surfaces the underlying Node networking error (ENOTFOUND,
1560
- // ECONNRESET, …) instead of an unactionable bare "UnknownError".
1561
- const message = describeError(err);
1562
- errors.push({ company: companyLabel, message });
1563
- // `state.status` was seeded as "errored" at loop entry — the throw
1564
- // path leaves it there, and `state.files{Down,Up}loaded` reflects the
1565
- // partial counts captured from `progress` events before the throw.
1566
- // Emit a `complete` event with `aborted: true` and those partial
1567
- // counts so consumers walking the `complete` event stream see every
1568
- // company in the fanout uniformly. This is the fix for the misleading
1569
- // rollup — see file header `Exit code: 2` doc.
1570
- emit({
1571
- type: "complete",
1572
- company: companyLabel,
1573
- filesDownloaded: state.filesDownloaded,
1574
- bytesDownloaded: state.bytesDownloaded,
1575
- filesUploaded: state.filesUploaded,
1576
- bytesUploaded: state.bytesUploaded,
1577
- filesSkipped: 0,
1578
- // Mid-flight throw: we have no clean ShareResult to read these
1579
- // from. Report 0 so the event shape stays stable; the partial
1580
- // counts above already reflect what actually moved before the
1581
- // throw.
1582
- filesTombstoned: 0,
1583
- filesRefusedStale: 0,
1584
- filesRefusedStalePaths: [],
1585
- filesExcludedByPolicy: 0,
1586
- conflicts: 0,
1587
- conflictPaths: [],
1588
- aborted: true,
1589
- newFiles: [],
1590
- newFilesCount: 0,
1591
- // Mid-flight throw: no clean scope counts to report. 0 keeps the
1592
- // event shape stable (US-005).
1593
- filesOutOfScope: 0,
1594
- scopeOrphansRemoved: 0,
1595
- scopeOrphansBlocked: 0,
1596
- });
1597
- emit({
1598
- type: "error",
1599
- company: companyLabel,
1600
- path: "(company)",
1601
- message,
1602
- });
1603
- // Continue — one company's failure shouldn't abort the whole fanout.
1604
- }
1605
- }
1606
-
1607
- // Walk every per-company entry — the map holds one row per planned company,
1608
- // including ones that aborted via thrown exception. This is the fix for the
1609
- // bug where `all-complete` reported `filesDownloaded: 0` for an aborted
1610
- // personal-sync that had already emitted thousands of `progress` events:
1611
- // the rollup used to only sum companies that emitted a clean `complete`,
1612
- // which silently dropped partials when the sync function threw.
1613
- let totalDownloaded = 0;
1614
- let totalDownloadedBytes = 0;
1615
- let totalUploaded = 0;
1616
- let totalUploadedBytes = 0;
1617
- let partial = false;
1618
- const companies: Array<{
1619
- company: string;
1620
- status: CompanyStatus;
1621
- filesDownloaded: number;
1622
- bytesDownloaded: number;
1623
- filesUploaded: number;
1624
- bytesUploaded: number;
1625
- }> = [];
1626
- for (const target of plan) {
1627
- const s = stateByCompany.get(target.slug);
1628
- if (!s) continue; // unreachable — every plan entry seeds the map
1629
- totalDownloaded += s.filesDownloaded;
1630
- totalDownloadedBytes += s.bytesDownloaded;
1631
- totalUploaded += s.filesUploaded;
1632
- totalUploadedBytes += s.bytesUploaded;
1633
- if (s.status !== "complete") partial = true;
1634
- companies.push({
1635
- company: s.company,
1636
- status: s.status,
1637
- filesDownloaded: s.filesDownloaded,
1638
- bytesDownloaded: s.bytesDownloaded,
1639
- filesUploaded: s.filesUploaded,
1640
- bytesUploaded: s.bytesUploaded,
1641
- });
1642
- }
1045
+ const fanout = await executeCompanyFanout({
1046
+ plan,
1047
+ direction: parsed.direction,
1048
+ hqRoot: parsed.hqRoot,
1049
+ onConflict: parsed.onConflict,
1050
+ client,
1051
+ vaultConfig,
1052
+ ...(uploadAuthor ? { uploadAuthor } : {}),
1053
+ ...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
1054
+ syncFn,
1055
+ shareFn,
1056
+ resolveDeletePolicy,
1057
+ emit,
1058
+ });
1059
+ const { errors, allConflicts } = fanout;
1060
+ const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1643
1061
 
1644
1062
  // Fire telemetry collector before the all-complete emit so the cursor at
1645
1063
  // `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
1646
- // Awaited fully — fire-and-forget would lose in-flight POSTs at process
1647
- // exit, and the previous 10s race was wrong: a first-run user with
1648
- // backlog (e.g. 60K Claude session events) takes well over 10s legitimately,
1649
- // and the race silently dropped the entire batch when it fired. Errors
1650
- // are swallowed inside `defaultCollectTelemetry`; per-request timeouts
1651
- // come from `VaultClient`'s retry loop (3 attempts × exponential backoff),
1652
- // which naturally bounds the outer wait.
1653
- const telemetryFn =
1654
- deps.collectTelemetry ??
1655
- (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined, parsed.hqRoot));
1656
- await telemetryFn().catch(() => undefined);
1064
+ await emitTelemetry({
1065
+ collectTelemetry: deps.collectTelemetry,
1066
+ defaultCollectTelemetry: () =>
1067
+ defaultCollectTelemetry(
1068
+ client,
1069
+ deps.createVaultClient !== undefined,
1070
+ parsed.hqRoot,
1071
+ ),
1072
+ });
1657
1073
 
1658
1074
  emit({
1659
1075
  type: "all-complete",
1660
1076
  companiesAttempted: plan.length,
1661
- filesDownloaded: totalDownloaded,
1662
- bytesDownloaded: totalDownloadedBytes,
1663
- filesUploaded: totalUploaded,
1664
- bytesUploaded: totalUploadedBytes,
1077
+ filesDownloaded: rollup.totalDownloaded,
1078
+ bytesDownloaded: rollup.totalDownloadedBytes,
1079
+ filesUploaded: rollup.totalUploaded,
1080
+ bytesUploaded: rollup.totalUploadedBytes,
1665
1081
  conflictPaths: allConflicts,
1666
1082
  errors,
1667
- partial,
1668
- companies,
1083
+ partial: rollup.partial,
1084
+ companies: rollup.companies,
1669
1085
  });
1670
1086
 
1671
1087
  // Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
@@ -1674,7 +1090,10 @@ export async function runRunner(
1674
1090
  // pulled in (nothing to reindex otherwise) and not explicitly disabled.
1675
1091
  // Self-contained: shells out to the global `qmd` binary, no dependency on
1676
1092
  // any (possibly stale) script inside the synced HQ tree. See qmd-reindex.ts.
1677
- if (totalDownloaded > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
1093
+ if (
1094
+ rollup.totalDownloaded > 0 &&
1095
+ process.env.HQ_QMD_REINDEX_ON_SYNC !== "0"
1096
+ ) {
1678
1097
  try {
1679
1098
  reindexAfterSync(parsed.hqRoot);
1680
1099
  } catch {
@@ -1849,604 +1268,28 @@ export interface WatcherSurface {
1849
1268
  dispose(): void;
1850
1269
  }
1851
1270
 
1852
- /**
1853
- * Route a changed relative path to the push target that owns it.
1854
- *
1855
- * - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
1856
- * pass per PRD decision — push only the changed company, not a full
1857
- * `--companies` fanout).
1858
- * - anything else under hqRoot → the personal target (a `--companies` push
1859
- * restricted to personal via the personal-vault scope; modeled here as the
1860
- * "personal" route the caller maps to the right argv).
1861
- *
1862
- * Returns `null` for paths the routing cannot attribute (defensive — the
1863
- * watcher's filter already drops excluded top-levels, so this is belt-and-
1864
- * suspenders for synthetic events).
1865
- */
1866
- export function routeChangeToTarget(
1867
- relPath: string,
1868
- ): { kind: "company"; slug: string } | { kind: "personal" } | null {
1869
- const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
1870
- if (norm === "" || norm.startsWith("..")) return null;
1871
- const segments = norm.split("/").filter((s) => s.length > 0);
1872
- if (segments.length === 0) return null;
1873
- if (segments[0] === "companies") {
1874
- // companies/<slug>/... — need at least the slug segment to target.
1875
- if (segments.length < 2 || segments[1].length === 0) return null;
1876
- return { kind: "company", slug: segments[1] };
1877
- }
1878
- return { kind: "personal" };
1879
- }
1880
-
1881
- /**
1882
- * Build the argv for a targeted push pass from a routed change. The push runs
1883
- * `--direction push` for just the routed target so a local edit propagates in
1884
- * seconds without a full fanout. Company routes use `--company <slug>`;
1885
- * personal routes use `--companies --direction push` (the personal-vault scope
1886
- * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
1887
- * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
1888
- * the base argv. Pure helper, exported for unit testing the routing→argv map.
1889
- */
1890
- export function buildTargetedPushArgv(
1891
- route: { kind: "company"; slug: string } | { kind: "personal" },
1892
- baseArgv: string[],
1893
- ): string[] {
1894
- const carried = carriedFlags(baseArgv);
1895
- if (route.kind === "company") {
1896
- return ["--company", route.slug, "--direction", "push", ...carried];
1897
- }
1898
- return ["--companies", "--direction", "push", ...carried];
1899
- }
1900
-
1901
- type WatchRoute = NonNullable<ReturnType<typeof routeChangeToTarget>>;
1902
-
1903
- function routeKey(route: WatchRoute): string {
1904
- return route.kind === "company" ? `company:${route.slug}` : "personal";
1905
- }
1906
-
1907
- function routesForBatch(batch: TreeChangeBatch): Map<string, WatchRoute> {
1908
- const routes = new Map<string, WatchRoute>();
1909
- for (const relPath of batch.paths.values()) {
1910
- const route = routeChangeToTarget(relPath);
1911
- if (!route) continue;
1912
- routes.set(routeKey(route), route);
1913
- }
1914
- return routes;
1915
- }
1916
-
1917
- function buildFullFanoutPushArgv(baseArgv: string[]): string[] {
1918
- return ["--companies", "--direction", "push", ...carriedFlags(baseArgv)];
1919
- }
1920
-
1921
- /**
1922
- * Build the argv for a targeted PULL pass from a routed change (US-009 — the
1923
- * receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
1924
- * with `--direction pull`: a peer device pushed a change, so this device pulls
1925
- * just the affected company/subtree instead of waiting for the next
1926
- * `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
1927
- * routes use `--companies` (the personal-vault scope is resolved inside
1928
- * runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
1929
- * Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
1930
- * exported for unit testing the event→argv map.
1931
- */
1932
- export function buildTargetedPullArgv(
1933
- route: { kind: "company"; slug: string } | { kind: "personal" },
1934
- baseArgv: string[],
1935
- ): string[] {
1936
- const carried = carriedFlags(baseArgv);
1937
- if (route.kind === "company") {
1938
- return ["--company", route.slug, "--direction", "pull", ...carried];
1939
- }
1940
- return ["--companies", "--direction", "pull", ...carried];
1941
- }
1942
-
1943
1271
  export async function runRunnerWithLoop(
1944
1272
  argv: string[],
1945
1273
  deps: RunnerLoopDeps = {},
1946
1274
  ): Promise<number> {
1947
1275
  const parsed = parseArgs(argv);
1948
- if (!argv.includes("--watch")) {
1949
- // One-shot cloud sync — take the per-root operation lock so it is mutually
1950
- // exclusive with rescue/reindex. If args don't parse, fall through to
1951
- // `runRunner` so it surfaces the parse error rather than us masking it
1952
- // with a lock failure.
1953
- if ("error" in parsed) return runRunner(argv);
1954
- // The actual sync pass — same seam the watch loop uses (deps.runPass),
1955
- // so a test can assert "waits for a short-lived holder, THEN proceeds to
1956
- // sync" without touching the network. Production passes `argv` to
1957
- // runRunner exactly as before. Regression guard for DEV-1772
1958
- // (feedback_28a1833f): instant-sync one-shots used to exit 17 and die on
1959
- // a lock conflict with the ~1-min reindex hook; they now WAIT (default)
1960
- // and proceed once the short holder releases.
1961
- const runOnce =
1962
- deps.runPass ??
1963
- ((passArgv: string[]) =>
1964
- runRunner(passArgv, { operationLockAlreadyHeld: true }));
1965
- try {
1966
- return await withOperationLock(parsed.hqRoot, "sync", () => runOnce(argv), {
1967
- timeoutSec: parsed.lockTimeoutSec,
1968
- });
1969
- } catch (err) {
1970
- if (err instanceof OperationLockedError) {
1971
- // The lock wait was BOUNDED and tripped (a holder never released
1972
- // within --lock-timeout / HQ_OP_LOCK_TIMEOUT). Surface it loudly on
1973
- // stderr and exit with the stable OPERATION_LOCKED_EXIT (17) so the
1974
- // spawner (menubar) can recognize a lock conflict and SCHEDULE A
1975
- // RETRY rather than treating it as a hard failure and silently giving
1976
- // up (DEV-1772). With the default (no bound) we never reach here — the
1977
- // one-shot waits indefinitely and proceeds.
1978
- process.stderr.write(err.message + "\n");
1979
- return OPERATION_LOCKED_EXIT;
1980
- }
1981
- throw err;
1982
- }
1983
- }
1984
- if ("error" in parsed) return runRunner(argv);
1985
- const sleep =
1986
- deps.sleep ??
1987
- ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1988
- const runPass =
1989
- deps.runPass ??
1990
- ((passArgv: string[]) =>
1991
- runRunner(passArgv, { operationLockAlreadyHeld: true }));
1992
- const pollIdx = argv.indexOf("--poll-remote-ms");
1993
- const pollMs =
1994
- pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
1995
- const eventPush = argv.includes("--event-push");
1996
- // In `--companies` mode the sync scope is companies/*/{knowledge,projects,…}
1997
- // (per .hqinclude), so the watcher must NOT apply the personal-vault
1998
- // top-level exclusions (PERSONAL_VAULT_EXCLUDED_TOP_LEVEL drops `companies/`
1999
- // and `workspace/`) — doing so would exclude exactly the paths being synced,
2000
- // and no local edit would ever trigger an instant push. The shared ignore
2001
- // stack (createIgnoreFilter / .hqignore / .hqinclude) already scopes the
2002
- // watch filter correctly in companies mode. personalMode is only for a
2003
- // personal-vault-as-root run, where companies/ et al. genuinely aren't synced.
2004
- const companiesMode = argv.includes("--companies");
2005
- const hqRoot = parsed.hqRoot;
2006
-
2007
- // Strip the loop-only flags before delegating: the parser inside runRunner
2008
- // accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
2009
- // iteration pass to think it's re-entering watch mode.
2010
- const passArgv = argv.filter((a, i) => {
2011
- if (a === "--watch") return false;
2012
- if (a === "--poll-remote-ms") return false;
2013
- if (a === "--event-push") return false;
2014
- if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
2015
- return true;
2016
- });
2017
-
2018
- if (parsed.lockTimeoutSec === 0) {
2019
- try {
2020
- const handle = acquireOperationLock(hqRoot, "sync", {
2021
- timeoutSec: 0,
2022
- onWaitStart: () => undefined,
2023
- });
2024
- handle.release();
2025
- } catch (err) {
2026
- if (err instanceof OperationLockedError) {
2027
- process.stderr.write(err.message + "\n");
2028
- return OPERATION_LOCKED_EXIT;
2029
- }
2030
- throw err;
2031
- }
2032
- }
2033
-
2034
- const runPassWithLock = async (passArgvForRun: string[]): Promise<number> => {
2035
- try {
2036
- const handle = acquireOperationLock(hqRoot, "sync", {
2037
- timeoutSec: 0,
2038
- onWaitStart: () => undefined,
2039
- });
2040
- try {
2041
- return await runPass(passArgvForRun);
2042
- } finally {
2043
- handle.release();
2044
- }
2045
- } catch (err) {
2046
- if (!(err instanceof OperationLockedError)) throw err;
2047
- if (parsed.lockTimeoutSec === 0) {
2048
- process.stderr.write(err.message + "\n");
2049
- return OPERATION_LOCKED_EXIT;
2050
- }
2051
- }
2052
-
2053
- try {
2054
- return await withOperationLock(
2055
- hqRoot,
2056
- "sync",
2057
- () => runPass(passArgvForRun),
2058
- { timeoutSec: parsed.lockTimeoutSec },
2059
- );
2060
- } catch (err) {
2061
- if (err instanceof OperationLockedError) {
2062
- process.stderr.write(err.message + "\n");
2063
- return OPERATION_LOCKED_EXIT;
2064
- }
2065
- throw err;
2066
- }
2067
- };
2068
-
2069
- // ---- shared pass queue -----------------------------------------------
2070
- // The poll loop, pull-on-event receiver, and watcher-triggered pushes all
2071
- // funnel through this queue so local/remote triggers never overlap, and a
2072
- // trigger arriving during an active pass runs immediately after it instead
2073
- // of being dropped.
2074
- let stopped = false;
2075
- let activePass: Promise<number> | null = null;
2076
- type QueuedPass = {
2077
- argv: string[];
2078
- resolve: (code: number) => void;
2079
- reject: (err: unknown) => void;
2080
- };
2081
- const pendingPasses: QueuedPass[] = [];
2082
- const resolveStoppedQueue = (): void => {
2083
- while (pendingPasses.length > 0) {
2084
- pendingPasses.shift()?.resolve(0);
2085
- }
2086
- };
2087
- const drainQueuedPasses = (): void => {
2088
- if (activePass !== null) return;
2089
- if (stopped) {
2090
- resolveStoppedQueue();
2091
- return;
2092
- }
2093
- const next = pendingPasses.shift();
2094
- if (!next) return;
2095
- const current = startGuardedPass(next.argv);
2096
- void current.then(next.resolve, next.reject);
2097
- };
2098
- const startGuardedPass = (passArgvForRun: string[]): Promise<number> => {
2099
- const current = stopped
2100
- ? Promise.resolve(0)
2101
- : runPassWithLock(passArgvForRun);
2102
- activePass = current;
2103
- void current
2104
- .finally(() => {
2105
- if (activePass === current) {
2106
- activePass = null;
2107
- drainQueuedPasses();
2108
- }
2109
- })
2110
- .catch(() => undefined);
2111
- return current;
2112
- };
2113
- const runGuarded = (passArgvForRun: string[]): Promise<number> => {
2114
- if (activePass === null && pendingPasses.length === 0) {
2115
- return startGuardedPass(passArgvForRun);
2116
- }
2117
-
2118
- return new Promise<number>((resolve, reject) => {
2119
- pendingPasses.push({ argv: passArgvForRun, resolve, reject });
2120
- drainQueuedPasses();
2121
- });
2122
- };
2123
-
2124
- // ---- event-push wiring (Phase 1) -------------------------------------
2125
- let watcher: WatcherSurface | null = null;
2126
- let driver: WatchPushDriver | null = null;
2127
- let detachSignal: (() => void) | null = null;
2128
- const pendingWatcherPaths = new Map<string, string>();
2129
- let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
2130
- let pendingWatcherBareChange = false;
2131
- let pendingWatcherOverflowed = false;
2132
- let pendingWatcherDroppedPaths = 0;
2133
- let pendingWatcherDroppedBytes = 0;
2134
- const addPendingWatcherChange = (
2135
- changedRelPath?: string,
2136
- batch?: TreeChangeBatch,
2137
- ): void => {
2138
- if (batch) {
2139
- pendingWatcherOriginalBatch =
2140
- pendingWatcherPaths.size === 0 &&
2141
- !pendingWatcherBareChange &&
2142
- !pendingWatcherOverflowed &&
2143
- !batch.overflowed
2144
- ? batch
2145
- : null;
2146
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
2147
- pendingWatcherPaths.set(absolutePath, relativePath);
2148
- }
2149
- if (batch.overflowed) {
2150
- pendingWatcherOverflowed = true;
2151
- pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
2152
- pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
2153
- }
2154
- return;
2155
- }
2156
- if (changedRelPath) {
2157
- pendingWatcherOriginalBatch = null;
2158
- pendingWatcherPaths.set(path.join(hqRoot, changedRelPath), changedRelPath);
2159
- return;
2160
- }
2161
- pendingWatcherOriginalBatch = null;
2162
- pendingWatcherBareChange = true;
2163
- };
2164
- const takePendingWatcherChange = (): {
2165
- rel: string | null;
2166
- batch: TreeChangeBatch | null;
2167
- } => {
2168
- const batch =
2169
- pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
2170
- ? pendingWatcherOriginalBatch
2171
- : pendingWatcherPaths.size > 0 || pendingWatcherOverflowed
2172
- ? {
2173
- paths: new Map(pendingWatcherPaths),
2174
- ...(pendingWatcherOverflowed
2175
- ? {
2176
- overflowed: true,
2177
- droppedPaths: pendingWatcherDroppedPaths,
2178
- droppedBytes: pendingWatcherDroppedBytes,
2179
- }
2180
- : {}),
2181
- }
2182
- : null;
2183
- const rel = [...pendingWatcherPaths.values()][0] ?? null;
2184
- const fallbackRel = rel ?? (pendingWatcherBareChange ? null : null);
2185
- pendingWatcherPaths.clear();
2186
- pendingWatcherOriginalBatch = null;
2187
- pendingWatcherBareChange = false;
2188
- pendingWatcherOverflowed = false;
2189
- pendingWatcherDroppedPaths = 0;
2190
- pendingWatcherDroppedBytes = 0;
2191
- return { rel: fallbackRel, batch };
2192
- };
2193
- // ---- pull-on-event receiver (Phase 2, US-009) ------------------------
2194
- // Started after the watcher, disposed before the watcher (mirror of the
2195
- // PushTransport ordering). Dormant by default: the default factory returns
2196
- // a NoopPushReceiver, and even a real receiver stays dormant unless the
2197
- // per-tenant feature flag is on AND a queue URL is provisioned server-side.
2198
- let receiver: PushReceiver | null = null;
2199
- // ---- event-driven publish + pull (Phase 3, US-017/018/019) ------------
2200
- // Brought up asynchronously after the watcher when the rollout gate
2201
- // passes; null until ready (and stays null on startup failure → poll-only).
2202
- let eventSync: EventSyncHandles | null = null;
2203
-
2204
- if (eventPush) {
2205
- const clock = deps.clock ?? systemClock;
2206
- const debounceMs = 2000;
2207
- const createWatcher =
2208
- deps.createWatcher ??
2209
- ((opts) =>
2210
- new TreeWatcher({
2211
- hqRoot: opts.hqRoot,
2212
- debounceMs: opts.debounceMs,
2213
- clock: opts.clock,
2214
- // false in --companies mode so the watch filter matches the sync
2215
- // scope (companies/* are included via .hqinclude); true only for a
2216
- // personal-vault-as-root run.
2217
- personalMode: !companiesMode,
2218
- }));
2219
- watcher = createWatcher({ hqRoot, debounceMs, clock });
2220
-
2221
- // The driver runs the targeted push when a debounced burst settles. Its
2222
- // concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
2223
- // through `runGuarded` so a poll pass in flight is never overlapped.
2224
- driver = new WatchPushDriver({
2225
- debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
2226
- clock,
2227
- push: async () => {
2228
- if (stopped) return;
2229
- // Snapshot accumulated watcher work BEFORE the await. Changes landing
2230
- // mid-pass are accumulated for the NEXT pass instead of overwriting
2231
- // this pass's publish target.
2232
- const { rel, batch: batchForPublish } = takePendingWatcherChange();
2233
- const batchRoutes = batchForPublish
2234
- ? routesForBatch(batchForPublish)
2235
- : new Map<string, WatchRoute>();
2236
- let targetedArgv: string[];
2237
- if (batchForPublish?.overflowed || batchRoutes.size > 1) {
2238
- targetedArgv = buildFullFanoutPushArgv(passArgv);
2239
- } else {
2240
- const route =
2241
- batchRoutes.size === 1
2242
- ? [...batchRoutes.values()][0]
2243
- : rel
2244
- ? routeChangeToTarget(rel)
2245
- : { kind: "personal" as const };
2246
- if (!route) return;
2247
- targetedArgv = buildTargetedPushArgv(route, passArgv);
2248
- }
2249
- const result = await runGuarded(targetedArgv);
2250
- // Phase 3 (US-017): publish PushEvents only AFTER the targeted push
2251
- // pass succeeded — an event must never announce bytes that are not
2252
- // in S3 yet. A failed pass publishes nothing; queued passes run after
2253
- // the active pass instead of dropping the watcher-triggered push. Fall
2254
- // back to a single-path batch when the watcher emitted a bare path
2255
- // signal. Overflowed batches deliberately publish nothing because the
2256
- // exact path set was dropped; the full fanout push plus cadence poll is
2257
- // the resync signal.
2258
- if (result === 0 && !stopped && eventSync && !batchForPublish?.overflowed) {
2259
- const batch: TreeChangeBatch | null =
2260
- batchForPublish ??
2261
- (rel ? { paths: new Map([[path.join(hqRoot, rel), rel]]) } : null);
2262
- if (batch) eventSync.publishBatch(batch);
2263
- }
2264
- },
2265
- });
2266
-
2267
- // A debounced TreeWatcher 'changed' signal feeds the driver, which fires
2268
- // the targeted push after its own (zero) window — i.e. immediately, but
2269
- // still serialized behind any in-flight pass. A path-aware watcher passes
2270
- // the changed relative path so the push targets just its owning company;
2271
- // the bare-signal TreeWatcher leaves it null → personal-vault route.
2272
- watcher.onChange((changedRelPath, batch) => {
2273
- if (stopped) return;
2274
- addPendingWatcherChange(changedRelPath, batch);
2275
- driver?.notifyChange();
2276
- });
2277
- watcher.start();
2278
-
2279
- // Pull-on-event receiver (US-009). The injected SyncEngineFn bridges a
2280
- // received PushEvent → a TARGETED pull pass routed by relativePath, funneled
2281
- // through the SAME `runGuarded` mutex as the poll loop + watcher push so a
2282
- // pull-on-event never overlaps an in-flight pass. Started AFTER the watcher
2283
- // so a live event can't race a half-built daemon. Default factory = noop
2284
- // (dormant); a real SqsPushReceiver is injected by a later release once the
2285
- // server-side per-client SQS queue is provisioned.
2286
- const receiverSyncFn: SyncEngineFn = async (ctx) => {
2287
- if (stopped) return;
2288
- const route = routeChangeToTarget(ctx.event.relativePath);
2289
- if (!route) return;
2290
- const targetedArgv = buildTargetedPullArgv(route, passArgv);
2291
- const result = await runGuarded(targetedArgv);
2292
- if (result !== 0) {
2293
- throw new Error(`targeted pull failed with exit code ${result}`);
2294
- }
2295
- };
2296
- const createReceiver =
2297
- deps.createReceiver ?? (() => new NoopPushReceiver());
2298
- receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
2299
- // Fire-and-forget start: a receiver's start() kicks off its own poll loop
2300
- // (SqsPushReceiver) or trivially flips connected (noop) — it must NOT block
2301
- // the runner's poll loop from entering. Errors are swallowed; the cadence
2302
- // poll is the safety net regardless of receiver health. (The await-free
2303
- // start also keeps the poll loop's microtask timing identical to the
2304
- // pre-US-009 wiring.)
2305
- void Promise.resolve(receiver.start()).catch(() => undefined);
2306
-
2307
- // ---- Phase 3: event-driven publish + pull (US-017/018/019) ----------
2308
- // Gated to enrolled accounts (resolveEventSync — exact-email allowlist +
2309
- // HQ_SYNC_EVENT_SYNC override). Brought up asynchronously so a slow
2310
- // subscribe/vend can't delay the first poll pass; until (and unless) the
2311
- // handles resolve, behavior is byte-identical to the gate-off path.
2312
- const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
2313
- const email = getClaims()?.email;
2314
- if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
2315
- const getAccessToken =
2316
- deps.getAccessToken ??
2317
- (() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
2318
- const startES = deps.startEventSync ?? defaultStartEventSync;
2319
- // Entirely async + caught: NOTHING in the Phase 3 bring-up (device-id
2320
- // persistence, tenant resolution, subscribe) may crash or delay the
2321
- // daemon — any failure degrades to poll-only.
2322
- void (async () => {
2323
- const handles = await startES({
2324
- hqRoot,
2325
- apiUrl: DEFAULT_VAULT_API_URL,
2326
- authToken: getAccessToken,
2327
- deviceId: getOrCreateMachineId(hqRoot),
2328
- // The server rejects publishes whose originTenantId mismatches the
2329
- // JWT principal, so resolve the SAME canonical person uid the vault
2330
- // API derives from this token.
2331
- resolveTenantId: async () => {
2332
- const client = new VaultClient({
2333
- apiUrl: DEFAULT_VAULT_API_URL,
2334
- authToken: getAccessToken,
2335
- region: DEFAULT_COGNITO.region,
2336
- });
2337
- const persons = await client.entity.listByType("person");
2338
- const pick = pickCanonicalPersonEntity(persons);
2339
- if (!pick?.uid) {
2340
- throw new Error("no canonical person entity for this account");
2341
- }
2342
- return pick.uid;
2343
- },
2344
- syncFn: receiverSyncFn,
2345
- log: (m) => process.stderr.write(`${m}\n`),
2346
- });
2347
- if (!handles) return;
2348
- if (stopped) {
2349
- // Shutdown raced the async bring-up — tear straight down.
2350
- void handles.dispose();
2351
- return;
2352
- }
2353
- eventSync = handles;
2354
- })().catch((err) => {
2355
- process.stderr.write(
2356
- `event-sync: wiring failed, continuing poll-only: ${describeError(err)}\n`,
2357
- );
2358
- });
2359
- }
2360
- }
2361
-
2362
- // ---- clean shutdown --------------------------------------------------
2363
- // SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
2364
- // the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
2365
- // watcher + driver, and let the poll loop observe `stopped` on its next tick.
2366
- let resolveStopped: (() => void) | null = null;
2367
- const stoppedSignal = new Promise<void>((resolve) => {
2368
- resolveStopped = resolve;
2369
- });
2370
- const shutdown = (): void => {
2371
- if (stopped) return;
2372
- stopped = true;
2373
- resolveStoppedQueue();
2374
- // Dispose the receiver FIRST (mirror of the PushTransport ordering:
2375
- // inbound subscription torn down before the watcher) so no new
2376
- // pull-on-event fires mid-teardown. dispose() is async (it drains the
2377
- // in-flight pull up to its own deadline); fire-and-forget here — the
2378
- // receiver's internal drain + the runGuarded mutex bound the work, and
2379
- // SIGTERM teardown must not block. Errors are swallowed.
2380
- try {
2381
- void receiver?.dispose();
2382
- } catch {
2383
- /* ignore */
2384
- }
2385
- // Phase 3 wiring (publish transport + live receiver) — torn down with
2386
- // the same fire-and-forget posture as the Phase 2 receiver above.
2387
- try {
2388
- void eventSync?.dispose();
2389
- } catch {
2390
- /* ignore */
2391
- }
2392
- eventSync = null;
2393
- try {
2394
- driver?.dispose();
2395
- } catch {
2396
- /* ignore */
2397
- }
2398
- try {
2399
- watcher?.dispose();
2400
- } catch {
2401
- /* ignore */
2402
- }
2403
- resolveStopped?.();
1276
+ const runtime = {
1277
+ runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
1278
+ runRunner(passArgv, { operationLockAlreadyHeld: true }),
1279
+ defaultGetIdTokenClaims,
1280
+ defaultGetAccessToken: () =>
1281
+ getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),
1282
+ apiUrl: DEFAULT_VAULT_API_URL,
1283
+ region: DEFAULT_COGNITO.region,
2404
1284
  };
2405
- const onShutdownSignal =
2406
- deps.onShutdownSignal ??
2407
- ((handler: () => void) => {
2408
- const wrapped = () => handler();
2409
- process.on("SIGTERM", wrapped);
2410
- process.on("SIGINT", wrapped);
2411
- return () => {
2412
- process.off("SIGTERM", wrapped);
2413
- process.off("SIGINT", wrapped);
2414
- };
2415
- });
2416
- detachSignal = onShutdownSignal(shutdown);
2417
1285
 
2418
- try {
2419
- while (!stopped) {
2420
- const result = await runGuarded(passArgv);
2421
- if (result !== 0) {
2422
- return result;
2423
- }
2424
- // Sleep the poll interval, but wake early on shutdown so SIGTERM stops
2425
- // the loop promptly instead of waiting out a 10-minute cycle.
2426
- await Promise.race([sleep(pollMs), stoppedSignal]);
2427
- }
2428
- return 0;
2429
- } finally {
2430
- shutdown();
2431
- detachSignal?.();
1286
+ if (!argv.includes("--watch")) {
1287
+ if ("error" in parsed) return runRunner(argv);
1288
+ return runOneShotWithOperationLock(argv, parsed, deps, runtime);
2432
1289
  }
2433
- }
2434
1290
 
2435
- /**
2436
- * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
2437
- * re-targeted push pass inherits the same root and conflict policy. Pure
2438
- * helper used by the event-push targeted-push composer.
2439
- */
2440
- function carriedFlags(baseArgv: string[]): string[] {
2441
- const carried: string[] = [];
2442
- for (let i = 0; i < baseArgv.length; i++) {
2443
- const a = baseArgv[i];
2444
- if (a === "--hq-root" || a === "--on-conflict") {
2445
- carried.push(a);
2446
- if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
2447
- }
2448
- }
2449
- return carried;
1291
+ if ("error" in parsed) return runRunner(argv);
1292
+ return runWatchLoop(argv, parsed, deps, runtime);
2450
1293
  }
2451
1294
 
2452
1295
  if (isDirectInvocation) {