@indigoai-us/hq-cloud 6.15.0 → 6.15.1

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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +64 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +27 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +52 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +31 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +82 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +221 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
package/src/cli/share.ts DELETED
@@ -1,3663 +0,0 @@
1
- /**
2
- * `hq share` command — selective push to entity vault (VLT-5 US-002).
3
- *
4
- * Broadcasts local file(s) to the company's S3 vault bucket.
5
- * Refuses to overwrite a newer remote version without prompting.
6
- */
7
-
8
- import * as fs from "fs";
9
- import * as os from "os";
10
- import * as path from "path";
11
- import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
12
- import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
13
- import { createSyncProgressRecorder } from "../sync-progress.js";
14
- import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
15
- import {
16
- uploadFile,
17
- uploadSymlink,
18
- toPosixKey,
19
- headRemoteFile,
20
- deleteRemoteFile,
21
- downloadFile,
22
- primeObjectTransport,
23
- primeUploads,
24
- } from "../s3.js";
25
- import * as crypto from "crypto";
26
- import type { UploadAuthor } from "../s3.js";
27
- import type { PutPrecondition } from "../object-io.js";
28
- import {
29
- readJournal,
30
- writeJournal,
31
- hashFile,
32
- hashSymlinkTarget,
33
- updateEntry,
34
- removeEntry,
35
- isTombstone,
36
- normalizeEtag,
37
- PERSONAL_VAULT_JOURNAL_SLUG,
38
- migratePersonalVaultJournal,
39
- } from "../journal.js";
40
- import {
41
- createIgnoreFilter,
42
- hasControlCharacters,
43
- isExpectedIgnore,
44
- isWithinSizeLimit,
45
- } from "../ignore.js";
46
- import {
47
- wrapFilterWithPersonalVaultDefaults,
48
- type PersonalVaultExclusion,
49
- } from "../personal-vault-exclusions.js";
50
- import {
51
- isGeneratedCoreMirrorKey,
52
- isPersonalVaultDeletionExcluded,
53
- } from "../personal-vault.js";
54
- import { resolveConflict } from "./conflict.js";
55
- import type { ConflictStrategy } from "./conflict.js";
56
- import type { SyncProgressEvent } from "./sync.js";
57
- import {
58
- fetchCompanyTombstones,
59
- type CompanyTombstone,
60
- } from "./tombstones.js";
61
- import {
62
- isCoveredByAny,
63
- isDirInScope,
64
- type ScopePrefixInput,
65
- } from "../prefix-coalesce.js";
66
- import {
67
- hasRemoteChanged,
68
- isAccessDenied,
69
- resolveActiveCompany,
70
- resolveTransferConcurrency,
71
- } from "../sync-core.js";
72
- import {
73
- buildConflictId,
74
- buildConflictPath,
75
- readShortMachineId,
76
- } from "../lib/conflict-file.js";
77
- import { appendConflictEntry } from "../lib/conflict-index.js";
78
- import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
79
- import { VaultAuthError } from "../vault-client.js";
80
- import { describeError } from "../lib/describe-error.js";
81
- import { readlinkOrNull } from "../lib/readlink-safe.js";
82
-
83
- /**
84
- * Push-side fresh-collision convergence probe.
85
- *
86
- * For a first push (no journal entry) where the remote object already
87
- * exists, its etag is a version token rather than a reliable content hash,
88
- * so we cannot tell "byte-identical" from "genuine divergence" without
89
- * looking at the bytes. Fetch the remote object once to a throwaway temp
90
- * file, hash it the same symlink-aware way the planner hashed local, and
91
- * report whether the two contents DIFFER.
92
- *
93
- * Returns `true` on a genuine difference (a real fresh collision) and
94
- * `false` when the bytes are identical. Fails safe to `true` on any
95
- * fetch/hash error — a false positive merely prompts the operator, while a
96
- * false negative could silently clobber a peer's content. Mirrors the
97
- * pull-side convergence guard in `sync.ts`.
98
- *
99
- * The temp file lives in the OS temp dir (never under hqRoot) so it can
100
- * never round-trip to S3, and is removed in a `finally` regardless of
101
- * outcome. The name is derived from the relative path so concurrent probes
102
- * for distinct files never collide on disk.
103
- */
104
- async function remoteContentDiffers(
105
- ctx: EntityContext,
106
- relativePath: string,
107
- localHash: string,
108
- hqRoot: string,
109
- ): Promise<boolean> {
110
- const probeKey = crypto
111
- .createHash("sha256")
112
- .update(relativePath)
113
- .digest("hex")
114
- .slice(0, 16);
115
- const probePath = path.join(
116
- os.tmpdir(),
117
- `hq-conflict-probe-${readShortMachineId(hqRoot)}-${probeKey}.tmp`,
118
- );
119
- try {
120
- await downloadFile(ctx, relativePath, probePath);
121
- const remoteHash = fs.lstatSync(probePath).isSymbolicLink()
122
- ? hashSymlinkTarget(fs.readlinkSync(probePath))
123
- : hashFile(probePath);
124
- return remoteHash !== localHash;
125
- } catch (err) {
126
- if (err instanceof VaultAuthError) throw err;
127
- return true;
128
- } finally {
129
- try {
130
- fs.rmSync(probePath, { force: true });
131
- } catch {
132
- /* best-effort cleanup; a stray temp probe is harmless */
133
- }
134
- }
135
- }
136
-
137
- /**
138
- * Local-only ephemeral artifacts: conflict-mirror files written by the pull
139
- * leg whenever a 3-way merge keeps local AND wants to preserve the remote
140
- * version for inspection. Format: `<orig>.conflict-<ISO-utc>-<machineHash>[.ext]`
141
- * (e.g. `.claude/CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md`,
142
- * or `.gitignore.conflict-2026-05-13T19-40-40Z-e5797a` — extensionless
143
- * originals produce no trailing dot, see `buildConflictPath` in
144
- * `../lib/conflict-file.ts`).
145
- *
146
- * These files MUST never round-trip to S3 — they're local-only safety backups
147
- * the user reviews and deletes once the merge is resolved. Pre-fix, the push
148
- * walker happily uploaded them, the journal recorded them, and the
149
- * `owned-only` delete policy then refused to clean them up when the user
150
- * deleted them locally (because pull-confirmation had stamped them as
151
- * `direction: "down"`). Net effect: a permanent litter ratchet on remote.
152
- *
153
- * Two known producer-shapes the regex must accommodate (both observed on
154
- * affected user trees prior to this fix):
155
- *
156
- * 1. **`unknown` machine token.** Pre-`<hqRoot>/.hq/machine-id`
157
- * provisioning (see `../lib/machine-id.ts`), hosts without
158
- * `~/.hq/menubar.json` — every Linux HQ Pro Outpost, every fresh CLI
159
- * install — fell through to the literal string `"unknown"` from the
160
- * old `readShortMachineId()` fallback. The letters `k`, `n`, `o`, `w`
161
- * live outside `[a-f]`, so the pre-fix `[a-f0-9]+` class refused those
162
- * filenames. They round-tripped to S3 as ordinary files (which IS the
163
- * "permanent litter ratchet" this module's contract was supposed to
164
- * prevent). The new machine-id provisioning closes the producer side,
165
- * but we still accept `unknown` here so legacy files already on disk
166
- * are filtered out by the next push.
167
- *
168
- * 2. **Extensionless originals.** `path.extname('.gitignore')` returns
169
- * `''` in Node, so `buildConflictPath` produces no trailing `.<ext>`
170
- * segment for hidden-but-extensionless files like `.gitignore`,
171
- * `.hqignore`, or any `.agents/skills`-style entry. The pre-fix `\.`
172
- * tail was mandatory, so those names slipped through.
173
- *
174
- * Wire-points: (1) push walker — `collectFiles` / `walkDir` skip these so
175
- * they never upload; (2) `computeDeletePlan` — skip these so an already-
176
- * journaled mirror that's been deleted locally doesn't get included in the
177
- * regular delete plan (the dedicated reconcile path handles existing litter).
178
- */
179
- export const EPHEMERAL_PATH_PATTERN =
180
- /\.conflict-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-(?:[a-f0-9]+|unknown)(?:\.[^/]*)?$/;
181
-
182
- /**
183
- * Cheap pure check — pass the relative key OR a basename; either works. Used
184
- * in both the file walker (basename matching) and the delete-plan walker
185
- * (relative-key matching), and also by the pull walker in sync.ts to refuse
186
- * downloading legacy conflict-mirror files that still live in cloud staging
187
- * (Bug #2 in the 5.33.0 deep-test report — push-side filtered them since
188
- * 5.33.0 but pull-side downloaded them freely until this export). The regex
189
- * matches anywhere in the string, which is fine: the
190
- * `.conflict-<ISO>-<hash>.` token is unambiguous.
191
- */
192
- /**
193
- * Rescue-overlay drift marker pattern. Written by `replace-rescue.sh`
194
- * (`rescue_one` / `conflict_one`) when its rescue target already exists:
195
- * `<orig>.drift-<unix-ts>-<pid>` — a collision suffix so the previous override
196
- * is never silently overwritten. Distinct from `EPHEMERAL_PATH_PATTERN` —
197
- * different producer (the rescue script vs the sync conflict-mirror path),
198
- * different filename grammar (decimal timestamp + decimal pid vs ISO timestamp
199
- * + hex machine hash). Like conflict mirrors, drift markers should never live
200
- * in the vault; if a buggy past run uploaded one, the delete plan must be
201
- * able to drain it regardless of where it sits.
202
- */
203
- export const DRIFT_PATH_PATTERN = /\.drift-\d+-\d+$/;
204
-
205
- /**
206
- * True iff the key is local-only ephemeral vault litter — a sync conflict
207
- * mirror (`.conflict-<ISO>-<machine>[.ext]`) OR a rescue drift marker
208
- * (`.drift-<unixts>-<pid>`).
209
- *
210
- * Used by `computeDeletePlan` to UNCONDITIONALLY drain existing vault litter,
211
- * bypassing every "skip" gate that would otherwise trap it:
212
- *
213
- * 1. `shouldSync` — the personal-vault default exclusions (introduced in
214
- * 5.25) reject paths like `**.obsidian/**`, `**.env`, `**output/**`,
215
- * `**node_modules/**`, etc. from BOTH the upload walk AND the delete
216
- * plan. That's correct for fresh content (don't upload), but it also
217
- * strands any litter already in the vault at those paths — `<orig>.drift`
218
- * / `<orig>.conflict` files inside an excluded parent get re-pulled
219
- * every sync and never tombstoned. The live obsidian case here:
220
- * `personal/.obsidian/graph.json.drift-1779863862-42519` survived every
221
- * sync for two weeks because `.obsidian` is excluded.
222
- * 2. `isEphemeralPath` — the existing skip in `computeDeletePlan` was
223
- * designed to prevent a FRESH local `.conflict-*` mirror (written by the
224
- * pull leg's "keep" branch as a side-by-side comparison file) from being
225
- * miscounted as a delete candidate before the user has resolved the
226
- * conflict. That intent stands, but it accidentally protects EXISTING
227
- * cloud litter too. The fix: when the local file is already gone AND the
228
- * key matches the litter pattern, drain it; the "fresh mirror, hasn't
229
- * been resolved yet" case is impossible because that mirror would still
230
- * be on disk.
231
- * 3. The policy gate — `owned-only`'s direction filter and
232
- * `currency-gated`'s etag check both encode user-content invariants
233
- * that don't apply to litter (a `.drift-…-PID` file has no meaningful
234
- * "ownership" or "freshness"; it's a stale local-overlay collision
235
- * marker, by construction).
236
- * 4. The bulk-asymmetry circuit breaker — litter cleanup is intentional
237
- * ratchet-drain, not a "corrupt local mirror, refuse mass-delete"
238
- * signal. Litter is queued via a separate bucket the breaker never
239
- * sweeps.
240
- *
241
- * Together: a vault key matching either pattern always tombstones on the
242
- * next push leg, regardless of personal-vault exclusions, ephemeral skip,
243
- * policy, or breaker. Producer-side exclusions (upload walker + rescue
244
- * script) close the ratchet on new litter; this drains the legacy buildup.
245
- */
246
- export function isVaultLitterArtifact(p: string): boolean {
247
- return EPHEMERAL_PATH_PATTERN.test(p) || DRIFT_PATH_PATTERN.test(p);
248
- }
249
-
250
- export function isEphemeralPath(p: string): boolean {
251
- return EPHEMERAL_PATH_PATTERN.test(p);
252
- }
253
-
254
- /**
255
- * A vault key containing a backslash is never legitimate. HQ keys are POSIX
256
- * (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile`
257
- * hard-normalizes at the S3 boundary), so a `\` in a remote key can only come
258
- * from a pre-5.47.2 Windows client whose walker built keys with `path.sep` —
259
- * verified live 2026-06-10: one such client duplicated 5,711 keys
260
- * (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date
261
- * puller then materialized them as junk single-filename-with-backslash files
262
- * that churned conflicts forever. The pull walker refuses these keys
263
- * (skip-excluded-policy), symmetric with the ephemeral-mirror filter above.
264
- */
265
- export function isMalformedVaultKey(key: string): boolean {
266
- return key.includes("\\");
267
- }
268
-
269
- /**
270
- * A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
271
- * PERSONAL vault, where it is handled by the dedicated `personalMode` branch
272
- * in `computePullPlan` (companies/* content a peer machine pushed into the
273
- * personal bucket). A COMPANY-scoped vault is already anchored at its company
274
- * root, so its keys are bucket-relative — a `companies/...` key there is a
275
- * doubly-scoped corrupt object. The vault-service refuses to presign such a
276
- * key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
277
- * never materialize it and the whole company sync wedges at `errored` (runner
278
- * exit 2) on every run. Verified live 2026-06-16: frogbear's
279
- * `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
280
- * was uploaded with a doubled key and broke every sync thereafter. The pull
281
- * and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
282
- * with the malformed-(backslash)-key filter above; the bogus objects
283
- * themselves are cleaned server-side.
284
- */
285
- export function isForbiddenCompanyVaultKey(key: string, personalMode: boolean): boolean {
286
- return !personalMode && key.startsWith("companies/");
287
- }
288
-
289
- /**
290
- * Test-only export. Kept under a `_testing` namespace so the module's public
291
- * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
292
- * regression-critical regex contracts (the conflict-mirror pattern) can be
293
- * pinned by direct unit tests without round-tripping through share().
294
- *
295
- * Do NOT import from `_testing` outside of tests in this package.
296
- */
297
- export const _testing = {
298
- isEphemeralPath,
299
- EPHEMERAL_PATH_PATTERN,
300
- wrapFilterWithIgnoreVisibility,
301
- collectFiles,
302
- resolveNamedPath,
303
- isWithinLexicalOrReal,
304
- defaultConsoleLogger,
305
- };
306
-
307
- /**
308
- * Stage-1 classification for a single local file in a push run. Pre-HEAD —
309
- * only inputs we can evaluate locally (size limit, journal hash, optional
310
- * skip-unchanged) determine the action. Files that pass classification as
311
- * `upload` are still subject to a per-file HEAD + 3-way conflict check in
312
- * Stage 2 before the actual PUT, so the `filesToUpload` count in the plan
313
- * event is an upper bound: it includes files that may turn out to be
314
- * conflicts. V1.5 follow-up: replace per-file HEAD with a single LIST so
315
- * conflicts can be classified up-front and reported in the plan.
316
- */
317
- type PushPlanItem =
318
- | {
319
- action: "upload";
320
- kind: "file";
321
- absolutePath: string;
322
- relativePath: string;
323
- localHash: string;
324
- size: number;
325
- }
326
- | {
327
- action: "upload";
328
- kind: "symlink";
329
- absolutePath: string;
330
- relativePath: string;
331
- // The link's target string verbatim (whatever readlink returned).
332
- // Hashed into localHash so a target rewrite re-uploads even when
333
- // skipUnchanged is on; size stays 0 because the wire body is empty.
334
- target: string;
335
- localHash: string;
336
- size: 0;
337
- }
338
- | {
339
- action: "skip-size-limit";
340
- absolutePath: string;
341
- relativePath: string;
342
- }
343
- | {
344
- action: "skip-unchanged";
345
- absolutePath: string;
346
- relativePath: string;
347
- // Present only when the lstat fast-path MISSED but the re-hash confirmed
348
- // the bytes are unchanged (a touched-but-identical file, or a pre-5.36
349
- // entry with no mtimeMs). Carries the current (mtimeMs, size) so the
350
- // apply loop can refresh the journal entry — otherwise the fast-path
351
- // keeps missing and re-hashes the file on every future sync. No content
352
- // change: this never uploads or conflicts.
353
- restamp?: { mtimeMs: number; size: number };
354
- };
355
-
356
- interface PushPlan {
357
- items: PushPlanItem[];
358
- filesToUpload: number;
359
- bytesToUpload: number;
360
- filesToSkip: number;
361
- }
362
-
363
- /**
364
- * Pure Stage-1 pass for push: walk the candidate file list, hash each one,
365
- * apply the size-limit and skip-unchanged gates, and return a classified
366
- * plan plus aggregate counts. No S3 calls, no journal writes, no event
367
- * emission.
368
- *
369
- * The conflict count is intentionally absent from the returned `PushPlan` —
370
- * detecting a push conflict requires a remote HEAD that we defer to Stage 2.
371
- * Consumers that want a conflict count get it from the `complete` event.
372
- */
373
- function computePushPlan(
374
- filesToShare: CollectedEntry[],
375
- journal: SyncJournal,
376
- skipUnchanged: boolean,
377
- ): PushPlan {
378
- const items: PushPlanItem[] = [];
379
-
380
- for (const entry of filesToShare) {
381
- const { absolutePath, relativePath } = entry;
382
-
383
- // Symlinks bypass the size-limit gate (the wire body is small,
384
- // bounded by target length) and hash the target through the
385
- // symlink-namespaced hash so a symlink to "real.md" can never
386
- // collide with a regular file containing the bytes "real.md" in
387
- // the skip-unchanged gate. The target is what we're actually
388
- // uploading, so its hash is what should drive change detection —
389
- // a target rewrite must re-fire an upload even when the link
390
- // itself "looks" identical.
391
- if (entry.kind === "symlink") {
392
- const localHash = hashSymlinkTarget(entry.target);
393
-
394
- if (skipUnchanged) {
395
- const existing = journal.files[relativePath];
396
- if (existing && existing.hash === localHash) {
397
- items.push({ action: "skip-unchanged", absolutePath, relativePath });
398
- continue;
399
- }
400
- }
401
-
402
- items.push({
403
- action: "upload",
404
- kind: "symlink",
405
- absolutePath,
406
- relativePath,
407
- target: entry.target,
408
- localHash,
409
- size: 0,
410
- });
411
- continue;
412
- }
413
-
414
- if (!isWithinSizeLimit(absolutePath)) {
415
- items.push({ action: "skip-size-limit", absolutePath, relativePath });
416
- continue;
417
- }
418
-
419
- // Fast-path (5.36.0): before SHA256-ing the file, lstat it and compare
420
- // (size, mtimeMs) against the journal entry. When both match, treat the
421
- // file as unchanged without reading its bytes. This is the same
422
- // industry-standard "is it changed?" cheap-check rsync and gitignore
423
- // use. Trade-off: a same-length edit that doesn't bump mtime will be
424
- // missed — vanishingly rare in practice. The big win is on no-op syncs
425
- // (most syncs): a 5000-file tree of mostly-unchanged content used to
426
- // SHA256 every file on every walk. Now it lstats once and short-
427
- // circuits in O(file count) instead of O(file bytes).
428
- //
429
- // Only applies when `skipUnchanged` is on AND the journal carries an
430
- // `mtimeMs` for this path AND the path is a regular file (symlinks
431
- // already hash via the cheap hashSymlinkTarget path above). Entries
432
- // without `mtimeMs` (pre-5.36 journal) fall through to the hash path;
433
- // the next upload stamps the field so subsequent syncs use the fast-
434
- // path. Back-compat is automatic.
435
- if (skipUnchanged) {
436
- const existing = journal.files[relativePath];
437
- if (existing && existing.mtimeMs !== undefined && existing.hash) {
438
- try {
439
- const lstat = fs.lstatSync(absolutePath);
440
- if (
441
- lstat.isFile() &&
442
- lstat.size === existing.size &&
443
- lstat.mtimeMs === existing.mtimeMs
444
- ) {
445
- items.push({ action: "skip-unchanged", absolutePath, relativePath });
446
- continue;
447
- }
448
- } catch {
449
- // Fall through to hashFile, which will surface the I/O error in
450
- // its own readFileSync.
451
- }
452
- }
453
- }
454
-
455
- const localHash = hashFile(absolutePath);
456
-
457
- if (skipUnchanged) {
458
- const existing = journal.files[relativePath];
459
- if (existing && existing.hash === localHash) {
460
- // Reaching here means the fast-path did NOT short-circuit (mtime or
461
- // size moved, or the entry predates mtimeMs) yet the re-hash proved the
462
- // bytes are unchanged — a no-op skip. Capture the current (mtimeMs,
463
- // size) so the apply loop refreshes the journal; without it the
464
- // fast-path keeps missing and re-hashes this file on every sync. Only
465
- // attach when the stored stat actually differs (avoid a needless
466
- // journal write on an already-current entry). Stat failure → no
467
- // restamp; the skip still stands.
468
- let restamp: { mtimeMs: number; size: number } | undefined;
469
- try {
470
- const lstat = fs.lstatSync(absolutePath);
471
- if (
472
- lstat.isFile() &&
473
- (existing.mtimeMs !== lstat.mtimeMs || existing.size !== lstat.size)
474
- ) {
475
- restamp = { mtimeMs: lstat.mtimeMs, size: lstat.size };
476
- }
477
- } catch {
478
- /* best-effort; a stat error just forgoes the fast-path refresh */
479
- }
480
- items.push({ action: "skip-unchanged", absolutePath, relativePath, restamp });
481
- continue;
482
- }
483
- }
484
-
485
- const size = fs.statSync(absolutePath).size;
486
- items.push({
487
- action: "upload",
488
- kind: "file",
489
- absolutePath,
490
- relativePath,
491
- localHash,
492
- size,
493
- });
494
- }
495
-
496
- let filesToUpload = 0;
497
- let bytesToUpload = 0;
498
- let filesToSkip = 0;
499
- for (const item of items) {
500
- if (item.action === "upload") {
501
- filesToUpload++;
502
- bytesToUpload += item.size;
503
- } else {
504
- filesToSkip++;
505
- }
506
- }
507
-
508
- return { items, filesToUpload, bytesToUpload, filesToSkip };
509
- }
510
-
511
- export interface ShareOptions {
512
- /** Path(s) to share (files or directories) */
513
- paths: string[];
514
- /** Company slug or UID (defaults to active company from config) */
515
- company?: string;
516
- /** Optional message attached to journal entries */
517
- message?: string;
518
- /** Non-interactive conflict strategy */
519
- onConflict?: ConflictStrategy;
520
- /**
521
- * Vault service config — used when share() must resolve the entity and vend
522
- * STS credentials itself (the default CLI path).
523
- *
524
- * Mutually exclusive with `entityContext`. Exactly one of the two must be
525
- * provided; supplying both throws.
526
- */
527
- vaultConfig?: VaultServiceConfig;
528
- /**
529
- * Pre-resolved entity context. When provided, share() skips its own
530
- * `resolveEntityContext` call (no entity lookup, no STS vending) and uses
531
- * these credentials directly.
532
- *
533
- * Use case: AppBar HQ Sync vends task-scoped creds via `/sts/vend-child`
534
- * (preserving audit traceability via `task_id` + `task_description`)
535
- * before invoking `hq sync push` as a subprocess. The subprocess reads the
536
- * EntityContext JSON from stdin and passes it here.
537
- *
538
- * IMPORTANT: When using `entityContext`, the caller is responsible for
539
- * vending credentials with enough TTL to cover the entire upload run.
540
- * share() cannot auto-refresh a pre-vended context (it has no Cognito
541
- * token to re-vend with) — if the credentials are expiring mid-run,
542
- * share() throws a clear error rather than silently failing on the
543
- * next S3 call.
544
- *
545
- * Mutually exclusive with `vaultConfig`.
546
- */
547
- entityContext?: EntityContext;
548
- /** HQ root directory */
549
- hqRoot: string;
550
- /**
551
- * Per-file event callback. When present, suppresses the default
552
- * `console.log`/`console.error` human output — same contract as `sync()`.
553
- * This is the seam `hq-sync-runner` uses to stream ndjson for push events.
554
- */
555
- onEvent?: (event: SyncProgressEvent) => void;
556
- /**
557
- * When true, files whose local hash matches the journal entry from the
558
- * last sync are skipped (no remote HEAD, no upload). This is the gate
559
- * that makes "push everything that changed" efficient — without it, a
560
- * bidirectional Sync Now would re-upload every file each tick.
561
- *
562
- * Default false to preserve `hq share <file>` semantics: when a user
563
- * explicitly names a file, they expect it to be sent even if the local
564
- * hash matches the last-sync state (e.g. to re-heal a bucket).
565
- */
566
- skipUnchanged?: boolean;
567
- /**
568
- * When true, journal entries whose local file is gone trigger a remote
569
- * `DeleteObject`. Only entries whose key falls under one of the supplied
570
- * `paths` (after resolution to absolute paths under `companies/{slug}/`)
571
- * are considered, so `hq share <file>` can never sweep deletes outside
572
- * the named scope.
573
- *
574
- * Vault buckets have versioning enabled, so the delete is soft: a
575
- * delete-marker becomes the current version and prior object versions
576
- * remain recoverable indefinitely. The pull-side `listRemoteFiles` skips
577
- * objects whose current version is a delete-marker (default
578
- * `ListObjectsV2` behavior), so a deletion stops the next pull from
579
- * re-downloading the file on this and any other machine.
580
- *
581
- * Default false to preserve `hq share <file>` semantics — only the
582
- * full-tree bidirectional runner opts in.
583
- */
584
- propagateDeletes?: boolean;
585
- /**
586
- * Policy for which journal entries `propagateDeletes` is willing to
587
- * convert into remote `DeleteObject` calls. Only consulted when
588
- * `propagateDeletes === true`.
589
- *
590
- * - `"currency-gated"` (safest; default scheduled for 5.25 after soak):
591
- * for each candidate, issue a remote HEAD and compare the current
592
- * remote ETag against the journal's
593
- * last-recorded `remoteEtag`. Match → safe-to-delete (this machine is
594
- * current for the file, so the local deletion reflects an intentional
595
- * removal AFTER seeing the latest remote version). Mismatch → refuse
596
- * and emit `delete-refused-stale-etag`; the journal entry is left
597
- * intact so the next pull leg re-pulls via the same hasRemoteChanged
598
- * path. 404 → tombstone: drop the journal entry, no DeleteObject (the
599
- * remote was already gone). Strictly safer than `owned-only` because
600
- * it gates on per-file proof of currency rather than direction-of-
601
- * origin — files that arrived via `/update-hq` (direction:"down") can
602
- * legitimately be deleted by the device that pulled them, as long as
603
- * no other device has touched them since.
604
- * - `"owned-only"` (current default in 5.24): only entries whose journal
605
- * `direction === "up"` are eligible. That is, only files this machine
606
- * previously uploaded can be remotely deleted on its behalf. Entries
607
- * recorded as pulled from elsewhere are never delete-propagated.
608
- * Default in 5.24 while currency-gated soaks; scheduled to lose the
609
- * default in 5.25. Downside: any file that arrived via `/update-hq`
610
- * or another device's push is stuck on remote forever once locally
611
- * removed, because no device "owns" it under this rule.
612
- * - `"all"`: legacy behaviour — every in-scope journal entry whose
613
- * local file is missing is eligible (regardless of direction or
614
- * currency). The bidirectional runner's first-push and any tool that
615
- * wants to mirror a destructive local checkout opts in here
616
- * explicitly. Use with care — a stale device can erase peer uploads.
617
- *
618
- * Independently of this policy, an entry is also dropped from the plan
619
- * when (a) it matches `EPHEMERAL_PATH_PATTERN` (conflict mirrors never
620
- * propagate), or (b) neither the file-shape nor the directory-shape probe
621
- * of `shouldSync` accepts the path — i.e. the current ignore filter would
622
- * have skipped the path on pull. That symmetry blocks the failure mode
623
- * where a path was filtered locally but lived in the vault (and the
624
- * journal) from an older HQ layout or a different machine, causing the
625
- * next push to erase it.
626
- */
627
- propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
628
- /**
629
- * Explicit vault-relative delete roots captured by a live watcher before a
630
- * scoped push. These roots do not need to exist locally; delete execution
631
- * still requires a current version-bound intent (or server tombstone).
632
- */
633
- deleteScopeRoots?: string[];
634
- /**
635
- * Hq-root-relative key prefixes whose journal entries should be
636
- * unconditionally decommissioned from the remote bucket and journal,
637
- * independent of whether the local file is present. Each prefix matches
638
- * its exact-key form (e.g. "companies/foo") AND any descendant key
639
- * (e.g. "companies/foo/knowledge/notes.md") — same prefix semantics as
640
- * `propagateDeletes`'s scope roots.
641
- *
642
- * Use case: a company that previously synced to the operator's personal
643
- * bucket has been promoted to its own team bucket (`/designate-team`).
644
- * Its keys at `companies/{slug}/...` in the personal bucket are now
645
- * orphans — the on-disk files still exist (the team bucket is the new
646
- * canonical home), so the standard `propagateDeletes` gate ("local file
647
- * missing") never fires. This option asserts "these objects no longer
648
- * belong in THIS bucket regardless of local state" and uses the same
649
- * DeleteObject + journal-removal path as `propagateDeletes`.
650
- *
651
- * Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's
652
- * own fallback when the caller passes nothing; every live caller passes
653
- * `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only
654
- * journal entries with `direction === "up"` are decommissioned, so a
655
- * misconfigured caller never erases content pulled from elsewhere.
656
- *
657
- * Independent of `propagateDeletes`: callers can opt into decommission
658
- * without enabling general delete propagation. In practice the runner
659
- * sets both for the personal slot.
660
- */
661
- decommissionPrefixes?: string[];
662
- /**
663
- * Identity stamped onto each uploaded object's S3 user metadata
664
- * (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
665
- * reads `Metadata['created-by']` for its "CREATED BY" column; uploads
666
- * without an author leave that column blank for every file synced via
667
- * this engine. The runner pipes Cognito idToken claims through here.
668
- */
669
- author?: UploadAuthor;
670
- /**
671
- * When true, share() targets the caller's person-entity bucket: syncRoot
672
- * is `hqRoot` itself (NOT `hqRoot/companies/<slug>/`), so remote keys are
673
- * hq-root-relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md") to
674
- * match the Rust hq-sync first-push contract in
675
- * `src-tauri/src/commands/personal.rs`. The exclusion of top-level dirs
676
- * (.git, companies, core, data, personal, repos, workspace) is enforced
677
- * by the runner — share() trusts its `paths` input.
678
- */
679
- personalMode?: boolean;
680
- /**
681
- * Override for the per-slug journal file name. Defaults to `ctx.slug`. The
682
- * runner passes `journalSlug: "personal"` for the personal slot so the TS
683
- * push and the Rust personal first-push share idempotency state under one
684
- * `sync-journal.personal.json` file.
685
- */
686
- journalSlug?: string;
687
- /**
688
- * Effective ACL push scope as a list of company-relative prefixes (the same
689
- * coalesced `prefixSet` the pull leg uses for `syncMode: "shared"`). When
690
- * provided, the upload + delete plans are filtered to paths covered by these
691
- * prefixes — any candidate outside them is skipped (and surfaced via a
692
- * `scope-excluded` event) rather than PUT, because the vended child
693
- * credential is scoped to exactly these prefixes and an out-of-scope PUT
694
- * draws the server's correct 403 `SCOPE_EXCEEDS_PARENT`.
695
- *
696
- * `undefined` (the owner/`all` case, and `hq share <file>`) applies NO scope
697
- * filter — full access. An empty array means "no granted prefixes" → every
698
- * path is out of scope (mirrors the pull side's `isCoveredByAny([])`).
699
- */
700
- prefixSet?: ScopePrefixInput[];
701
- /**
702
- * Pre-fetched FILE_TOMBSTONE map (POSIX key → tombstone) for the push-side
703
- * delete-resync consult. When omitted, share() fetches it itself via
704
- * `fetchCompanyTombstones` for COMPANY vaults that have a `vaultConfig`
705
- * (personal vaults and pre-vended `entityContext`-only callers without a
706
- * `vaultConfig` degrade to no-suppression — the safe, legacy direction).
707
- *
708
- * Injection is an optimization + test seam: a sync run that already fetched
709
- * tombstones for the pull leg can hand the same map to the push leg to avoid a
710
- * second round-trip, and tests can supply a controlled map without stubbing
711
- * the network. See the consult in the Stage-2 classification pass.
712
- */
713
- fileTombstones?: Map<string, CompanyTombstone>;
714
- /**
715
- * What to do when a path the caller EXPLICITLY named cannot be shipped —
716
- * it does not resolve under any base, or it resolves outside the company
717
- * folder (see `ShareResult.unreachablePaths`).
718
- *
719
- * - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the
720
- * company folder throws {@link UnreachablePushPathsError} BEFORE any upload
721
- * runs, so the push is an atomic no-op and the CLI exits nonzero. This is
722
- * ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file
723
- * exists locally but is unreachable by the resolver". A user who typed a
724
- * path and got "✓ Pushed 0 file(s)" had no way to know their content never
725
- * left the machine. A path that resolves to NOTHING under any base is still
726
- * only warn-recorded (see `collectFatalUnreachablePaths` for why bulk
727
- * membership fanout depends on that).
728
- * - `"warn"`: record it on the result + emit the `not-shipped` event and
729
- * carry on, never throwing. For callers whose `paths` are INTERNAL walk
730
- * roots rather than user input — the background sync runner — where a path
731
- * disappearing mid-run is a benign race (a directory removed between the
732
- * scan and the push) and must never fail an unattended sync.
733
- */
734
- unreachablePathPolicy?: "error" | "warn";
735
- }
736
-
737
- /** Why an explicitly-named push path could not be shipped. */
738
- export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link";
739
-
740
- /**
741
- * Thrown by `share()` when a caller-named path cannot be pushed and
742
- * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
743
- * are still being built, so NOTHING has been uploaded, journaled, or deleted
744
- * when it surfaces — the failed push leaves no partial state behind.
745
- */
746
- export class UnreachablePushPathsError extends Error {
747
- /** Caller's original spellings, verbatim (see the CollectHooks contract). */
748
- readonly paths: string[];
749
- /** Per-path reason, keyed by the same original spelling. */
750
- readonly reasons: Record<string, UnreachablePathReason>;
751
-
752
- constructor(unreachable: ReadonlyMap<string, UnreachablePathReason>, syncRoot: string) {
753
- const paths = [...unreachable.keys()];
754
- const lines = paths.map((p) => {
755
- const reason = unreachable.get(p);
756
- if (reason === "outside-company") {
757
- return ` · ${p} — resolves outside the company folder (${syncRoot})`;
758
- }
759
- if (reason === "unreadable-link") {
760
- return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
761
- }
762
- return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
763
- });
764
- super(
765
- `${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
766
- `nothing was uploaded.\n${lines.join("\n")}\n` +
767
- `A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
768
- `(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
769
- `outside HQ has to sync through whatever owns it, not the vault.`,
770
- );
771
- this.name = "UnreachablePushPathsError";
772
- this.paths = paths;
773
- this.reasons = Object.fromEntries(unreachable);
774
- }
775
- }
776
-
777
- export interface ShareResult {
778
- filesUploaded: number;
779
- bytesUploaded: number;
780
- filesSkipped: number;
781
- /**
782
- * Number of remote `DeleteObject` calls that succeeded this run. Always 0
783
- * when `propagateDeletes` is false. The corresponding journal entries are
784
- * removed in the same pass so the next sync sees the key as truly gone.
785
- * Does NOT include tombstones (remote was already 404; no DELETE was
786
- * issued — see `filesTombstoned`) or refused-stale entries (currency-
787
- * gated refused because remote etag drifted — see `filesRefusedStale`).
788
- */
789
- filesDeleted: number;
790
- /**
791
- * Number of journal entries dropped because the remote was already 404 at
792
- * HEAD time (cleaned out-of-band — e.g. someone hand-deleted via the S3
793
- * console, or another tool ran a destructive operation). No `DeleteObject`
794
- * was issued for these; the journal converges with reality. Always 0 when
795
- * `propagateDeletes` is false or `propagateDeletePolicy !== "currency-gated"`.
796
- */
797
- filesTombstoned: number;
798
- /**
799
- * Number of delete candidates refused by the `currency-gated` policy
800
- * because the remote object's current ETag no longer matches the journal's
801
- * recorded one (some other device modified the file since this device last
802
- * synced it) — OR because the journal entry is a legacy record with no
803
- * `remoteEtag` to compare against. Neither S3 nor the journal is mutated
804
- * for these; the next pull leg re-pulls naturally via `hasRemoteChanged`.
805
- * Always 0 when `propagateDeletes` is false or policy is not
806
- * `currency-gated`.
807
- */
808
- filesRefusedStale: number;
809
- /**
810
- * Paths corresponding to `filesRefusedStale`, capped at 50 to keep the
811
- * event payload bounded (mirrors `newFiles` capping). Surfaces *which*
812
- * paths were refused so operators can triage the recurring
813
- * \`filesRefusedStale: 205\` signal flagged in the 5.33.0 deep-test —
814
- * the count alone is impossible to investigate because the per-file
815
- * \`delete-refused-stale-etag\` events vanish from the event stream
816
- * once the runner has folded them into the totals.
817
- */
818
- filesRefusedStalePaths: string[];
819
- /**
820
- * Number of uploads suppressed by the push-side FILE_TOMBSTONE consult — keys
821
- * an authoritative delete (`hq files delete`) tombstoned that this machine
822
- * still held as the unchanged synced baseline. Skipping the upload is what
823
- * stops a behind peer from resurrecting an authoritatively-deleted key. Always
824
- * 0 when there are no tombstones for in-scope keys (the common case) or when
825
- * the run can't load tombstones (personal vault / no `vaultConfig`).
826
- */
827
- filesSuppressedByTombstone: number;
828
- /**
829
- * Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
830
- * run (push leg, personalMode=true). Includes both files that would have
831
- * uploaded and journal entries that would have been included in the delete
832
- * plan; deduplicated across walks. Always 0 outside personalMode. Mirrors
833
- * the `count` field of the `personal-vault-out-of-policy` event (which is
834
- * emitted exactly once if this is > 0).
835
- */
836
- filesExcludedByPolicy: number;
837
- /**
838
- * Number of distinct company-relative paths/prefixes skipped because they
839
- * fell OUTSIDE the run's ACL `prefixSet` (member/guest scoped push). Always
840
- * 0 when `prefixSet` is undefined (owner/`all`) or the whole tree is in
841
- * scope. Mirrors the `count` field of the `scope-excluded` event (emitted
842
- * once if this is > 0). These paths were never PUT, so the server's correct
843
- * 403 `SCOPE_EXCEEDS_PARENT` is never triggered and the company still syncs
844
- * its in-scope subset.
845
- */
846
- filesExcludedByScope: number;
847
- /**
848
- * Number of distinct hq-root-relative paths skipped by the base ignore
849
- * filter that look like real content rather than expected build/VCS/cache
850
- * noise. Mirrors the `count` field of the `ignore-excluded` event (emitted
851
- * once if this is > 0).
852
- */
853
- filesExcludedByIgnore: number;
854
- /**
855
- * Paths the caller EXPLICITLY named for push that exist locally but the
856
- * resolver could not place under the company folder (or could not find under
857
- * any base). Empty in the common case (internal walk roots are always the
858
- * reachable company folder). A non-empty list means the push did NOT ship
859
- * something the caller asked for.
860
- *
861
- * Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT
862
- * `"error"` policy throws {@link UnreachablePushPathsError} instead of
863
- * returning, so an interactive `hq sync push` fails loudly rather than
864
- * reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the
865
- * warn-mode surface for unattended callers (sync runner / watcher).
866
- *
867
- * Entries are the caller's ORIGINAL spellings, verbatim — a relative token
868
- * stays relative, an absolute token stays absolute — so the list is directly
869
- * comparable to the `paths` input. See the CollectHooks spelling contract.
870
- * Mirrors the `not-shipped` event with `reason: "unreachable-path"`.
871
- */
872
- unreachablePaths: string[];
873
- /**
874
- * Company-relative keys of directory symlinks that were recorded as links but
875
- * NOT descended because their target lives outside the company folder — their
876
- * contents sync via their own repo, not the vault. Surfaced so files created
877
- * under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no
878
- * longer vanish from every push bucket without a trace. Mirrors the
879
- * `not-shipped` event with `reason: "linked-subtree"`.
880
- */
881
- linkedSubtreesNotShipped: string[];
882
- /**
883
- * Paths (company-relative) that were detected as push conflicts. Mirrors
884
- * `SyncResult.conflictPaths` so push and pull surface conflicts the same
885
- * way to runner/UI consumers.
886
- */
887
- conflictPaths: string[];
888
- /** Uncapped per-path delete outcomes used by scoped watcher publication. */
889
- pathResults?: SharePathResult[];
890
- aborted: boolean;
891
- }
892
-
893
- export type ShareDeleteRefusalReason =
894
- | "stale-etag"
895
- | "legacy-no-etag"
896
- | "bulk-asymmetry"
897
- | "divergent-local"
898
- | "missing-delete-intent"
899
- | "intent-changed"
900
- | "recreated-locally"
901
- | "transfer-error";
902
-
903
- export interface SharePathResult {
904
- path: string;
905
- status: "accepted" | "refused";
906
- operation: "delete" | "tombstone";
907
- reason?: ShareDeleteRefusalReason;
908
- }
909
-
910
- /**
911
- * A conditional-write fence rejection — the SDK's 412 (`name:
912
- * "PreconditionFailed"`) or the presigned transport's mirror of it. Means
913
- * the remote moved past the etag this pass inspected (If-Match) or an
914
- * object appeared at a key we believed absent (If-None-Match). Always a
915
- * conflict, never a transport failure.
916
- */
917
- function isPreconditionFailed(err: unknown): boolean {
918
- if (err && typeof err === "object" && "name" in err) {
919
- return (err as { name?: unknown }).name === "PreconditionFailed";
920
- }
921
- return false;
922
- }
923
-
924
- /**
925
- * Wrap an existing path filter (ignore filter, optionally already wrapped with
926
- * the personal-vault defaults) so that paths OUTSIDE the run's ACL `prefixSet`
927
- * are rejected before they enter the upload OR delete plan. Keeps the
928
- * `(absolutePath, isDir) => boolean` shape the rest of share() already uses
929
- * (collectFiles / walkDir / computeDeletePlan), so wiring is one line at the
930
- * filter-construction site — exactly like `wrapFilterWithPersonalVaultDefaults`.
931
- *
932
- * Files use `isCoveredByAny` (plain `startsWith`); directories use
933
- * `isDirInScope` (descend if the dir is inside a grant OR a grant is inside
934
- * the dir) so the walk still reaches deep/exact-file grants. Rejected entries
935
- * are tagged via `onScopeExcluded` (directories with a trailing slash) so the
936
- * runner can emit a single `scope-excluded` summary naming what was skipped.
937
- */
938
- function wrapFilterWithScope(
939
- underlying: (absPath: string, isDir?: boolean) => boolean,
940
- syncRoot: string,
941
- prefixSet: readonly ScopePrefixInput[],
942
- onScopeExcluded: (rel: string) => void,
943
- ): (absPath: string, isDir?: boolean) => boolean {
944
- return (absPath: string, isDir?: boolean) => {
945
- if (!underlying(absPath, isDir)) return false;
946
- const rel = vaultKeyForLocalPath(syncRoot, absPath);
947
- if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
948
- if (isDir) {
949
- if (isDirInScope(rel, prefixSet)) return true;
950
- onScopeExcluded(rel.endsWith("/") ? rel : rel + "/");
951
- return false;
952
- }
953
- if (isCoveredByAny(rel, prefixSet)) return true;
954
- onScopeExcluded(rel);
955
- return false;
956
- };
957
- }
958
-
959
- /**
960
- * Wrap the base ignore filter so push can observe paths it rejects before
961
- * personal-vault policy or ACL scope wrappers add their own exclusions.
962
- * Every base-ignore rejection increments `onAnyExcluded`; only rejections
963
- * that do NOT match expected build/VCS/cache noise are tagged through
964
- * `onIgnoreExcluded` for the one-shot `ignore-excluded` summary.
965
- */
966
- export function wrapFilterWithIgnoreVisibility(
967
- underlying: (absPath: string, isDir?: boolean) => boolean,
968
- hqRoot: string,
969
- onIgnoreExcluded: (relPath: string) => void,
970
- onAnyExcluded?: () => void,
971
- ): (absPath: string, isDir?: boolean) => boolean {
972
- return (absPath: string, isDir?: boolean) => {
973
- const allowed = underlying(absPath, isDir);
974
- if (allowed) return true;
975
-
976
- onAnyExcluded?.();
977
- const rel = vaultKeyForLocalPath(hqRoot, absPath);
978
- if (rel === "" || rel.startsWith("..")) return false;
979
- if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
980
- return false;
981
- };
982
- }
983
-
984
- /**
985
- * Share local file(s) to the entity vault.
986
- */
987
- export async function share(options: ShareOptions): Promise<ShareResult> {
988
- const run = await createPushRunContext(options);
989
- const counters = createShareCounters();
990
- const filesRefusedStalePaths: string[] = [];
991
- const conflictPaths: string[] = [];
992
- const pathResults: SharePathResult[] = [];
993
-
994
- const plans = await buildSharePlans(run);
995
- const uploadResult = await executeUploads(
996
- run,
997
- plans.pushPlan,
998
- counters,
999
- conflictPaths,
1000
- );
1001
- if (uploadResult.aborted) {
1002
- return buildShareResult(
1003
- run,
1004
- counters,
1005
- filesRefusedStalePaths,
1006
- uploadResult.abortFlightConflictPaths,
1007
- pathResults,
1008
- true,
1009
- );
1010
- }
1011
-
1012
- await executeDeletes(
1013
- run,
1014
- plans.deletePlan,
1015
- plans.decommissionPlan,
1016
- counters,
1017
- filesRefusedStalePaths,
1018
- pathResults,
1019
- );
1020
- finalizeShareJournal(run);
1021
- throwUploadWorkerErrors(uploadResult.workerErrors);
1022
-
1023
- return buildShareResult(
1024
- run,
1025
- counters,
1026
- filesRefusedStalePaths,
1027
- conflictPaths,
1028
- pathResults,
1029
- false,
1030
- );
1031
- }
1032
-
1033
- type DeletePolicy = NonNullable<ShareOptions["propagateDeletePolicy"]>;
1034
- type ShareEmit = (event: SyncProgressEvent) => void;
1035
- type UploadPlanItem = Extract<PushPlanItem, { action: "upload" }>;
1036
- type JournalFileEntry = SyncJournal["files"][string];
1037
-
1038
- interface PushRunContext {
1039
- options: ShareOptions;
1040
- paths: string[];
1041
- message?: string;
1042
- onConflict?: ConflictStrategy;
1043
- vaultConfig?: VaultServiceConfig;
1044
- entityContext?: EntityContext;
1045
- hqRoot: string;
1046
- skipUnchanged?: boolean;
1047
- propagateDeletes?: boolean;
1048
- propagateDeletePolicy: DeletePolicy;
1049
- emit: ShareEmit;
1050
- companyRef: string;
1051
- ctx: EntityContext;
1052
- syncRoot: string;
1053
- shouldSync: (filePath: string, isDir?: boolean) => boolean;
1054
- journalSlug: string;
1055
- journal: SyncJournal;
1056
- excludedSet: Set<string>;
1057
- excludedById: Record<string, number>;
1058
- scopeExcludedSet: Set<string>;
1059
- ignoreExcludedSet: Set<string>;
1060
- ignoreExcludedTotal: { value: number };
1061
- /** Explicitly-named push paths that exist locally but the resolver could not
1062
- * place under the company folder (or find at all), keyed by the CALLER'S
1063
- * ORIGINAL spelling (see the CollectHooks spelling contract) and valued by
1064
- * why it could not be shipped. Non-empty ⇒ the push did NOT ship something
1065
- * the caller named — surfaced so a "Pushed 0 file(s)" is never a silent
1066
- * false success, and (under the default `unreachablePathPolicy: "error"`)
1067
- * raised as a hard failure before any upload runs. */
1068
- unreachablePaths: Map<string, UnreachablePathReason>;
1069
- /** Company-relative keys of directory symlinks recorded but not descended
1070
- * because their target lives outside the company folder (contents sync via
1071
- * their own repo, not the vault). */
1072
- linkedSubtreeSet: Set<string>;
1073
- }
1074
-
1075
- interface ShareCounters {
1076
- filesUploaded: number;
1077
- bytesUploaded: number;
1078
- filesSkipped: number;
1079
- filesDeleted: number;
1080
- filesTombstoned: number;
1081
- filesRefusedStale: number;
1082
- filesSuppressedByTombstone: number;
1083
- }
1084
-
1085
- interface SharePlans {
1086
- pushPlan: PushPlan;
1087
- deletePlan: DeletePlan;
1088
- decommissionPlan: string[];
1089
- }
1090
-
1091
- interface UploadExecutionResult {
1092
- aborted: boolean;
1093
- abortFlightConflictPaths: string[];
1094
- workerErrors: Error[];
1095
- }
1096
-
1097
- const REFUSED_STALE_PATH_CAP = 50;
1098
-
1099
- async function createPushRunContext(options: ShareOptions): Promise<PushRunContext> {
1100
- const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
1101
- const propagateDeletePolicy: DeletePolicy =
1102
- options.propagateDeletePolicy ?? "owned-only";
1103
- const baseEmit = options.onEvent ?? defaultConsoleLogger;
1104
-
1105
- if (vaultConfig && entityContext) {
1106
- throw new Error(
1107
- "share() requires exactly one of `vaultConfig` or `entityContext`, not both. " +
1108
- "Pass `vaultConfig` to vend credentials internally, or `entityContext` to use pre-vended ones.",
1109
- );
1110
- }
1111
- if (!vaultConfig && !entityContext) {
1112
- throw new Error(
1113
- "share() requires either `vaultConfig` (for internal STS vending) " +
1114
- "or `entityContext` (pre-vended credentials).",
1115
- );
1116
- }
1117
-
1118
- const companyRef =
1119
- company ?? entityContext?.slug ?? resolveActiveCompany(hqRoot);
1120
- if (!companyRef) {
1121
- throw new Error(
1122
- "No company specified and no active company found. " +
1123
- "Use --company <slug> or set up .hq/config.json.",
1124
- );
1125
- }
1126
-
1127
- const ctx: EntityContext = entityContext
1128
- ? entityContext
1129
- : await resolveEntityContext(companyRef, vaultConfig!);
1130
-
1131
- // NOTE (removed 6.14.48): a `prs_`-scoped coercion used to silently rewrite
1132
- // `owned-only` -> `currency-gated` here (366721b). It was correct for the
1133
- // problem it was written against and is now actively harmful, for three
1134
- // reasons that only became true later:
1135
- //
1136
- // 1. Its ONLY reachable effect in production was to override an EXPLICIT
1137
- // `owned-only`. Every live caller passes a policy
1138
- // (`sync-runner-company.ts` -> `resolveDeletePolicy()`), and that
1139
- // resolver already defaults to `currency-gated`. So the coercion never
1140
- // fired on a default run — it fired exactly when an operator had set
1141
- // `HQ_SYNC_DELETE_POLICY=owned-only`, i.e. the documented rollback.
1142
- // 2. Once delete authorization moved to ETag currency (no watcher-minted
1143
- // intent required), that override stopped being cosmetic: an operator
1144
- // reaching for the rollback because deletes were misbehaving would keep
1145
- // getting intent-less etag-only deletes on their personal vault. A
1146
- // rollback knob that silently exempts a vault is not a rollback knob.
1147
- // 3. Its motivating bug — the May-27 `personal/.obsidian/*.drift-*` leak —
1148
- // is fixed twice over by mechanisms that postdate it: the vault-litter
1149
- // drain (6.0.2) drains `.drift-*` markers ahead of every policy gate,
1150
- // and etag-gated deletes propagate an intent-less `direction:"down"`
1151
- // removal under the default policy without needing any coercion.
1152
- //
1153
- // So `owned-only` now means owned-only on every vault kind. It is the strict
1154
- // rollback path and must stay strictly stricter than the default everywhere.
1155
-
1156
- // Mirror push progress into the shared cross-process snapshot (see sync.ts).
1157
- // Record the friendly company slug, or null for the personal vault so the
1158
- // menubar shows "Personal" — never the raw prs_/cmp_ UID.
1159
- const recordProgress = createSyncProgressRecorder({
1160
- company:
1161
- ctx.uid.startsWith("prs_") || options.personalMode === true
1162
- ? null
1163
- : ctx.slug,
1164
- phase: "push",
1165
- });
1166
- const emit: ShareEmit = (event) => {
1167
- baseEmit(event);
1168
- recordProgress(event);
1169
- };
1170
-
1171
- const syncRoot = options.personalMode === true
1172
- ? hqRoot
1173
- : path.join(hqRoot, "companies", ctx.slug);
1174
-
1175
- const ignoreFilter = createIgnoreFilter(hqRoot);
1176
- const ignoreExcludedSet = new Set<string>();
1177
- const ignoreExcludedTotal = { value: 0 };
1178
- const recordedIgnoreFilter = wrapFilterWithIgnoreVisibility(
1179
- ignoreFilter,
1180
- hqRoot,
1181
- (rel) => {
1182
- ignoreExcludedSet.add(rel);
1183
- },
1184
- () => {
1185
- ignoreExcludedTotal.value++;
1186
- },
1187
- );
1188
- const excludedSet = new Set<string>();
1189
- const excludedById: Record<string, number> = {};
1190
- const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
1191
- if (excludedSet.has(rel)) return;
1192
- excludedSet.add(rel);
1193
- excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
1194
- };
1195
- const scopeExcludedSet = new Set<string>();
1196
- const onScopeExcluded = (rel: string) => {
1197
- scopeExcludedSet.add(rel);
1198
- };
1199
- const unreachablePaths = new Map<string, UnreachablePathReason>();
1200
- const linkedSubtreeSet = new Set<string>();
1201
- const baseFilter = options.personalMode === true
1202
- ? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
1203
- : recordedIgnoreFilter;
1204
- const shouldSync = options.prefixSet !== undefined
1205
- ? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
1206
- : baseFilter;
1207
- const journalSlug = options.journalSlug ?? ctx.slug;
1208
- if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
1209
- const journal = readJournal(journalSlug);
1210
-
1211
- return {
1212
- options,
1213
- paths,
1214
- message,
1215
- onConflict,
1216
- vaultConfig,
1217
- entityContext,
1218
- hqRoot,
1219
- skipUnchanged,
1220
- propagateDeletes,
1221
- propagateDeletePolicy,
1222
- emit,
1223
- companyRef,
1224
- ctx,
1225
- syncRoot,
1226
- shouldSync,
1227
- journalSlug,
1228
- journal,
1229
- excludedSet,
1230
- excludedById,
1231
- scopeExcludedSet,
1232
- ignoreExcludedSet,
1233
- ignoreExcludedTotal,
1234
- unreachablePaths,
1235
- linkedSubtreeSet,
1236
- };
1237
- }
1238
-
1239
- function createShareCounters(): ShareCounters {
1240
- return {
1241
- filesUploaded: 0,
1242
- bytesUploaded: 0,
1243
- filesSkipped: 0,
1244
- filesDeleted: 0,
1245
- filesTombstoned: 0,
1246
- filesRefusedStale: 0,
1247
- filesSuppressedByTombstone: 0,
1248
- };
1249
- }
1250
-
1251
- async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1252
- const collected = collectFiles(
1253
- run.paths,
1254
- run.hqRoot,
1255
- run.syncRoot,
1256
- run.shouldSync,
1257
- {
1258
- onUnreachablePath: (namedPath, reason) => {
1259
- // First reason wins: a path is named once, and re-adding would only
1260
- // churn the map ordering the error message and event sample rely on.
1261
- if (!run.unreachablePaths.has(namedPath)) {
1262
- run.unreachablePaths.set(namedPath, reason);
1263
- }
1264
- },
1265
- onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
1266
- },
1267
- );
1268
- // Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
1269
- // exists locally but is unreachable by the resolver". This is the throw that
1270
- // makes it true end-to-end — the CLI's push handler already turns a thrown
1271
- // error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
1272
- // "✓ Pushed 0 file(s)" success can no longer happen for a file that is
1273
- // sitting right there on disk. It fires HERE, before executeUploads, so a
1274
- // failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
1275
- // written, no delete propagated.
1276
- const fatal = collectFatalUnreachablePaths(run);
1277
- if (fatal.size > 0) {
1278
- emitUnreachablePathEvent(run);
1279
- throw new UnreachablePushPathsError(fatal, run.syncRoot);
1280
- }
1281
- // Control-character key filter (macOS Finder Icon\r incident). A key
1282
- // containing a control character can never be stored — validateVaultUploadKey
1283
- // rejects it with INVALID_KEY_CONTROL_CHARS — so planning an upload for one
1284
- // only buys a guaranteed throw inside executeUploads, which lands in the
1285
- // generic catch and emits `type: "error"`. That is the failure this filter
1286
- // exists to stop: a non-empty errors[] makes the runner return
1287
- // PARTIAL_SYNC_EXIT, so ONE such file on disk marks EVERY sync pass failed
1288
- // forever. A macOS tree seeded with Finder `Icon\r` files produced 100+ per
1289
- // run, wedging sync indefinitely.
1290
- //
1291
- // Structurally identical to the size-limit skip in executeUploads: a
1292
- // permanently-invalid path is a benign skip, never an error. Symmetric with
1293
- // the pull leg, which has classified such keys since the 2026-07-11 incident
1294
- // (see computePullPlan's classifyVaultKey call). Applied before the mode
1295
- // branch below so it covers personal and company vaults alike.
1296
- //
1297
- // Finder icon files are normally already gone by here — createIgnoreFilter
1298
- // drops them during the walk. This stays as the general guard for any other
1299
- // control-char path, and as defense for callers that bypass the walker.
1300
- const controlCharFiltered = collected.filter((entry) => {
1301
- if (!hasControlCharacters(entry.relativePath)) return true;
1302
- run.emit({
1303
- type: "skip-invalid-scoped-key",
1304
- path: entry.relativePath,
1305
- errorCode: "INVALID_KEY_CONTROL_CHARS",
1306
- });
1307
- return false;
1308
- });
1309
-
1310
- // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
1311
- // root IS the company folder, so a local entry whose vault key starts with
1312
- // `companies/` can only come from a stale doubled local tree
1313
- // (companies/{slug}/companies/{slug}/…). Uploading it poisons the bucket
1314
- // with keys the server validator rejects (INVALID_KEY_COMPANIES_SCOPED) —
1315
- // the direct-S3 STS transport bypasses server validation. Refuse them here
1316
- // with a per-key warning; uploadFile/uploadSymlink's validateVaultUploadKey
1317
- // is the belt-and-suspenders backstop. The pull leg's journal-tombstone
1318
- // path then CLEANS the doubled tree (local delete + journal drop) instead
1319
- // of this leg ever re-uploading it. Personal vaults legitimately carry
1320
- // `companies/{slug}/…` keys, so this only applies in company mode.
1321
- const filesToShare =
1322
- run.options.personalMode === true
1323
- ? controlCharFiltered.filter((entry) => {
1324
- // `core/<type>` symlinks are generated projections of canonical
1325
- // personal/package content. Reindex or package wiring owns their
1326
- // lifecycle, so uploading them creates remote objects that a later
1327
- // pull can only materialize temporarily before reindex removes them.
1328
- // Keep ordinary core files eligible: only a symlink is derived.
1329
- const generatedCoreMirror =
1330
- entry.kind === "symlink" && isGeneratedCoreMirrorKey(entry.relativePath)
1331
- if (!generatedCoreMirror) return true;
1332
-
1333
- // This is a type-aware companion to the path-only personal-vault
1334
- // default filters. `collectFiles` is the first seam that knows the
1335
- // entry is a symlink, so record it here for the same summary/result
1336
- // accounting used by the static exclusions.
1337
- if (!run.excludedSet.has(entry.relativePath)) {
1338
- run.excludedSet.add(entry.relativePath);
1339
- run.excludedById["generated-core-mirror"] =
1340
- (run.excludedById["generated-core-mirror"] ?? 0) + 1;
1341
- }
1342
- return false;
1343
- })
1344
- : controlCharFiltered.filter((entry) => {
1345
- if (!entry.relativePath.startsWith("companies/")) return true;
1346
- run.emit({
1347
- type: "skip-invalid-scoped-key",
1348
- path: entry.relativePath,
1349
- });
1350
- return false;
1351
- });
1352
- const pushPlan = computePushPlan(
1353
- filesToShare,
1354
- run.journal,
1355
- run.skipUnchanged === true,
1356
- );
1357
- const deleteScopeRoots = run.propagateDeletes === true
1358
- ? resolveDeleteScopeRoots(
1359
- run.paths,
1360
- run.hqRoot,
1361
- run.syncRoot,
1362
- run.options.deleteScopeRoots,
1363
- )
1364
- : [];
1365
- const deletePlan: DeletePlan = run.propagateDeletes === true
1366
- ? await computeDeletePlan(
1367
- run.journal,
1368
- run.syncRoot,
1369
- deleteScopeRoots,
1370
- run.shouldSync,
1371
- run.propagateDeletePolicy,
1372
- run.ctx,
1373
- run.options.personalMode !== true,
1374
- )
1375
- : { toDelete: [], toTombstone: [], refusedStale: [] };
1376
- const decommissionPlan =
1377
- (run.options.decommissionPrefixes ?? []).length > 0
1378
- ? computeDecommissionPlan(
1379
- run.journal,
1380
- run.options.decommissionPrefixes ?? [],
1381
- run.propagateDeletePolicy,
1382
- new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
1383
- )
1384
- : [];
1385
-
1386
- run.emit({
1387
- type: "plan",
1388
- filesToDownload: 0,
1389
- bytesToDownload: 0,
1390
- filesToUpload: pushPlan.filesToUpload,
1391
- bytesToUpload: pushPlan.bytesToUpload,
1392
- filesToSkip: pushPlan.filesToSkip,
1393
- filesToConflict: 0,
1394
- filesToDelete: deletePlan.toDelete.length + decommissionPlan.length,
1395
- });
1396
-
1397
- if (deletePlan.bulkAsymmetry) {
1398
- run.emit({
1399
- type: "delete-refused-bulk-asymmetry",
1400
- candidates: deletePlan.bulkAsymmetry.candidates,
1401
- inScope: deletePlan.bulkAsymmetry.inScope,
1402
- ratio: deletePlan.bulkAsymmetry.ratio,
1403
- samplePaths: deletePlan.bulkAsymmetry.samplePaths,
1404
- });
1405
- }
1406
-
1407
- return { pushPlan, deletePlan, decommissionPlan };
1408
- }
1409
-
1410
- async function executeUploads(
1411
- run: PushRunContext,
1412
- pushPlan: PushPlan,
1413
- counters: ShareCounters,
1414
- conflictPaths: string[],
1415
- ): Promise<UploadExecutionResult> {
1416
- const TRANSFER_CONCURRENCY = resolveTransferConcurrency();
1417
- let conflictPromptChain: Promise<unknown> = Promise.resolve();
1418
- const resolveConflictSerialized = (
1419
- info: Parameters<typeof resolveConflict>[0],
1420
- ): ReturnType<typeof resolveConflict> => {
1421
- const resolution = conflictPromptChain.then(() =>
1422
- resolveConflict(info, run.onConflict),
1423
- );
1424
- conflictPromptChain = resolution.then(
1425
- () => undefined,
1426
- () => undefined,
1427
- );
1428
- return resolution;
1429
- };
1430
-
1431
- const fileTombstones: Map<string, CompanyTombstone> =
1432
- run.options.fileTombstones ??
1433
- (!run.ctx.uid.startsWith("prs_") && run.vaultConfig
1434
- ? await fetchCompanyTombstones(run.vaultConfig, run.ctx.uid)
1435
- : new Map<string, CompanyTombstone>());
1436
-
1437
- const uploadItems: UploadPlanItem[] = [];
1438
- for (const item of pushPlan.items) {
1439
- if (item.action === "skip-size-limit") {
1440
- // A file over the size cap is a permanent, benign skip — NOT an error.
1441
- // Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
1442
- // so every watch pass returned exit 2 and the menubar reported an
1443
- // "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
1444
- // tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
1445
- // do not let it flip the pass to a non-zero exit.
1446
- let bytes = 0;
1447
- try {
1448
- bytes = fs.statSync(item.absolutePath).size;
1449
- } catch {
1450
- // Best-effort size for display only; a stat race must not break the sync.
1451
- }
1452
- run.emit({
1453
- type: "skip-size-limit",
1454
- path: item.relativePath,
1455
- bytes,
1456
- });
1457
- counters.filesSkipped++;
1458
- continue;
1459
- }
1460
- if (item.action === "upload") {
1461
- if (fileTombstones.size > 0) {
1462
- const ts = fileTombstones.get(toPosixKey(item.relativePath));
1463
- if (ts !== undefined) {
1464
- const entry = run.journal.files[item.relativePath];
1465
- if (entry && entry.hash === item.localHash) {
1466
- counters.filesSuppressedByTombstone++;
1467
- run.emit({
1468
- type: "upload-suppressed-tombstone",
1469
- path: item.relativePath,
1470
- deletedAt: ts.deletedAt,
1471
- });
1472
- continue;
1473
- }
1474
- }
1475
- }
1476
- uploadItems.push(item);
1477
- continue;
1478
- }
1479
- if (item.restamp) {
1480
- const existing = run.journal.files[item.relativePath];
1481
- if (existing && existing.hash) {
1482
- existing.mtimeMs = item.restamp.mtimeMs;
1483
- existing.size = item.restamp.size;
1484
- }
1485
- }
1486
- counters.filesSkipped++;
1487
- }
1488
-
1489
- await primeUploads(
1490
- run.ctx,
1491
- uploadItems.map((it) => ({
1492
- key: it.relativePath,
1493
- localPath: it.absolutePath,
1494
- isSymlink: it.kind === "symlink",
1495
- author: run.options.author,
1496
- })),
1497
- );
1498
- // Warm the GET presigns the per-item conflict HEAD (remoteMeta) reuses, so a
1499
- // large upload set doesn't mint one presign per HEAD and burst/trip the
1500
- // presign breaker. Mirrors the new-files + tombstone pre-primes on the pull.
1501
- await primeObjectTransport(
1502
- run.ctx,
1503
- "get",
1504
- uploadItems.map((it) => it.relativePath),
1505
- );
1506
-
1507
- let aborted = false;
1508
- let abortFlightConflictPaths: string[] = [];
1509
-
1510
- const processUploadItem = async (item: UploadPlanItem): Promise<void> => {
1511
- if (aborted) return;
1512
- const { absolutePath, relativePath, localHash } = item;
1513
-
1514
- // Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
1515
- // ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
1516
- // NEVER upload the local copy over them — not just on a two-sided conflict, but on
1517
- // ANY divergence, including a one-sided local change. A stale local mirror (e.g. a
1518
- // second HQ root that shares this machine's sync journal) would otherwise read as
1519
- // `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
1520
- // rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
1521
- // board.json is the one push-side exception: local board appends are
1522
- // legitimate, but must flow through the conditional PUT fence below. It
1523
- // remains cloud-authoritative on pull so server automation still wins a
1524
- // pull conflict.
1525
- if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
1526
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1527
- counters.filesSkipped++;
1528
- return;
1529
- }
1530
-
1531
- if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
1532
- run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
1533
- }
1534
-
1535
- let remoteMeta: Awaited<ReturnType<typeof headRemoteFile>>;
1536
- try {
1537
- remoteMeta = await headRemoteFile(run.ctx, relativePath);
1538
- } catch (headErr) {
1539
- if (isAccessDenied(headErr)) {
1540
- run.scopeExcludedSet.add(relativePath);
1541
- return;
1542
- }
1543
- throw headErr;
1544
- }
1545
- // Journal entry for this key (if any). Used both for 3-way conflict
1546
- // classification and for the write fence below: Prefer last-synced
1547
- // `remoteEtag` over live HEAD so a peer-advanced object cannot be
1548
- // silently LWW-overwritten by a journaled stale local body (Ace-shaped
1549
- // vault regression 2026-08-03).
1550
- const journalEntry = run.journal.files[relativePath];
1551
-
1552
- if (remoteMeta) {
1553
- const localChanged = !!journalEntry && journalEntry.hash !== localHash;
1554
- const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
1555
-
1556
- let isFreshCollision = false;
1557
- let freshContentConverged = false;
1558
- if (!journalEntry && item.kind === "symlink") {
1559
- isFreshCollision = true;
1560
- } else if (!journalEntry && item.kind === "file") {
1561
- const remoteDiffers = await remoteContentDiffers(
1562
- run.ctx,
1563
- relativePath,
1564
- localHash,
1565
- run.hqRoot,
1566
- );
1567
- if (remoteDiffers) {
1568
- isFreshCollision = true;
1569
- } else {
1570
- freshContentConverged = true;
1571
- }
1572
- }
1573
-
1574
- if (freshContentConverged) {
1575
- const lstat = fs.lstatSync(absolutePath);
1576
- updateEntry(
1577
- run.journal,
1578
- relativePath,
1579
- localHash,
1580
- lstat.size,
1581
- "up",
1582
- absolutePath,
1583
- {
1584
- remoteEtag: remoteMeta.etag,
1585
- mtimeMs: lstat.mtimeMs,
1586
- kind: item.kind,
1587
- },
1588
- );
1589
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1590
- counters.filesSkipped++;
1591
- return;
1592
- }
1593
-
1594
- // `localDiverges` means a prior pull-keep stamped the remote etag for
1595
- // re-fire silence (#137) while local never matched that remote. The
1596
- // journal-baseline If-Match fence alone would SUCCEED when remote is
1597
- // still at that etag, silently promoting the divergent local to write
1598
- // authority. Route through conflict (keep/abort/overwrite) instead.
1599
- const divergentLocalHonesty = !!journalEntry?.localDiverges;
1600
-
1601
- if ((localChanged && remoteChanged) || isFreshCollision || divergentLocalHonesty) {
1602
- // Cloud-authoritative paths are already skipped at the top of
1603
- // processUploadItem (before HEAD), so anything reaching here is a genuine
1604
- // conflict on a client-owned file.
1605
- conflictPaths.push(relativePath);
1606
-
1607
- const resolution = await resolveConflictSerialized({
1608
- path: relativePath,
1609
- localHash,
1610
- remoteModified: remoteMeta.lastModified,
1611
- direction: "push",
1612
- });
1613
-
1614
- run.emit({
1615
- type: "conflict",
1616
- path: relativePath,
1617
- direction: "push",
1618
- resolution,
1619
- });
1620
-
1621
- if (resolution === "abort") {
1622
- aborted = true;
1623
- abortFlightConflictPaths = [...conflictPaths];
1624
- return;
1625
- }
1626
- if (resolution === "keep" || resolution === "skip") {
1627
- if (isFreshCollision) {
1628
- await writePushConflictMirror(run, item, normalizeEtag(remoteMeta.etag));
1629
- }
1630
- counters.filesSkipped++;
1631
- return;
1632
- }
1633
- // overwrite: fall through to conditional PUT (or unfenced retry on 412).
1634
- }
1635
- }
1636
-
1637
- if (aborted) return;
1638
-
1639
- // Write fence: when remote exists, prefer last-synced journal remoteEtag
1640
- // over live HEAD so a peer-advanced object fails closed (412 → keep/
1641
- // abort/overwrite). Live-HEAD If-Match alone only covers HEAD→PUT TOCTOU.
1642
- // When remote is missing (HEAD null), always create-fence with
1643
- // If-None-Match:* — even if the journal still carries a stale remoteEtag
1644
- // from a prior sync (deleted key recreation under keep). Using journal
1645
- // If-Match against a missing object cannot succeed and false-conflicts.
1646
- // Legacy entries without remoteEtag fall back to live HEAD (prior behavior).
1647
- const precondition: PutPrecondition = remoteMeta
1648
- ? journalEntry?.remoteEtag
1649
- ? { ifMatch: journalEntry.remoteEtag }
1650
- : { ifMatch: remoteMeta.etag }
1651
- : { ifNoneMatch: "*" };
1652
-
1653
- const performUpload = async (pc: PutPrecondition | undefined): Promise<void> => {
1654
- const isSymlinkUpload = item.kind === "symlink";
1655
- const lstat = fs.lstatSync(absolutePath);
1656
- const size = isSymlinkUpload ? 0 : lstat.size;
1657
- const mtimeMs = lstat.mtimeMs;
1658
-
1659
- const { etag } = isSymlinkUpload
1660
- ? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
1661
- : await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
1662
-
1663
- updateEntry(
1664
- run.journal,
1665
- relativePath,
1666
- localHash,
1667
- size,
1668
- "up",
1669
- absolutePath,
1670
- { remoteEtag: etag, mtimeMs, kind: item.kind },
1671
- );
1672
- if (run.message) {
1673
- run.journal.files[relativePath] = {
1674
- ...run.journal.files[relativePath],
1675
- message: run.message,
1676
- } as JournalFileEntry & { message: string };
1677
- }
1678
-
1679
- counters.filesUploaded++;
1680
- counters.bytesUploaded += size;
1681
- run.emit({
1682
- type: "progress",
1683
- path: relativePath,
1684
- bytes: size,
1685
- ...(run.message ? { message: run.message } : {}),
1686
- });
1687
- };
1688
-
1689
- try {
1690
- await performUpload(precondition);
1691
- } catch (err) {
1692
- if (err instanceof VaultAuthError) throw err;
1693
- if (isPreconditionFailed(err)) {
1694
- conflictPaths.push(relativePath);
1695
- const resolution = await resolveConflictSerialized({
1696
- path: relativePath,
1697
- localHash,
1698
- direction: "push",
1699
- });
1700
- run.emit({
1701
- type: "conflict",
1702
- path: relativePath,
1703
- direction: "push",
1704
- resolution,
1705
- });
1706
- if (resolution === "abort") {
1707
- aborted = true;
1708
- abortFlightConflictPaths = [...conflictPaths];
1709
- return;
1710
- }
1711
- if (resolution === "overwrite") {
1712
- try {
1713
- await performUpload(undefined);
1714
- } catch (retryErr) {
1715
- if (retryErr instanceof VaultAuthError) throw retryErr;
1716
- run.emit({
1717
- type: "error",
1718
- path: relativePath,
1719
- message: describeError(retryErr),
1720
- });
1721
- }
1722
- return;
1723
- }
1724
- await writePushConflictMirror(
1725
- run,
1726
- item,
1727
- remoteMeta ? normalizeEtag(remoteMeta.etag) : "",
1728
- );
1729
- counters.filesSkipped++;
1730
- return;
1731
- }
1732
- if (isAccessDenied(err)) {
1733
- run.scopeExcludedSet.add(relativePath);
1734
- return;
1735
- }
1736
- run.emit({
1737
- type: "error",
1738
- path: relativePath,
1739
- message: describeError(err),
1740
- });
1741
- }
1742
- };
1743
-
1744
- const workerErrors: Error[] = [];
1745
- {
1746
- const queue = [...uploadItems];
1747
- const inFlight: Set<Promise<unknown>> = new Set();
1748
- while (queue.length > 0 || inFlight.size > 0) {
1749
- while (!aborted && inFlight.size < TRANSFER_CONCURRENCY && queue.length > 0) {
1750
- const item = queue.shift()!;
1751
- const p: Promise<void> = processUploadItem(item)
1752
- .catch((err: unknown) => {
1753
- workerErrors.push(err instanceof Error ? err : new Error(String(err)));
1754
- })
1755
- .finally(() => {
1756
- inFlight.delete(p);
1757
- });
1758
- inFlight.add(p);
1759
- }
1760
- if (inFlight.size > 0) {
1761
- await Promise.race(Array.from(inFlight));
1762
- } else {
1763
- break;
1764
- }
1765
- }
1766
- }
1767
-
1768
- return { aborted, abortFlightConflictPaths, workerErrors };
1769
- }
1770
-
1771
- async function writePushConflictMirror(
1772
- run: PushRunContext,
1773
- item: UploadPlanItem,
1774
- remoteHash: string,
1775
- ): Promise<void> {
1776
- try {
1777
- const detectedAt = new Date().toISOString();
1778
- const machineId = readShortMachineId(run.hqRoot);
1779
- const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
1780
- const conflictRelative = buildConflictPath(
1781
- originalRelative,
1782
- detectedAt,
1783
- machineId,
1784
- );
1785
- const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
1786
- if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
1787
- run.emit({
1788
- type: "error",
1789
- path: item.relativePath,
1790
- message: "conflict mirror skipped: local parent escaped the sync root",
1791
- });
1792
- } else {
1793
- await downloadFile(run.ctx, item.relativePath, conflictAbs);
1794
- appendConflictEntry(run.hqRoot, {
1795
- id: buildConflictId(originalRelative, detectedAt),
1796
- originalPath: originalRelative,
1797
- conflictPath: conflictRelative,
1798
- detectedAt,
1799
- side: "push",
1800
- machineId,
1801
- localHash: item.localHash,
1802
- remoteHash,
1803
- });
1804
- }
1805
- } catch (mirrorErr) {
1806
- if (mirrorErr instanceof VaultAuthError) throw mirrorErr;
1807
- run.emit({
1808
- type: "error",
1809
- path: item.relativePath,
1810
- message: "conflict mirror write failed: " + describeError(mirrorErr),
1811
- });
1812
- }
1813
- }
1814
-
1815
- async function executeDeletes(
1816
- run: PushRunContext,
1817
- deletePlan: DeletePlan,
1818
- decommissionPlan: string[],
1819
- counters: ShareCounters,
1820
- filesRefusedStalePaths: string[],
1821
- pathResults: SharePathResult[],
1822
- ): Promise<void> {
1823
- const deleteItems = [
1824
- ...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
1825
- ...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
1826
- ];
1827
- const deleteKeys = deleteItems.map((item) => item.key);
1828
- await primeObjectTransport(run.ctx, "delete", deleteKeys);
1829
- for (const item of deleteItems) {
1830
- const { key: relativePath } = item;
1831
- if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
1832
- run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
1833
- }
1834
- try {
1835
- const entry = run.journal.files[relativePath];
1836
- // Last-second absence re-check. Applies to EVERY propagated delete,
1837
- // intent-backed or etag-only: a pull leg or the user can recreate the
1838
- // file between planning and applying, and deleting it remotely then
1839
- // destroys a file that exists locally right now. This used to sit inside
1840
- // the intent-version check, which meant it was skipped for any item
1841
- // carrying `intentVersion: null` — harmless while that only covered
1842
- // litter and decommission, but not once etag-only candidates travel that
1843
- // way too.
1844
- //
1845
- // Codec boundary: journal keys are canonical; the probe must use the
1846
- // ENCODED local path or a win32 colon key always looks absent (a raw `:`
1847
- // name can never exist on disk) and the remote delete proceeds while the
1848
- // encoded local file is still present.
1849
- const recreatedLocally =
1850
- !item.decommission &&
1851
- !isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath));
1852
- const intentChanged =
1853
- !item.decommission &&
1854
- item.intentVersion !== null &&
1855
- (!hasCurrentLocalDeleteIntent(entry) ||
1856
- entry!.localDeleteIntent!.version !== item.intentVersion);
1857
- if (recreatedLocally || intentChanged) {
1858
- if (entry?.localDeleteIntent) {
1859
- delete entry.localDeleteIntent;
1860
- }
1861
- const refusalReason = recreatedLocally
1862
- ? ("recreated-locally" as const)
1863
- : ("intent-changed" as const);
1864
- counters.filesRefusedStale++;
1865
- if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1866
- filesRefusedStalePaths.push(relativePath);
1867
- }
1868
- run.emit({
1869
- type: "delete-refused-stale-etag",
1870
- path: relativePath,
1871
- journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
1872
- remoteEtag: "<not-checked>",
1873
- reason: refusalReason,
1874
- });
1875
- pathResults.push({
1876
- path: relativePath,
1877
- status: "refused",
1878
- operation: "delete",
1879
- reason: refusalReason,
1880
- });
1881
- continue;
1882
- }
1883
- const size = entry?.size ?? 0;
1884
- await deleteRemoteFile(run.ctx, relativePath);
1885
- removeEntry(run.journal, relativePath);
1886
- counters.filesDeleted++;
1887
- run.emit({
1888
- type: "progress",
1889
- path: relativePath,
1890
- bytes: size,
1891
- deleted: true,
1892
- });
1893
- pathResults.push({
1894
- path: relativePath,
1895
- status: "accepted",
1896
- operation: "delete",
1897
- });
1898
- } catch (err) {
1899
- if (err instanceof VaultAuthError) throw err;
1900
- run.emit({
1901
- type: "error",
1902
- path: relativePath,
1903
- message: describeError(err),
1904
- });
1905
- pathResults.push({
1906
- path: relativePath,
1907
- status: "refused",
1908
- operation: "delete",
1909
- reason: "transfer-error",
1910
- });
1911
- }
1912
- }
1913
- for (const relativePath of deletePlan.toTombstone) {
1914
- const localPath = localPathForVaultKey(run.syncRoot, relativePath);
1915
- try {
1916
- const lstat = fs.lstatSync(localPath);
1917
- const entry = run.journal.files[relativePath];
1918
- if (lstat.isFile()) {
1919
- const localHash = hashFile(localPath);
1920
- if (entry?.hash && entry.hash !== localHash) {
1921
- run.emit({
1922
- type: "error",
1923
- path: relativePath,
1924
- message:
1925
- "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1926
- });
1927
- continue;
1928
- }
1929
- fs.unlinkSync(localPath);
1930
- } else if (lstat.isSymbolicLink()) {
1931
- const target = readlinkOrNull(localPath);
1932
- if (target === null) {
1933
- run.emit({
1934
- type: "not-shipped",
1935
- reason: "unreadable-link",
1936
- count: 1,
1937
- samplePaths: [relativePath],
1938
- });
1939
- continue;
1940
- }
1941
- const localHash = hashSymlinkTarget(target);
1942
- if (entry?.hash && entry.hash !== localHash) {
1943
- run.emit({
1944
- type: "error",
1945
- path: relativePath,
1946
- message:
1947
- "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1948
- });
1949
- continue;
1950
- }
1951
- fs.unlinkSync(localPath);
1952
- }
1953
- } catch (err: unknown) {
1954
- const code =
1955
- err && typeof err === "object" && "code" in err
1956
- ? (err as { code?: string }).code
1957
- : undefined;
1958
- if (code !== "ENOENT") {
1959
- run.emit({
1960
- type: "error",
1961
- path: relativePath,
1962
- message: `tombstone unlink failed: ${
1963
- err instanceof Error ? err.message : String(err)
1964
- }`,
1965
- });
1966
- continue;
1967
- }
1968
- }
1969
- removeEntry(run.journal, relativePath);
1970
- counters.filesTombstoned++;
1971
- run.emit({
1972
- type: "progress",
1973
- path: relativePath,
1974
- bytes: 0,
1975
- deleted: true,
1976
- message: "tombstone (remote already 404)",
1977
- });
1978
- pathResults.push({
1979
- path: relativePath,
1980
- status: "accepted",
1981
- operation: "tombstone",
1982
- });
1983
- }
1984
- const decommissionedSet =
1985
- decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
1986
- for (const refused of deletePlan.refusedStale) {
1987
- if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
1988
- counters.filesRefusedStale++;
1989
- if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1990
- filesRefusedStalePaths.push(refused.key);
1991
- }
1992
- run.emit({
1993
- type: "delete-refused-stale-etag",
1994
- path: refused.key,
1995
- journalEtag: refused.journalEtag,
1996
- remoteEtag: refused.remoteEtag,
1997
- reason: refused.reason,
1998
- });
1999
- pathResults.push({
2000
- path: refused.key,
2001
- status: "refused",
2002
- operation: "delete",
2003
- reason: refused.reason,
2004
- });
2005
- }
2006
- }
2007
-
2008
- function finalizeShareJournal(run: PushRunContext): void {
2009
- run.journal.lastSync = new Date().toISOString();
2010
- writeJournal(run.journalSlug, run.journal);
2011
-
2012
- if (run.excludedSet.size > 0) {
2013
- const samplePaths: string[] = [];
2014
- for (const p of run.excludedSet) {
2015
- samplePaths.push(p);
2016
- if (samplePaths.length >= 10) break;
2017
- }
2018
- run.emit({
2019
- type: "personal-vault-out-of-policy",
2020
- count: run.excludedSet.size,
2021
- samplePaths,
2022
- byId: { ...run.excludedById },
2023
- });
2024
- }
2025
-
2026
- if (run.scopeExcludedSet.size > 0) {
2027
- const samplePaths: string[] = [];
2028
- for (const p of run.scopeExcludedSet) {
2029
- samplePaths.push(p);
2030
- if (samplePaths.length >= 10) break;
2031
- }
2032
- run.emit({
2033
- type: "scope-excluded",
2034
- count: run.scopeExcludedSet.size,
2035
- samplePaths,
2036
- });
2037
- }
2038
-
2039
- if (run.ignoreExcludedSet.size > 0) {
2040
- const samplePaths: string[] = [];
2041
- for (const p of run.ignoreExcludedSet) {
2042
- samplePaths.push(p);
2043
- if (samplePaths.length >= 10) break;
2044
- }
2045
- run.emit({
2046
- type: "ignore-excluded",
2047
- count: run.ignoreExcludedSet.size,
2048
- totalExcluded: run.ignoreExcludedTotal.value,
2049
- samplePaths,
2050
- });
2051
- }
2052
-
2053
- emitUnreachablePathEvent(run);
2054
-
2055
- if (run.linkedSubtreeSet.size > 0) {
2056
- run.emit({
2057
- type: "not-shipped",
2058
- reason: "linked-subtree",
2059
- count: run.linkedSubtreeSet.size,
2060
- samplePaths: sampleSet(run.linkedSubtreeSet),
2061
- });
2062
- }
2063
- }
2064
-
2065
- /**
2066
- * Which unreachable named paths are FATAL under the run's policy.
2067
- *
2068
- * Under the default `"error"` policy only `"outside-company"` is fatal: the
2069
- * entry is sitting on disk and the resolver refused to place it under the
2070
- * company folder, which is precisely the "exists locally but is unreachable by
2071
- * the resolver" case the report asks to turn into an error, and it cannot
2072
- * happen for an internal walk root (a company folder is trivially inside
2073
- * itself).
2074
- *
2075
- * `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
2076
- * and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
2077
- * one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
2078
- * folder was never materialized locally; throwing there would turn "you haven't
2079
- * pulled that company yet" into a hard failure of an unrelated multi-company
2080
- * push. Same reasoning for a watcher path deleted between the event and the
2081
- * push. Those callers get the loud report without the regression.
2082
- *
2083
- * `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
2084
- * end to end (the background sync runner).
2085
- */
2086
- function collectFatalUnreachablePaths(
2087
- run: PushRunContext,
2088
- ): Map<string, UnreachablePathReason> {
2089
- const fatal = new Map<string, UnreachablePathReason>();
2090
- if (run.options.unreachablePathPolicy === "warn") return fatal;
2091
- for (const [namedPath, reason] of run.unreachablePaths) {
2092
- if (reason === "outside-company") fatal.set(namedPath, reason);
2093
- }
2094
- return fatal;
2095
- }
2096
-
2097
- /**
2098
- * Emit the `not-shipped` / `unreachable-path` event for a run, if any named
2099
- * path went unshipped. Shared by BOTH policies so the operator sees the same
2100
- * report either way: the fail-fast path emits it immediately before throwing
2101
- * (the journal finalizer never runs on a throw), and the `"warn"` path emits it
2102
- * from the finalizer at the end of a successful run. Exactly one of those two
2103
- * call sites can fire per run, so the event is never duplicated.
2104
- */
2105
- function emitUnreachablePathEvent(run: PushRunContext): void {
2106
- if (run.unreachablePaths.size === 0) return;
2107
- const unreadableLinks: string[] = [];
2108
- const unreachablePaths: string[] = [];
2109
- for (const [namedPath, reason] of run.unreachablePaths) {
2110
- (reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
2111
- }
2112
- if (unreachablePaths.length > 0) {
2113
- run.emit({
2114
- type: "not-shipped",
2115
- reason: "unreachable-path",
2116
- count: unreachablePaths.length,
2117
- samplePaths: unreachablePaths.slice(0, 10),
2118
- });
2119
- }
2120
- if (unreadableLinks.length > 0) {
2121
- run.emit({
2122
- type: "not-shipped",
2123
- reason: "unreadable-link",
2124
- count: unreadableLinks.length,
2125
- samplePaths: unreadableLinks.slice(0, 10),
2126
- });
2127
- }
2128
- }
2129
-
2130
- /** First up-to-`limit` members of an iterable, for bounded event payloads. */
2131
- function sampleSet(set: Iterable<string>, limit = 10): string[] {
2132
- const sample: string[] = [];
2133
- for (const value of set) {
2134
- sample.push(value);
2135
- if (sample.length >= limit) break;
2136
- }
2137
- return sample;
2138
- }
2139
-
2140
- function throwUploadWorkerErrors(workerErrors: Error[]): void {
2141
- if (workerErrors.length > 0) {
2142
- const first = workerErrors[0]!;
2143
- if (workerErrors.length > 1) {
2144
- first.message =
2145
- first.message +
2146
- " (and " +
2147
- (workerErrors.length - 1) +
2148
- " more upload-worker errors)";
2149
- }
2150
- throw first;
2151
- }
2152
- }
2153
-
2154
- function buildShareResult(
2155
- run: PushRunContext,
2156
- counters: ShareCounters,
2157
- filesRefusedStalePaths: string[],
2158
- conflictPaths: string[],
2159
- pathResults: SharePathResult[],
2160
- aborted: boolean,
2161
- ): ShareResult {
2162
- return {
2163
- filesUploaded: counters.filesUploaded,
2164
- bytesUploaded: counters.bytesUploaded,
2165
- filesSkipped: counters.filesSkipped,
2166
- filesDeleted: counters.filesDeleted,
2167
- filesTombstoned: counters.filesTombstoned,
2168
- filesRefusedStale: counters.filesRefusedStale,
2169
- filesRefusedStalePaths,
2170
- filesSuppressedByTombstone: counters.filesSuppressedByTombstone,
2171
- filesExcludedByPolicy: run.excludedSet.size,
2172
- filesExcludedByScope: run.scopeExcludedSet.size,
2173
- filesExcludedByIgnore: run.ignoreExcludedSet.size,
2174
- unreachablePaths: [...run.unreachablePaths.keys()],
2175
- linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
2176
- conflictPaths,
2177
- pathResults,
2178
- aborted,
2179
- };
2180
- }
2181
-
2182
- /**
2183
- * Default human-readable share output. Preserves the exact format the CLI
2184
- * emitted before `onEvent` was added — tty users see no change.
2185
- */
2186
- function defaultConsoleLogger(event: SyncProgressEvent): void {
2187
- if (event.type === "plan") {
2188
- if (event.filesToUpload > 0) {
2189
- console.log(
2190
- `Plan: ${event.filesToUpload} to upload (${event.bytesToUpload} bytes), ${event.filesToSkip} unchanged`,
2191
- );
2192
- }
2193
- } else if (event.type === "progress") {
2194
- if (event.deleted) {
2195
- // Append `message` when present (e.g. tombstone events carry
2196
- // "tombstone (remote already 404)"). Without this, tombstones and
2197
- // real deletes render byte-identically in the tty stream, and
2198
- // operators have no way to distinguish from logs alone.
2199
- const suffix = event.message ? ` — ${event.message}` : "";
2200
- console.log(` ✗ ${event.path} (deleted)${suffix}`);
2201
- } else if (event.message) {
2202
- console.log(` ✓ ${event.path} — "${event.message}"`);
2203
- } else {
2204
- console.log(` ✓ ${event.path}`);
2205
- }
2206
- } else if (event.type === "conflict") {
2207
- console.error(
2208
- ` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
2209
- );
2210
- } else if (event.type === "error") {
2211
- console.error(` ✗ ${event.path} — ${event.message}`);
2212
- } else if (event.type === "delete-refused-stale-etag") {
2213
- // Branch on `reason`, not on the sentinel etag strings, so legacy
2214
- // entries render with a clear explanation instead of "<legacy-no-etag>"
2215
- // leaking into operator-visible output.
2216
- if (event.reason === "legacy-no-etag") {
2217
- console.error(
2218
- ` ⚠ no-etag-on-record, kept on remote: ${event.path} (journal entry predates etag tracking)`,
2219
- );
2220
- } else if (event.reason === "bulk-asymmetry") {
2221
- // The one-shot summary below carries the count + bypass instructions.
2222
- // Per-key lines stay compact so a 100-candidate refusal doesn't bury
2223
- // the summary banner.
2224
- console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
2225
- } else if (event.reason === "missing-delete-intent") {
2226
- // NEITHER etag is meaningful here: no HEAD was issued, so `remoteEtag`
2227
- // is the `<not-checked>` sentinel, and `journalEtag` is the entry's
2228
- // real recorded etag. Printing them under the `stale-etag` wording
2229
- // (which this branch used to fall through to) reads as an etag
2230
- // conflict and has repeatedly sent operators chasing a currency bug
2231
- // that does not exist — the actual cause is that no watcher observed
2232
- // the removal, so no delete authorization was ever recorded.
2233
- console.error(
2234
- ` ⚠ no-delete-authorization, kept on remote: ${event.path} ` +
2235
- `(the sync watcher did not observe this removal, so the delete was never authorized)`,
2236
- );
2237
- } else if (event.reason === "recreated-locally") {
2238
- console.error(
2239
- ` ⚠ recreated-locally, kept on remote: ${event.path} ` +
2240
- `(the file exists locally again, so the delete was abandoned)`,
2241
- );
2242
- } else if (event.reason === "divergent-local") {
2243
- // Same trap as above — `remoteEtag` is `<not-checked>`. The entry's
2244
- // recorded etag was stamped only to silence a re-fired conflict; the
2245
- // local copy never matched it, so propagating the delete would destroy
2246
- // divergent remote work.
2247
- console.error(
2248
- ` ⚠ divergent-local, kept on remote: ${event.path} ` +
2249
- `(the local copy never matched the recorded remote version; pull to reconcile first)`,
2250
- );
2251
- } else {
2252
- console.error(
2253
- ` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`,
2254
- );
2255
- }
2256
- } else if (event.type === "delete-refused-bulk-asymmetry") {
2257
- const pct = (event.ratio * 100).toFixed(1);
2258
- console.error(
2259
- ` ⚠ bulk-asymmetry circuit-breaker tripped: ${event.candidates}/${event.inScope} in-scope journal entries (${pct}%) are missing locally.\n` +
2260
- ` Refusing to propagate deletes — this looks like a local-mirror loss (moved hqRoot, partial restore, fresh clone, unmounted volume, accidental rm) rather than an intentional cleanup.\n` +
2261
- ` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
2262
- ` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
2263
- );
2264
- } else if (event.type === "skip-invalid-scoped-key") {
2265
- console.warn(
2266
- ` ! ${event.path} skipped — 'companies/'-prefixed key is invalid in a company-scoped vault (stale doubled local tree? remove the inner companies/<slug> copy)`,
2267
- );
2268
- } else if (event.type === "ignore-excluded") {
2269
- // The "not silent" surface: an ignore rule dropped content that doesn't
2270
- // look like expected build/VCS/cache noise, so it never reached the vault.
2271
- // Surface it as a WARN naming the paths so an over-broad exclusion is
2272
- // observable rather than invisible (DEV-1791 / feedback_b9dfc7ae).
2273
- console.warn(
2274
- ` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
2275
- );
2276
- for (const p of event.samplePaths) {
2277
- console.warn(` · ${p}`);
2278
- }
2279
- if (event.count > event.samplePaths.length) {
2280
- console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2281
- }
2282
- } else if (event.type === "not-shipped") {
2283
- // The other "not silent" surface: content the walk saw but chose not to
2284
- // ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
2285
- if (event.reason === "unreachable-path") {
2286
- console.warn(
2287
- ` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
2288
- );
2289
- } else if (event.reason === "unreadable-link") {
2290
- console.warn(
2291
- ` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
2292
- );
2293
- } else {
2294
- console.warn(
2295
- ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
2296
- );
2297
- }
2298
- for (const p of event.samplePaths) {
2299
- console.warn(` · ${p}`);
2300
- }
2301
- if (event.count > event.samplePaths.length) {
2302
- console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2303
- }
2304
- }
2305
- }
2306
-
2307
- /**
2308
- * One entry produced by collectFiles/walkDir. Files describe regular
2309
- * payloads that get hashed + size-checked + uploaded via uploadFile;
2310
- * symlinks describe link records whose target string flows through
2311
- * uploadSymlink as user metadata. Walked-dir traversal NEVER descends
2312
- * into a symlink — directory symlinks are recorded as link entries and
2313
- * left at that, so following them never duplicates content into the
2314
- * wrong vault path (the same topology safety the legacy walker provided
2315
- * by accident of Dirent.isFile() returning false for links).
2316
- */
2317
- type CollectedEntry =
2318
- | { kind: "file"; absolutePath: string; relativePath: string }
2319
- | { kind: "symlink"; absolutePath: string; relativePath: string; target: string };
2320
-
2321
- /**
2322
- * Optional visibility callbacks for {@link collectFiles} / {@link walkDir}.
2323
- * They turn two previously-SILENT outcomes into surfaced signals — without
2324
- * changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
2325
- *
2326
- * - `onUnreachablePath`: a path that cannot be shipped. It is normally the
2327
- * caller's explicit spelling (`"outside-company"` or `"missing"`), and is
2328
- * the company-relative key for an unreadable link discovered during a
2329
- * directory walk (`"unreadable-link"`). Pre-fix this was a bare
2330
- * `console.error` warn-skip that still let the push report "Pushed 0 file(s)"
2331
- * — a false success. The caller can now turn a non-empty set into a real
2332
- * error / nonzero exit.
2333
- *
2334
- * SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token,
2335
- * verbatim — never the resolved absolute path, and never normalized. A
2336
- * relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
2337
- * an absolute path is reported absolute because that is what was passed.
2338
- * An unreadable link discovered during a recursive walk instead uses its
2339
- * company-relative vault key, because it has no separate caller token.
2340
- * This keeps UI output actionable without exposing host-specific paths.
2341
- * - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
2342
- * descended because its target resolves OUTSIDE the company folder (e.g.
2343
- * `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
2344
- * contents ship via their own repo, not the vault; reporting it stops files
2345
- * created under such a link from vanishing from every push bucket silently.
2346
- */
2347
- interface CollectHooks {
2348
- onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void;
2349
- onLinkedSubtree?: (rel: string) => void;
2350
- }
2351
-
2352
- /**
2353
- * Resolve a caller-supplied push path to an absolute path.
2354
- *
2355
- * Relative paths were historically resolved against `hqRoot` ONLY, so a
2356
- * company-relative spelling like `knowledge/agents/x.md` became
2357
- * `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
2358
- * caller's cwd or the company being pushed (feedback_258e4a86 /
2359
- * feedback_a51cb63d — "there is no path spelling that reaches the file").
2360
- *
2361
- * PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
2362
- * (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
2363
- * ADDITIVE to the legacy behavior: every relative spelling that resolved
2364
- * pre-fix still resolves to exactly the same file, and the two new bases only
2365
- * catch spellings that previously resolved to nothing. Probing cwd first would
2366
- * silently re-point existing callers (a `knowledge/` directory in the shell's
2367
- * cwd would win over the hq-root one), which is a behavior change no reporter
2368
- * asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
2369
- * the unchanged "does not exist" diagnostic. Absolute paths are returned
2370
- * verbatim.
2371
- *
2372
- * `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
2373
- * rather than an ambient read, so resolution is deterministic and testable
2374
- * without mutating process state.
2375
- */
2376
- function resolveNamedPath(
2377
- p: string,
2378
- hqRoot: string,
2379
- syncRoot: string,
2380
- cwd: string = process.cwd(),
2381
- ): string {
2382
- if (path.isAbsolute(p)) return p;
2383
- const hqRootCandidate = path.resolve(hqRoot, p);
2384
- const candidates = [
2385
- hqRootCandidate,
2386
- path.resolve(syncRoot, p),
2387
- path.resolve(cwd, p),
2388
- ];
2389
- for (const candidate of candidates) {
2390
- try {
2391
- fs.lstatSync(candidate);
2392
- // An hqRoot hit outside the company folder would be rejected by
2393
- // collectFiles as outside-company; skip it so a valid company-relative
2394
- // spelling can win (Codex P2 — common `knowledge/` homonym case).
2395
- if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
2396
- continue;
2397
- }
2398
- return candidate;
2399
- } catch {
2400
- // Base did not resolve to an on-disk entry — try the next one.
2401
- }
2402
- }
2403
- return hqRootCandidate;
2404
- }
2405
-
2406
- /**
2407
- * Containment check for a regular file or directory that tolerates a symlinked
2408
- * ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
2409
- * path reached through a symlinked ancestor (`companies/{co}/knowledge` →
2410
- * `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
2411
- * rejected as "outside company folder" — even though its logical path is
2412
- * in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
2413
- * company-namespaced key for it. Accept when the LEXICAL path is inside
2414
- * (honoring the same logical topology the vault key uses) OR the realpath is
2415
- * inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
2416
- *
2417
- * The lexical arm carries TWO bounds, because it is the only place where a
2418
- * path's bytes and its vault key come from different trees:
2419
- *
2420
- * 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
2421
- * symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
2422
- * state under a company-namespaced key.
2423
- * 2. The TENANT — the realpath must not land inside another company's bytes
2424
- * (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
2425
- * must never be mistaken for a tenant boundary: hqRoot CONTAINS every
2426
- * other company, so `companies/acme/knowledge → companies/other/secret`
2427
- * (or → `repos/private/knowledge-other`, the linked-repo topology) is
2428
- * lexically inside acme and really inside HQ, and would upload the other
2429
- * tenant's bytes into acme's bucket under the key `knowledge/…`.
2430
- *
2431
- * The motivating topology (`companies/{co}/knowledge` →
2432
- * `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
2433
- * that escapes HQ, or one that reaches another tenant, is refused — and under
2434
- * the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
2435
- *
2436
- * Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
2437
- * externally-linked subtree can only be recognized while that company's folder
2438
- * is materialized locally and publishes the link. A machine holding
2439
- * `repos/private/knowledge-other` with no `companies/other` folder has no
2440
- * on-disk evidence of the claim, so a link into it is indistinguishable from a
2441
- * link into any other local repo. Ownership metadata (not path shape) is what
2442
- * would close that gap.
2443
- */
2444
- function isWithinLexicalOrReal(
2445
- parent: string,
2446
- child: string,
2447
- hqRoot: string,
2448
- tenantRootsCache?: Map<string, string[]>,
2449
- ): boolean {
2450
- // Strict arm first: the realpath is genuinely inside the company folder.
2451
- // This is the overwhelmingly common case, needs no relaxation, and costs no
2452
- // directory scan.
2453
- if (isWithin(parent, child)) return true;
2454
-
2455
- const resolvedChild = path.resolve(child);
2456
- if (!isPathWithin(path.resolve(parent), resolvedChild)) return false;
2457
-
2458
- const childReal = realpathSafe(resolvedChild);
2459
- if (!isWithin(hqRoot, childReal)) return false; // bound 1: escapes HQ
2460
-
2461
- // NUL-joined: it is the one byte a path cannot contain, so no pair of
2462
- // (hqRoot, parent) values can collide on the key.
2463
- const cacheKey = `${hqRoot}\u0000${parent}`;
2464
- let foreignRoots = tenantRootsCache?.get(cacheKey);
2465
- if (foreignRoots === undefined) {
2466
- foreignRoots = foreignTenantRoots(hqRoot, parent);
2467
- tenantRootsCache?.set(cacheKey, foreignRoots);
2468
- }
2469
- for (const foreign of foreignRoots) {
2470
- if (isPathWithin(foreign, childReal)) return false; // bound 2: other tenant
2471
- }
2472
- return true;
2473
- }
2474
-
2475
- /**
2476
- * Depth (in path segments below a company root) at which we look for the
2477
- * directory symlinks a company publishes into its own folder. HQ's linked
2478
- * topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
2479
- * (`companies/{co}/repos/{name}`); going deeper would turn a containment check
2480
- * into a full-tree walk for no additional coverage.
2481
- */
2482
- const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
2483
-
2484
- /**
2485
- * Canonicalized roots that belong to a tenant OTHER than the one being pushed.
2486
- * A lexically-contained path whose realpath lands inside any of these is
2487
- * another company's data wearing this company's key, and must be refused.
2488
- *
2489
- * Two kinds of root are collected per foreign company:
2490
- * - the company folder itself (`companies/{other}`), and
2491
- * - the targets of the directory symlinks that folder publishes — HQ's
2492
- * pattern-2 topology puts a company's knowledge in `repos/private/
2493
- * knowledge-{other}` and links it in, so the bytes live OUTSIDE every
2494
- * `companies/` root and a companies-only check would miss them entirely.
2495
- *
2496
- * Only consulted on the rare lexical arm (a path whose realpath is not inside
2497
- * the company folder), never on the ordinary in-tree push, so the directory
2498
- * scan is not on the hot path. Callers may memoize it for the duration of a
2499
- * single collect pass; nothing memoizes it for longer, because the sync runner
2500
- * is long-lived and a stale tenant map fails OPEN — the wrong direction for a
2501
- * boundary whose whole job is to refuse.
2502
- */
2503
- function foreignTenantRoots(hqRoot: string, syncRoot: string): string[] {
2504
- const companiesDir = path.join(hqRoot, "companies");
2505
- let entries: fs.Dirent[];
2506
- try {
2507
- entries = fs.readdirSync(companiesDir, { withFileTypes: true });
2508
- } catch {
2509
- return []; // no companies/ tree here — nothing to be foreign to
2510
- }
2511
-
2512
- const activeReal = realpathSafe(syncRoot);
2513
- const roots: string[] = [];
2514
- for (const entry of entries) {
2515
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
2516
- const companyRoot = path.join(companiesDir, entry.name);
2517
- const companyReal = realpathSafe(companyRoot);
2518
- if (companyReal === activeReal) continue; // this is the tenant being pushed
2519
- roots.push(companyReal);
2520
- collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
2521
- }
2522
- return roots;
2523
- }
2524
-
2525
- /**
2526
- * Append the resolved targets of the directory symlinks published under
2527
- * `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
2528
- * inside the company folder are skipped — they add no reach beyond the root
2529
- * already recorded. Errors are swallowed per entry on purpose: an unreadable
2530
- * sibling directory must narrow what we can prove, never abort the push it is
2531
- * unrelated to.
2532
- */
2533
- function collectPublishedLinkTargets(
2534
- dir: string,
2535
- companyReal: string,
2536
- depth: number,
2537
- out: string[],
2538
- ): void {
2539
- if (depth <= 0) return;
2540
- let entries: fs.Dirent[];
2541
- try {
2542
- entries = fs.readdirSync(dir, { withFileTypes: true });
2543
- } catch {
2544
- return;
2545
- }
2546
- for (const entry of entries) {
2547
- const child = path.join(dir, entry.name);
2548
- if (entry.isSymbolicLink()) {
2549
- let real: string;
2550
- try {
2551
- real = fs.realpathSync.native(child);
2552
- } catch {
2553
- continue; // dangling link claims nothing
2554
- }
2555
- try {
2556
- if (!fs.statSync(real).isDirectory()) continue;
2557
- } catch {
2558
- continue;
2559
- }
2560
- if (isPathWithin(companyReal, real)) continue; // no reach beyond the root
2561
- out.push(real);
2562
- } else if (entry.isDirectory()) {
2563
- collectPublishedLinkTargets(child, companyReal, depth - 1, out);
2564
- }
2565
- }
2566
- }
2567
-
2568
- /**
2569
- * If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
2570
- * `onLinkedSubtree` so the caller can report that the link's contents were not
2571
- * uploaded to the vault. Fires only for links that (a) resolve to a directory
2572
- * and (b) point outside the company folder — an in-tree link is descended
2573
- * elsewhere, and a dangling or file link has nothing behind it to report.
2574
- */
2575
- function reportLinkedSubtreeIfExternal(
2576
- linkPath: string,
2577
- syncRoot: string,
2578
- relativePath: string,
2579
- hooks: CollectHooks,
2580
- ): void {
2581
- if (!hooks.onLinkedSubtree) return;
2582
- let real: string;
2583
- try {
2584
- real = fs.realpathSync.native(linkPath); // resolves the link to its target
2585
- } catch {
2586
- return; // dangling link — nothing behind it to report
2587
- }
2588
- let targetStat: fs.Stats;
2589
- try {
2590
- targetStat = fs.statSync(real);
2591
- } catch {
2592
- return;
2593
- }
2594
- if (!targetStat.isDirectory()) return;
2595
- if (isWithin(syncRoot, real)) return; // in-tree target: descended elsewhere
2596
- hooks.onLinkedSubtree(relativePath);
2597
- }
2598
-
2599
- /**
2600
- * Collect files from paths (expanding directories recursively).
2601
- *
2602
- * Remote S3 keys are computed relative to `syncRoot` (companies/{slug}/), not
2603
- * `hqRoot`. Files outside `syncRoot` are skipped with a warning — sharing
2604
- * anything outside a company's folder would leak state into the wrong vault.
2605
- *
2606
- * Symlink classification uses lstat (not stat), so a top-level path that is
2607
- * itself a symlink is recorded as a link record rather than dereferenced.
2608
- * Pre-fix, statSync followed the link and the target's bytes were uploaded
2609
- * under the link's key — silently flattening the link topology.
2610
- */
2611
- function collectFiles(
2612
- paths: string[],
2613
- hqRoot: string,
2614
- syncRoot: string,
2615
- filter: (p: string, isDir?: boolean) => boolean,
2616
- hooks: CollectHooks = {},
2617
- ): CollectedEntry[] {
2618
- const results: CollectedEntry[] = [];
2619
- // Scoped to THIS collect pass and discarded with it: a watcher batch can
2620
- // name hundreds of paths, and rescanning the companies/ tree for each one
2621
- // is wasted I/O. A cache that outlived the pass could fail open on a tenant
2622
- // boundary, so it deliberately does not.
2623
- const tenantRoots = new Map<string, string[]>();
2624
-
2625
- for (const p of paths) {
2626
- const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
2627
-
2628
- // Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
2629
- // Caller may pass one explicitly; we still refuse to upload it. Basename
2630
- // check matches the walkDir gate so behavior is identical whether the
2631
- // mirror is the user-supplied path or found during directory recursion.
2632
- if (isEphemeralPath(path.basename(absolutePath))) continue;
2633
-
2634
- // existsSync follows symlinks: a dangling top-level link will report
2635
- // not-existing and be skipped here. lstatSync below handles the
2636
- // valid-link case directly without needing the existsSync gate.
2637
- let lstat: fs.Stats;
2638
- try {
2639
- lstat = fs.lstatSync(absolutePath);
2640
- } catch {
2641
- console.error(` Warning: ${p} does not exist, skipping.`);
2642
- hooks.onUnreachablePath?.(p, "missing");
2643
- continue;
2644
- }
2645
-
2646
- // Containment check is split by entry kind: regular files and
2647
- // directories use isWithin (which canonicalizes via realpathSync to
2648
- // tolerate macOS APFS case-insensitivity), but symlinks use the
2649
- // link's own pathname rather than the link's resolved target. A
2650
- // valid use case for this asymmetry: a directory symlink whose
2651
- // target lives outside the company folder (e.g. companies/{co}/
2652
- // knowledge → repos/private/knowledge-{co}/) — the LINK itself is
2653
- // inside the company folder and is exactly what we want to record,
2654
- // but isWithin's realpath would say the resolved target is outside
2655
- // and reject the share. Recording symlinks rather than following
2656
- // them is the whole topology contract this fix establishes; the
2657
- // containment check needs to honor the same semantic.
2658
- if (lstat.isSymbolicLink()) {
2659
- if (!isWithinForLink(syncRoot, absolutePath)) {
2660
- console.error(` Warning: ${p} is outside company folder, skipping.`);
2661
- hooks.onUnreachablePath?.(p, "outside-company");
2662
- continue;
2663
- }
2664
- const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
2665
- // Probe the filter with both isDir hints — we don't know whether
2666
- // the link's target is a file or a directory without
2667
- // stat-following the link, which we explicitly avoid (it would
2668
- // re-introduce the dereference behavior this whole change set is
2669
- // designed to prevent). An `.hqinclude` dir-only pattern like
2670
- // `companies/*/knowledge/` only matches with isDir=true, so a
2671
- // single isDir=false probe would silently drop directory
2672
- // symlinks under allowlist mode (the motivating case for this
2673
- // whole branch). The filter is pure path lookup with no I/O,
2674
- // so two calls are free.
2675
- if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
2676
- const target = readlinkOrNull(absolutePath);
2677
- if (target === null) {
2678
- console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
2679
- hooks.onUnreachablePath?.(p, "unreadable-link");
2680
- continue;
2681
- }
2682
- // A directory symlink whose target lives outside the company folder is
2683
- // recorded here but never descended — its contents ship via their own
2684
- // repo, not the vault. Surface it so files created under such a link
2685
- // don't vanish from every push bucket silently.
2686
- reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
2687
- results.push({
2688
- kind: "symlink",
2689
- absolutePath,
2690
- relativePath,
2691
- target,
2692
- });
2693
- continue;
2694
- }
2695
-
2696
- if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
2697
- console.error(` Warning: ${p} is outside company folder, skipping.`);
2698
- hooks.onUnreachablePath?.(p, "outside-company");
2699
- continue;
2700
- }
2701
-
2702
- if (lstat.isDirectory()) {
2703
- if (!filter(absolutePath, true)) continue;
2704
- walkDir(absolutePath, syncRoot, filter, results, hooks);
2705
- } else if (lstat.isFile()) {
2706
- const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
2707
- if (filter(absolutePath)) {
2708
- results.push({ kind: "file", absolutePath, relativePath });
2709
- }
2710
- }
2711
- }
2712
-
2713
- return results;
2714
- }
2715
-
2716
- function walkDir(
2717
- dir: string,
2718
- syncRoot: string,
2719
- filter: (p: string, isDir?: boolean) => boolean,
2720
- results: CollectedEntry[],
2721
- hooks: CollectHooks = {},
2722
- ): void {
2723
- // A frame per open directory preserves the recursive walk's depth-first
2724
- // ordering without turning a completed subtree into one giant call argument
2725
- // list. This matters for both operator-visible plan ordering and large trees.
2726
- const stack: Array<{ dir: string; entries: fs.Dirent[]; index: number }> = [];
2727
- const enterDirectory = (currentDir: string): void => {
2728
- if (!fs.existsSync(currentDir)) return;
2729
- stack.push({
2730
- dir: currentDir,
2731
- entries: fs.readdirSync(currentDir, { withFileTypes: true }),
2732
- index: 0,
2733
- });
2734
- };
2735
-
2736
- enterDirectory(dir);
2737
- while (stack.length > 0) {
2738
- const frame = stack[stack.length - 1];
2739
- if (frame.index >= frame.entries.length) {
2740
- stack.pop();
2741
- continue;
2742
- }
2743
- const entry = frame.entries[frame.index++];
2744
- // Ephemeral artifacts (conflict mirrors) are local-only safety backups
2745
- // that MUST NEVER round-trip to S3. Check basename here so the filter
2746
- // applies regardless of which company root contains them. See
2747
- // EPHEMERAL_PATH_PATTERN doc for the full rationale.
2748
- if (isEphemeralPath(entry.name)) continue;
2749
- const absolutePath = path.join(frame.dir, entry.name);
2750
- const isDir = entry.isDirectory();
2751
-
2752
- // Symlinks need their own filter probe BEFORE the regular gate.
2753
- // Dirent.isDirectory() returns false for any symlink — even a
2754
- // directory symlink — so the regular filter call below would use
2755
- // isDir=false and a dir-only allowlist pattern like
2756
- // `companies/*/knowledge/` would reject the link before the
2757
- // record-only branch runs. Probe with both hints; include if
2758
- // either matches. The filter is pure path lookup with no I/O.
2759
- if (entry.isSymbolicLink()) {
2760
- if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
2761
- // Record the link without descending into its target. Following
2762
- // a directory symlink would re-enter content via a path that
2763
- // isn't its on-disk home (e.g. companies/{co}/knowledge → repos/
2764
- // private/knowledge-{co}/), causing per-company knowledge repos
2765
- // to be uploaded into every vault that links them. Recording
2766
- // and not following preserves the link topology while avoiding
2767
- const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
2768
- // On win32, a Dirent-known link is not sufficient proof that readlink
2769
- // will succeed (notably for some reparse points). Never fall through to
2770
- // normal file/directory handling here: doing so would dereference the
2771
- // link and duplicate its target under this vault key.
2772
- const target = readlinkOrNull(absolutePath);
2773
- if (target === null) {
2774
- console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
2775
- hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
2776
- continue;
2777
- }
2778
- // The link is recorded but its (external) target is not descended, so
2779
- // any files under it are NOT uploaded. Report the subtree so a full
2780
- // `sync now` no longer drops it from every bucket without a trace.
2781
- reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
2782
- results.push({
2783
- kind: "symlink",
2784
- absolutePath,
2785
- relativePath: linkRelative,
2786
- target,
2787
- });
2788
- continue;
2789
- }
2790
-
2791
- // Pass the dir hint so dir-only ignore/include patterns (`foo/`)
2792
- // resolve correctly for the descent decision.
2793
- if (!filter(absolutePath, isDir)) continue;
2794
-
2795
- if (isDir) {
2796
- enterDirectory(absolutePath);
2797
- } else if (entry.isFile()) {
2798
- results.push({
2799
- kind: "file",
2800
- absolutePath,
2801
- relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
2802
- });
2803
- }
2804
- }
2805
- }
2806
-
2807
- function isWithin(parent: string, child: string): boolean {
2808
- // Canonicalize both ends so the comparison survives case-insensitive
2809
- // filesystems (macOS APFS, Windows NTFS): `path.relative('/Users/x/hq',
2810
- // '/Users/x/HQ/foo')` returns `'../HQ/foo'`, which would falsely report
2811
- // `child` as outside `parent`. `realpathSync.native` resolves to the
2812
- // on-disk canonical case so the relative path lands inside.
2813
- const parentCanon = realpathSafe(parent);
2814
- const childCanon = realpathSafe(child);
2815
- const rel = path.relative(parentCanon, childCanon);
2816
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2817
- }
2818
-
2819
- function isPathWithin(parent: string, child: string): boolean {
2820
- const rel = path.relative(parent, child);
2821
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2822
- }
2823
-
2824
- function realpathSafe(p: string): string {
2825
- try {
2826
- return fs.realpathSync.native(p);
2827
- } catch {
2828
- return p;
2829
- }
2830
- }
2831
-
2832
- function deepestExistingAncestor(start: string): string | null {
2833
- let current = start;
2834
- for (;;) {
2835
- try {
2836
- fs.lstatSync(current);
2837
- return current;
2838
- } catch (err: unknown) {
2839
- const code =
2840
- err && typeof err === "object" && "code" in err
2841
- ? (err as { code?: string }).code
2842
- : undefined;
2843
- if (code !== "ENOENT" && code !== "ENOTDIR") return null;
2844
- }
2845
-
2846
- const parent = path.dirname(current);
2847
- if (parent === current) return null;
2848
- current = parent;
2849
- }
2850
- }
2851
-
2852
- function isMaterializationPathStillContained(root: string, localPath: string): boolean {
2853
- const resolvedRoot = path.resolve(root);
2854
- const resolvedLocal = path.resolve(localPath);
2855
- if (!isPathWithin(resolvedRoot, resolvedLocal)) return false;
2856
-
2857
- let realRoot: string;
2858
- try {
2859
- realRoot = fs.realpathSync.native(resolvedRoot);
2860
- } catch {
2861
- return false;
2862
- }
2863
-
2864
- const existingAncestor = deepestExistingAncestor(path.dirname(resolvedLocal));
2865
- if (existingAncestor === null) return false;
2866
- try {
2867
- const realAncestor = fs.realpathSync.native(existingAncestor);
2868
- return isPathWithin(realRoot, realAncestor);
2869
- } catch {
2870
- return false;
2871
- }
2872
- }
2873
-
2874
- /**
2875
- * Containment check tailored for symlinks. Canonicalizes the link's
2876
- * PARENT DIR (which is a real dir, not the link), then compares the
2877
- * recombined `parentReal/basename(linkPath)` against `parent`. Skipping
2878
- * the link's own canonicalization means a symlink that points outside
2879
- * `parent` is still considered "inside" so long as the link file itself
2880
- * lives inside — which is exactly the topology we want to upload as a
2881
- * link record without dereferencing.
2882
- *
2883
- * Falls back to `parent` / `path.dirname(linkPath)` literally when
2884
- * realpath throws (e.g. permission denied on a parent), trading a tiny
2885
- * window of macOS-APFS case-sensitivity drift for the more common case
2886
- * of "link lives inside, target lives outside."
2887
- */
2888
- function isWithinForLink(parent: string, linkPath: string): boolean {
2889
- const parentReal = realpathSafe(parent);
2890
- const linkParentReal = realpathSafe(path.dirname(linkPath));
2891
- const candidate = path.join(linkParentReal, path.basename(linkPath));
2892
- const rel = path.relative(parentReal, candidate);
2893
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2894
- }
2895
-
2896
- /**
2897
- * Resolve each user-supplied share path to a directory under `syncRoot`,
2898
- * returning the company-relative prefix that constrains delete propagation.
2899
- * Files (non-directories) and paths outside the company root are dropped —
2900
- * a delete sweep keyed off a single file or a sibling tree would surprise
2901
- * users who expected deletes to mirror the targeted scope.
2902
- *
2903
- * Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
2904
- * itself; this is the bidirectional-runner case.
2905
- *
2906
- * Path spellings are resolved with `resolveNamedPath`, the same resolver the
2907
- * upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
2908
- * like its absolute equivalent instead of silently resolving nowhere and
2909
- * scoping nothing.
2910
- *
2911
- * Containment, however, deliberately stays on strict realpath `isWithin` and
2912
- * does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
2913
- * legs are asymmetric on purpose: the upload leg ships the files it was handed,
2914
- * while a delete scope is a PREFIX that authorizes removing every remote object
2915
- * beneath it. A linked subtree's contents are never walked (`walkDir` does not
2916
- * descend external directory symlinks), so anchoring a delete scope on one
2917
- * would compare an empty local walk against a populated remote prefix and sweep
2918
- * the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
2919
- * accepted cost is that deletes inside a linked subtree are not propagated,
2920
- * which matches the snapshot semantics `hq sync push` already documents for
2921
- * those paths.
2922
- */
2923
- function resolveDeleteScopeRoots(
2924
- paths: string[],
2925
- hqRoot: string,
2926
- syncRoot: string,
2927
- explicitRoots: readonly string[] = [],
2928
- ): string[] {
2929
- const prefixes = new Set<string>();
2930
- for (const root of explicitRoots) {
2931
- const normalized = root
2932
- .split("\\")
2933
- .join("/")
2934
- .replace(/^\.\/+/, "")
2935
- .replace(/\/+$/, "");
2936
- if (
2937
- normalized === "" ||
2938
- normalized === "." ||
2939
- path.posix.isAbsolute(normalized) ||
2940
- normalized === ".." ||
2941
- normalized.startsWith("../")
2942
- ) {
2943
- continue;
2944
- }
2945
- prefixes.add(normalized);
2946
- }
2947
- for (const p of paths) {
2948
- const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
2949
- if (!fs.existsSync(absolutePath)) continue;
2950
- if (!isWithin(syncRoot, absolutePath)) continue;
2951
- const stat = fs.statSync(absolutePath);
2952
- if (!stat.isDirectory()) continue;
2953
- const rel = vaultKeyForLocalPath(syncRoot, absolutePath);
2954
- if (rel === "" || rel === ".") {
2955
- return [""];
2956
- }
2957
- prefixes.add(rel);
2958
- }
2959
- return Array.from(prefixes);
2960
- }
2961
-
2962
- /**
2963
- * Reason a candidate was bucketed into `refusedStale`. Discriminated so
2964
- * consumers (UI, telemetry, the event logger) can branch on intent without
2965
- * string-comparing the placeholder etag value.
2966
- * - `"stale-etag"` → currency-gated saw a real etag mismatch (peer
2967
- * drift). `journalEtag` and `remoteEtag` are both
2968
- * real ETag values.
2969
- * - `"legacy-no-etag"` → journal entry was written before remoteEtag was
2970
- * tracked. `journalEtag` and `remoteEtag` are
2971
- * placeholder sentinels — do not display as ETags.
2972
- */
2973
- type RefusedStaleReason =
2974
- | "stale-etag"
2975
- | "legacy-no-etag"
2976
- | "bulk-asymmetry"
2977
- | "divergent-local"
2978
- | "missing-delete-intent"
2979
- | "intent-changed"
2980
- | "recreated-locally";
2981
-
2982
- /**
2983
- * Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
2984
- * fraction of the in-scope journal entries into remote `DeleteObject` calls.
2985
- *
2986
- * Failure mode this defends against: the local mirror is corrupt
2987
- * (moved hqRoot, partial restore from backup, fresh clone over an inherited
2988
- * `~/.hq/sync-journal.*.json`, unmounted external volume, accidental
2989
- * `rm -rf` of a populated subtree). `computeDeletePlan` walks every journal
2990
- * entry, lstats locally, and any ENOENT is a delete candidate; under
2991
- * `currency-gated` the per-file HEAD passes in the steady state (nobody
2992
- * else rewrote the object) so the delete proceeds. Per-file currency is
2993
- * orthogonal to whole-set health — when a large slice of the mirror is
2994
- * absent at once, the engine should refuse rather than amplify the
2995
- * accident into a mass-DELETE.
2996
- *
2997
- * The guard fires when **both** conditions hold:
2998
- * - `candidates / inScope >= BULK_ASYMMETRY_RATIO` (default 10%).
2999
- * - `candidates >= BULK_ASYMMETRY_MIN_ABS` (default 10).
3000
- *
3001
- * `candidates` is the whole prospective destructive set, whether or not each
3002
- * entry carries a current watcher delete-intent. A live watcher stamps one
3003
- * intent per descendant when a directory disappears, so intent presence says
3004
- * nothing about whether the disappearance was deliberate — see the numerator
3005
- * comment in `computeDeletePlan` and the 2026-07-30 incident.
3006
- *
3007
- * The MIN_ABS floor is what lets small intentional deletes through —
3008
- * `rm 1 file` from a 5-entry mirror is 20% but 1 absolute candidate; never
3009
- * tripped. The RATIO is what lets large intentional uploads-then-deletes
3010
- * through — a 10000-entry journal where 50 files were intentionally
3011
- * removed is 0.5%; never tripped.
3012
- *
3013
- * Bypass paths (both intentional opt-outs of safety):
3014
- * - Env `HQ_SYNC_DELETE_BULK_OVERRIDE=1|true|yes` (case-insensitive). The
3015
- * rollback knob for the rare legitimate mass-delete; symmetric to
3016
- * `HQ_SYNC_DELETE_POLICY` as a per-invocation policy override.
3017
- * - `propagateDeletePolicy: "all"`. The emergency-reconcile policy
3018
- * already opts out of safety gates (currency check, owned-only).
3019
- * This guard honors that explicit opt-out.
3020
- *
3021
- * `"owned-only"` and `"currency-gated"` both go through the guard. The
3022
- * `direction === "up"` filter in `owned-only` is no defense once a user
3023
- * has ever run full bidirectional sync (every uploaded file gets
3024
- * direction:'up') — see investigation report
3025
- * `workspace/reports/indigo-vault-mass-delete-debug.md`.
3026
- */
3027
- const BULK_ASYMMETRY_RATIO = 0.10;
3028
- const BULK_ASYMMETRY_MIN_ABS = 10;
3029
- const BULK_ASYMMETRY_SAMPLE_CAP = 10;
3030
-
3031
- function isBulkAsymmetryOverride(): boolean {
3032
- const v = (process.env.HQ_SYNC_DELETE_BULK_OVERRIDE ?? "").toLowerCase();
3033
- return v === "1" || v === "true" || v === "yes";
3034
- }
3035
-
3036
- /**
3037
- * Three buckets returned by computeDeletePlan, exposed so the execution
3038
- * loop can take a different action for each:
3039
- * - `toDelete` → issue DeleteObject + drop journal entry.
3040
- * - `toTombstone` → no DeleteObject (remote already 404), drop journal
3041
- * entry. Lets the journal converge with reality even
3042
- * when the remote was cleaned out-of-band.
3043
- * - `refusedStale` → no DeleteObject, no journal change. Some other
3044
- * device modified the remote object since this device
3045
- * last synced it; the next pull leg re-pulls via the
3046
- * same `hasRemoteChanged` path the conflict detector
3047
- * uses. Emitted as `delete-refused-stale-etag` events.
3048
- */
3049
- interface DeletePlan {
3050
- toDelete: Array<{ key: string; intentVersion: 1 | null }>;
3051
- toTombstone: string[];
3052
- refusedStale: Array<{
3053
- key: string;
3054
- journalEtag: string;
3055
- remoteEtag: string;
3056
- reason: RefusedStaleReason;
3057
- }>;
3058
- /**
3059
- * Populated only when the bulk-asymmetry circuit-breaker tripped. The
3060
- * caller emits `delete-refused-bulk-asymmetry` as a one-shot summary so
3061
- * the UI gets one banner-grade signal in addition to the per-key
3062
- * `delete-refused-stale-etag` events under `refusedStale`.
3063
- */
3064
- bulkAsymmetry?: {
3065
- candidates: number;
3066
- inScope: number;
3067
- ratio: number;
3068
- samplePaths: string[];
3069
- };
3070
- }
3071
-
3072
- function hasCurrentLocalDeleteIntent(
3073
- entry: SyncJournal["files"][string] | undefined,
3074
- ): boolean {
3075
- const intent = entry?.localDeleteIntent;
3076
- return !!(
3077
- intent &&
3078
- intent.version === 1 &&
3079
- entry.remoteEtag &&
3080
- entry.kind &&
3081
- intent.remoteEtag === entry.remoteEtag &&
3082
- intent.localHash === entry.hash &&
3083
- intent.localKind === entry.kind
3084
- );
3085
- }
3086
-
3087
- function isLocallyAbsent(localPath: string): boolean {
3088
- try {
3089
- fs.lstatSync(localPath);
3090
- return false;
3091
- } catch (err: unknown) {
3092
- const code =
3093
- err && typeof err === "object" && "code" in err
3094
- ? (err as { code?: string }).code
3095
- : undefined;
3096
- return code === "ENOENT";
3097
- }
3098
- }
3099
-
3100
- /**
3101
- * Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
3102
- * HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
3103
- * case that's 15-50s of latency. 16-way concurrency keeps S3 well within
3104
- * per-prefix burst limits (~3,500 GET/HEAD/sec/prefix is the documented
3105
- * floor) and bounded under the AWS-SDK default agent's max-sockets so we
3106
- * don't compete with the in-flight upload pool.
3107
- */
3108
- const DELETE_PLAN_HEAD_CONCURRENCY = 16;
3109
-
3110
- /**
3111
- * Walk every journal key in `scopeRoots` whose local file is missing from
3112
- * disk and bucket each candidate into the right action per `policy`. Hard
3113
- * filters that drop a candidate entirely (no bucket) — regardless of policy:
3114
- *
3115
- * 1. Its key must match (or sit beneath) one of the `scopeRoots` prefixes.
3116
- * 2. Its local file must be missing from disk (lstat ENOENT). We use
3117
- * `lstat` (not `existsSync`) so a dangling symlink — a link whose
3118
- * target has been removed but whose link file is still on disk —
3119
- * counts as "still present locally" and is NOT delete-propagated.
3120
- * Pre-fix, existsSync followed the link, returned false, and the
3121
- * entry was queued for remote DeleteObject in the same sync that
3122
- * had just uploaded it via `uploadSymlink` — the link round-tripped
3123
- * as "upload, then delete" in one cycle. ENOENT means truly absent
3124
- * → eligible; other lstat errors propagate.
3125
- * 3. The current ignore filter (`shouldSync`) accepts the key — paths
3126
- * filtered out by `.hqignore` / `.gitignore` / `DEFAULT_IGNORES` are
3127
- * never delete-propagated. Closes the failure mode where a path lives
3128
- * in the vault (and journal) but the local walk skips it because of
3129
- * asymmetric ignore rules.
3130
- *
3131
- * Dual-hint probe: by the time we're considering this entry for
3132
- * remote deletion, the local file is already gone — we have no way to
3133
- * know whether it was a regular file or a symlink record. A single
3134
- * `isDir=false` probe would silently keep the remote record alive
3135
- * whenever the only matching `.hqinclude` allowlist pattern is dir-
3136
- * only (e.g. `companies/*\/knowledge/`), since gitignore's slash
3137
- * semantics reject the slashless probe. The same dual-hint pattern in
3138
- * `walkDir`/`collectFiles` (push) and `computePullPlan` (pull) applies
3139
- * symmetrically here. Pure path lookup, no I/O.
3140
- * 4. The key does NOT match `EPHEMERAL_PATH_PATTERN`. Conflict mirrors
3141
- * are local-only artifacts that should never have been journaled in
3142
- * the first place; the dedicated reconcile command sweeps already-
3143
- * journaled mirrors. Excluding them here keeps a regular `sync now`
3144
- * from accidentally deleting a mirror another device is still
3145
- * reviewing.
3146
- *
3147
- * Then per-policy bucketing:
3148
- *
3149
- * - `"currency-gated"` (default, safest): issue a HEAD against the remote.
3150
- * 200 + `normalizeEtag(remote) === entry.remoteEtag` → `toDelete`.
3151
- * 200 + mismatch → `refusedStale` (peer drift; let pull re-pull).
3152
- * 404 → `toTombstone` (remote was cleaned out-of-band).
3153
- * If the journal entry has no recorded `remoteEtag` (legacy entries
3154
- * written before etag tracking), the candidate falls back to
3155
- * `refusedStale` with `reason: "legacy-no-etag"` — we can't prove
3156
- * currency without an etag, so refusal is the safe direction. The
3157
- * journal entry survives so a future sync with a recorded etag can
3158
- * re-evaluate.
3159
- *
3160
- * HEAD calls are batched at `DELETE_PLAN_HEAD_CONCURRENCY` so a large
3161
- * candidate set (e.g. a one-shot reconcile sweep) doesn't serialize
3162
- * into N×RTT latency. The candidate set is materialized into a list
3163
- * first (synchronous filters above), then the HEAD pass runs in
3164
- * bounded-parallel chunks.
3165
- *
3166
- * Note: there is a TOCTOU window between this HEAD and the eventual
3167
- * `deleteRemoteFile` call in the share() execution loop. If a peer
3168
- * overwrites the object in that window (~50-200ms), the resulting
3169
- * delete-marker lands on a newer version than we verified. S3
3170
- * versioning makes the worst case recoverable (prior versions are
3171
- * retained), and the conditional-delete primitive does not exist on
3172
- * S3 DeleteObject — only PutObject/CopyObject accept `IfMatch`. The
3173
- * window is bounded, not zero. Realtime sync (separate work) reduces
3174
- * it further by keeping the journal continuously fresh.
3175
- * - `"owned-only"`: include only entries with `direction === "up"`. No
3176
- * HEAD round-trip. Goes to `toDelete`. Legacy fallback.
3177
- * - `"all"`: include every candidate. No HEAD, no direction check. Goes
3178
- * to `toDelete`. Caller has explicitly opted out of safety gates.
3179
- *
3180
- * Empty `scopeRoots` ⇒ empty plan (caller didn't opt in).
3181
- */
3182
- async function computeDeletePlan(
3183
- journal: SyncJournal,
3184
- syncRoot: string,
3185
- scopeRoots: string[],
3186
- shouldSync: (filePath: string, isDir?: boolean) => boolean,
3187
- policy: "currency-gated" | "owned-only" | "all",
3188
- ctx: EntityContext,
3189
- // True for a COMPANY-scoped push (personalMode !== true): the sync root is
3190
- // the company folder and journal keys are bucket-relative, so a literal
3191
- // `companies/…` key is scope-invalid (see the poisoned-key branch below).
3192
- // Personal-vault pushes legitimately journal `companies/{slug}/…` keys.
3193
- companyScoped: boolean = true,
3194
- ): Promise<DeletePlan> {
3195
- const plan: DeletePlan = { toDelete: [], toTombstone: [], refusedStale: [] };
3196
- if (scopeRoots.length === 0) return plan;
3197
-
3198
- // Stage 1: synchronous pre-filter. Walk every journal entry and either
3199
- // drop it (hard filter), assign it directly to a bucket (owned-only /
3200
- // all), or queue it for HEAD (currency-gated). Keeping this synchronous
3201
- // means the HEAD pass below sees a single, deduplicated candidate list
3202
- // and the journal-mutation buckets are already settled before any I/O.
3203
- type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 | null };
3204
- const headCandidates: HeadCandidate[] = [];
3205
- // Litter drain bucket — kept separate from `plan.toDelete` so the
3206
- // bulk-asymmetry breaker (which moves toDelete + headCandidates into
3207
- // `refusedStale` when it trips) can't sweep these out. See
3208
- // `isVaultLitterArtifact` for the patterns and rationale: by construction
3209
- // these aren't user content losses, so a high ratio of litter must not
3210
- // refuse the drain — that's the whole point of the bypass. Merged into
3211
- // `plan.toDelete` after the breaker check.
3212
- const litterToDelete: string[] = [];
3213
- // Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
3214
- // and every entry that would have been a delete-candidate before the guard
3215
- // (numerator). The numerator is the WHOLE prospective destructive set:
3216
- // intent-backed picks (owned-only/all toDelete, currency-gated
3217
- // headCandidates, legacy-no-etag refusals) AND intent-less disappearances.
3218
- //
3219
- // 6.14.19 (#227) narrowed it to intent-less disappearances only, on the
3220
- // premise that a current watcher intent is affirmative evidence of a
3221
- // deliberate delete. A wiped, swapped, unmounted or partially-restored tree
3222
- // observed by a live watcher mints an intent per descendant, so that premise
3223
- // does not hold and the breaker stopped covering the exact failure mode it
3224
- // exists for (incident 2026-07-30: 1,108 intent-backed deletions in one
3225
- // push). Intent currency is a per-file property; the breaker is a whole-set
3226
- // health check, and the two must not be conflated.
3227
- //
3228
- // We do NOT count "ENOENT but ignore-filtered" or "ENOENT but ephemeral" —
3229
- // those drop out of the plan entirely on their own and don't reflect
3230
- // mirror-loss intent — nor litter drains (see above), which are intentional
3231
- // ratchet-cleanup, not mass-delete intent.
3232
- let inScopeJournalEntries = 0;
3233
- let bulkCandidatePicks = 0;
3234
- // Intent-less candidates already parked in `refusedStale`. On a trip they are
3235
- // re-labelled `bulk-asymmetry` in place rather than refused twice.
3236
- const intentlessKeys = new Set<string>();
3237
-
3238
- for (const [relativeKey, entry] of Object.entries(journal.files)) {
3239
- const inScope = scopeRoots.some(
3240
- (root) =>
3241
- root === "" ||
3242
- relativeKey === root ||
3243
- relativeKey.startsWith(`${root}/`),
3244
- );
3245
- if (!inScope) continue;
3246
-
3247
- // A tombstoned entry has ALREADY had its disposition decided — by a scope
3248
- // shrink, `hq sync narrow --apply`, a manual prune, or a pull-side
3249
- // intentional-delete record. It must never be re-evaluated as a fresh
3250
- // delete candidate.
3251
- //
3252
- // This matters most for `all` -> `shared`: `tombstoneEntry` deliberately
3253
- // KEEPS `hash`, `direction` and `remoteEtag` (so a recovery flow can see
3254
- // what was there), and the shrink removes the file locally. Every one of
3255
- // those entries therefore presents as locally-absent, intent-less and
3256
- // etag-current — which, now that authorization is etag-only, is an
3257
- // instruction to delete another member's files out of the shared company
3258
- // vault, for the crime of having narrowed one's own sync scope.
3259
- //
3260
- // Until now the ONLY thing standing in the way was `shouldSync` happening
3261
- // to carry a `prefixSet` (see `createPushRunContext`); any run that
3262
- // resolved no prefix set — a degraded scope lookup, a caller that does not
3263
- // set one — would have walked straight through. That is far too load-
3264
- // bearing for an implicit dependency, so the invariant is stated here
3265
- // directly.
3266
- //
3267
- // `local-delete` is the ONE reason that must pass: the pull leg stamps it
3268
- // as a deliberate producer hand-off ("the user removed this; push leg,
3269
- // finish the job"), and skipping it would strand exactly the deletes this
3270
- // change exists to enable. Every other reason — scope_shrink,
3271
- // narrow_apply, manual, or an absent/unrecognised one — means "stop
3272
- // tracking, do NOT touch the remote", so the test is written fail-closed:
3273
- // only the explicit hand-off is let through.
3274
- //
3275
- // Skipped BEFORE `inScopeJournalEntries++` so these inflate neither the
3276
- // numerator nor the denominator of the bulk-asymmetry ratio. Excluding
3277
- // them from the denominator can only make the breaker MORE likely to trip,
3278
- // which is the safe direction.
3279
- if (isTombstone(entry) && entry.removedReason !== "local-delete") continue;
3280
-
3281
- inScopeJournalEntries++;
3282
- const localPath = localPathForVaultKey(syncRoot, relativeKey);
3283
-
3284
- // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
3285
- // context, a journal entry at a literal `companies/…` key records a
3286
- // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
3287
- // presign transport is rejected by the server validator
3288
- // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
3289
- // straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
3290
- // remote call). Personal-vault pushes (personalMode) carry legitimate
3291
- // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
3292
- // Checked before the presentLocally gate: the poison file lives at the
3293
- // doubled path and must drain even while still on disk.
3294
- if (companyScoped && relativeKey.startsWith("companies/")) {
3295
- plan.toTombstone.push(relativeKey);
3296
- continue;
3297
- }
3298
-
3299
- let presentLocally = true;
3300
- try {
3301
- fs.lstatSync(localPath);
3302
- } catch (err: unknown) {
3303
- if (
3304
- err &&
3305
- typeof err === "object" &&
3306
- "code" in err &&
3307
- (err as { code?: string }).code === "ENOENT"
3308
- ) {
3309
- presentLocally = false;
3310
- } else {
3311
- throw err;
3312
- }
3313
- }
3314
- if (presentLocally) continue;
3315
-
3316
- if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
3317
- continue;
3318
- }
3319
-
3320
- // Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
3321
- // ALWAYS drain, bypassing `shouldSync` (which would skip them when the
3322
- // parent path is in personal-vault default exclusions like
3323
- // `.obsidian/**`), the `isEphemeralPath` skip (which protects FRESH
3324
- // local conflict mirrors but accidentally also protects existing vault
3325
- // litter), the policy gate (no meaningful ownership/freshness for
3326
- // litter), and the bulk-asymmetry breaker (intentional ratchet-drain,
3327
- // not corrupt-mirror mass-delete intent). See `isVaultLitterArtifact`
3328
- // for the full rationale and patterns. Queued via the separate
3329
- // `litterToDelete` bucket so the breaker can't sweep these out.
3330
- if (isVaultLitterArtifact(relativeKey)) {
3331
- litterToDelete.push(relativeKey);
3332
- continue;
3333
- }
3334
-
3335
- if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
3336
- // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
3337
- // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
3338
- // post-litter-drain above — any key matching EPHEMERAL_PATH_PATTERN was
3339
- // already routed to `litterToDelete`. Retained as defense-in-depth +
3340
- // documentation of the policy-side intent.
3341
- if (isEphemeralPath(relativeKey)) continue;
3342
-
3343
- // Journal-honesty guard: an entry flagged `localDiverges` recorded the
3344
- // remote etag only to silence a re-fired conflict (#137) — the local copy
3345
- // NEVER matched that remote. Its currency would falsely "match" on HEAD, so
3346
- // propagating a delete would destroy the divergent remote version. Refuse it
3347
- // regardless of policy, and do NOT count it toward the bulk-asymmetry ratio
3348
- // (it isn't a genuine mirror-loss candidate). Cleared by a real download.
3349
- if (entry.localDiverges) {
3350
- plan.refusedStale.push({
3351
- key: relativeKey,
3352
- journalEtag: entry.remoteEtag ?? "<divergent-local>",
3353
- remoteEtag: "<not-checked>",
3354
- reason: "divergent-local",
3355
- });
3356
- continue;
3357
- }
3358
-
3359
- if (!hasCurrentLocalDeleteIntent(entry)) {
3360
- // A delete intent is minted ONLY by a live watcher, at the instant it
3361
- // observes the unlink. Three of the four ways HQ syncs never construct a
3362
- // watcher at all — outpost/agent boxes run a one-shot runner, `hq sync
3363
- // now` is one-shot, and the desktop app drops `--event-push` (and with it
3364
- // the whole TreeWatcher) whenever Instant-sync is off. On those surfaces
3365
- // an intent can never exist, so requiring one made deletes structurally
3366
- // impossible rather than merely gated. Files deleted while no watcher was
3367
- // running also stranded permanently: the file is already gone, so no
3368
- // watcher can ever retroactively witness it.
3369
- //
3370
- // `currency-gated` therefore authorizes on ETag currency alone. The
3371
- // per-file question it answers is unchanged — "is the remote object still
3372
- // exactly what this journal recorded, i.e. has no peer touched it since"
3373
- // — and a mismatch still refuses (`stale-etag`) so a peer's newer work is
3374
- // never destroyed.
3375
- //
3376
- // The whole-set question ("does my local mirror look catastrophically
3377
- // empty") is NOT answered by the ETag, and must not be conflated with it:
3378
- // in the steady state nobody has overwritten anything, so a moved hqRoot,
3379
- // unmounted volume, fresh clone or partial restore presents mass-ENOENT
3380
- // with perfectly matching ETags. That is exactly how the 2026-05-25
3381
- // indigo vault mass-delete destroyed 559 objects. The bulk-asymmetry
3382
- // breaker below is the guard for that class and is deliberately left
3383
- // intact — these candidates are counted into `bulkCandidatePicks` on the
3384
- // SAME line as intent-backed ones, so relaxing the per-file gate cannot
3385
- // weaken the whole-set one. See policy `sync-delete-bulk-asymmetry-guard`
3386
- // (hard) and workspace/reports/indigo-vault-mass-delete-debug.md.
3387
- //
3388
- // `owned-only` keeps requiring an intent: it is the documented rollback
3389
- // knob, and an operator who selects it is asking for the stricter path.
3390
- if (policy === "owned-only") {
3391
- // Still counted into the breaker exactly as before: an owned-only run
3392
- // whose mirror has gone missing must trip the whole-set guard even
3393
- // though each individual key is refused for want of an intent.
3394
- if (entry.direction === "up") {
3395
- bulkCandidatePicks++;
3396
- intentlessKeys.add(relativeKey);
3397
- }
3398
- plan.refusedStale.push({
3399
- key: relativeKey,
3400
- journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
3401
- remoteEtag: "<not-checked>",
3402
- reason: "missing-delete-intent",
3403
- });
3404
- continue;
3405
- }
3406
- if (policy === "currency-gated") {
3407
- bulkCandidatePicks++;
3408
- intentlessKeys.add(relativeKey);
3409
- // No etag on record ⇒ nothing to compare ⇒ no authorization. Refused
3410
- // for the same reason an intent-backed legacy entry is (below).
3411
- if (!entry.remoteEtag) {
3412
- plan.refusedStale.push({
3413
- key: relativeKey,
3414
- journalEtag: "<legacy-no-etag>",
3415
- remoteEtag: "<unknown>",
3416
- reason: "legacy-no-etag",
3417
- });
3418
- continue;
3419
- }
3420
- headCandidates.push({
3421
- key: relativeKey,
3422
- journalEtag: entry.remoteEtag,
3423
- intentVersion: null,
3424
- });
3425
- continue;
3426
- }
3427
- // policy === "all" is deliberately NOT relaxed here. It already skips the
3428
- // bulk-asymmetry breaker, so letting it skip the intent gate too would
3429
- // make it a wholly unguarded mass delete — a strictly larger blast radius
3430
- // than this change is scoped to. It keeps refusing intent-less entries
3431
- // exactly as before.
3432
- plan.refusedStale.push({
3433
- key: relativeKey,
3434
- journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
3435
- remoteEtag: "<not-checked>",
3436
- reason: "missing-delete-intent",
3437
- });
3438
- continue;
3439
- }
3440
-
3441
- bulkCandidatePicks++;
3442
- if (policy === "all") {
3443
- // policy:"all" is the explicit-opt-out emergency-reconcile mode; the
3444
- // bulk-asymmetry guard skips this branch (caller asserted intent).
3445
- plan.toDelete.push({
3446
- key: relativeKey,
3447
- intentVersion: entry.localDeleteIntent!.version,
3448
- });
3449
- continue;
3450
- }
3451
- if (policy === "owned-only") {
3452
- if (entry.direction !== "up") {
3453
- // Not a delete candidate under owned-only, but it WAS missing
3454
- // locally. Don't count it for the bulk guard — direction:'down'
3455
- // entries that vanish locally are silently ignored by this policy
3456
- // anyway, so they don't represent intent to mass-delete.
3457
- bulkCandidatePicks--;
3458
- continue;
3459
- }
3460
- plan.toDelete.push({
3461
- key: relativeKey,
3462
- intentVersion: entry.localDeleteIntent!.version,
3463
- });
3464
- continue;
3465
- }
3466
- // currency-gated: queue for HEAD unless the entry is legacy (no etag).
3467
- const journalEtag = entry.remoteEtag;
3468
- if (!journalEtag) {
3469
- plan.refusedStale.push({
3470
- key: relativeKey,
3471
- journalEtag: "<legacy-no-etag>",
3472
- remoteEtag: "<unknown>",
3473
- reason: "legacy-no-etag",
3474
- });
3475
- continue;
3476
- }
3477
- headCandidates.push({
3478
- key: relativeKey,
3479
- journalEtag,
3480
- intentVersion: entry.localDeleteIntent!.version,
3481
- });
3482
- }
3483
-
3484
- // Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
3485
- // rationale + bypass paths. Skipped under policy:"all" (caller asserted
3486
- // intent) and under `HQ_SYNC_DELETE_BULK_OVERRIDE` (operator rollback knob).
3487
- if (
3488
- policy !== "all" &&
3489
- !isBulkAsymmetryOverride() &&
3490
- bulkCandidatePicks >= BULK_ASYMMETRY_MIN_ABS &&
3491
- inScopeJournalEntries > 0 &&
3492
- bulkCandidatePicks / inScopeJournalEntries >= BULK_ASYMMETRY_RATIO
3493
- ) {
3494
- // Move every staged candidate (both already-bucketed toDelete from
3495
- // owned-only and queued headCandidates from currency-gated) into
3496
- // refusedStale with reason "bulk-asymmetry", and re-label the intent-less
3497
- // entries already parked there. Journal is not mutated; no DeleteObject is
3498
- // issued; the Stage-2 HEAD pass never runs.
3499
- const samplePaths: string[] = [];
3500
- const noteSample = (key: string): void => {
3501
- if (samplePaths.length < BULK_ASYMMETRY_SAMPLE_CAP) samplePaths.push(key);
3502
- };
3503
- for (const refused of plan.refusedStale) {
3504
- if (!intentlessKeys.has(refused.key)) continue;
3505
- refused.journalEtag = "<bulk-asymmetry>";
3506
- refused.remoteEtag = "<not-checked>";
3507
- refused.reason = "bulk-asymmetry";
3508
- noteSample(refused.key);
3509
- }
3510
- const pushRefused = (key: string): void => {
3511
- plan.refusedStale.push({
3512
- key,
3513
- journalEtag: "<bulk-asymmetry>",
3514
- remoteEtag: "<not-checked>",
3515
- reason: "bulk-asymmetry",
3516
- });
3517
- noteSample(key);
3518
- };
3519
- for (const item of plan.toDelete) pushRefused(item.key);
3520
- for (const c of headCandidates) pushRefused(c.key);
3521
- // Emptying the delete list is what makes the trip a refusal rather than a
3522
- // label. #227 dropped this line, so a tripped breaker stopped nothing it
3523
- // had not already refused for another reason.
3524
- plan.toDelete = [];
3525
- plan.bulkAsymmetry = {
3526
- candidates: bulkCandidatePicks,
3527
- inScope: inScopeJournalEntries,
3528
- ratio: bulkCandidatePicks / inScopeJournalEntries,
3529
- samplePaths,
3530
- };
3531
- // Litter still drains even when the breaker trips — see `litterToDelete`
3532
- // declaration. The breaker protects against corrupt-mirror mass-deletes
3533
- // of user content; litter cleanup is orthogonal and should never be
3534
- // refused for the same reason new-litter producer-side exclusions
3535
- // shouldn't block it.
3536
- plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
3537
- return plan;
3538
- }
3539
-
3540
- // Stage 2: bounded-parallel HEAD pass. Promise.all over chunks of size
3541
- // `DELETE_PLAN_HEAD_CONCURRENCY` so a large candidate set doesn't
3542
- // serialize into N round-trips, and so we don't burst past the AWS-SDK
3543
- // default agent's per-host socket cap. Each result is bucketed
3544
- // independently — one failed HEAD doesn't poison the others (errors
3545
- // propagate from the chunk's Promise.all and are surfaced by share()'s
3546
- // outer try/catch, mirroring the existing pre-share error handling).
3547
- // Warm the GET presigns the HEAD pass reuses so a large candidate set doesn't
3548
- // mint one presign per HEAD and trip the presign breaker. Mirrors the
3549
- // new-files + tombstone HEAD-pass pre-primes.
3550
- await primeObjectTransport(
3551
- ctx,
3552
- "get",
3553
- headCandidates.map((c) => c.key),
3554
- );
3555
- for (let i = 0; i < headCandidates.length; i += DELETE_PLAN_HEAD_CONCURRENCY) {
3556
- const chunk = headCandidates.slice(i, i + DELETE_PLAN_HEAD_CONCURRENCY);
3557
- const results = await Promise.all(
3558
- chunk.map(async (c) => {
3559
- try {
3560
- return {
3561
- candidate: c,
3562
- remote: await headRemoteFile(ctx, c.key),
3563
- scopeExcluded: false,
3564
- };
3565
- } catch (err) {
3566
- if (isAccessDenied(err)) {
3567
- return { candidate: c, remote: null, scopeExcluded: true };
3568
- }
3569
- throw err;
3570
- }
3571
- }),
3572
- );
3573
- for (const { candidate, remote, scopeExcluded } of results) {
3574
- if (scopeExcluded) {
3575
- continue;
3576
- }
3577
- if (remote === null) {
3578
- plan.toTombstone.push(candidate.key);
3579
- continue;
3580
- }
3581
- const currentEtag = normalizeEtag(remote.etag);
3582
- if (currentEtag === candidate.journalEtag) {
3583
- plan.toDelete.push({
3584
- key: candidate.key,
3585
- intentVersion: candidate.intentVersion,
3586
- });
3587
- } else {
3588
- plan.refusedStale.push({
3589
- key: candidate.key,
3590
- journalEtag: candidate.journalEtag,
3591
- remoteEtag: currentEtag,
3592
- reason: "stale-etag",
3593
- });
3594
- }
3595
- }
3596
- }
3597
-
3598
- // Litter drains alongside the normal candidates — bypasses every gate but
3599
- // settles into the same plan.toDelete bucket so the share() executor's
3600
- // delete loop tombstones each key identically (DeleteObject + remove from
3601
- // journal). Merged at the end so the bulk-asymmetry breaker above had its
3602
- // chance to NOT include litter in either the numerator or the sweep.
3603
- for (const key of litterToDelete) {
3604
- plan.toDelete.push({ key, intentVersion: null });
3605
- }
3606
-
3607
- return plan;
3608
- }
3609
-
3610
- /**
3611
- * Walk every journal key that matches one of the supplied `prefixes` and
3612
- * return the keys eligible for unconditional remote `DeleteObject`. Unlike
3613
- * `computeDeletePlan` this DOES NOT check whether the local file is missing
3614
- * — the caller has asserted that these keys no longer belong in this
3615
- * bucket regardless of local state (typically a company that was promoted
3616
- * from personal-bucket fallback to its own team bucket).
3617
- *
3618
- * An entry is in the plan only when ALL of the following hold:
3619
- *
3620
- * 1. Its key matches (or sits beneath) one of the `prefixes`. The match
3621
- * is exact OR `key.startsWith(prefix + "/")` — same semantics as
3622
- * `computeDeletePlan`'s scope-root check.
3623
- * 2. When `policy === "owned-only"`: the journal entry's `direction`
3624
- * is `"up"` (this machine previously uploaded the file). Mirrors
3625
- * `computeDeletePlan`'s safety property — a misconfigured prefix
3626
- * list can never erase content pulled from elsewhere.
3627
- * 3. The key is NOT already in `alreadyPlanned` (the standard delete
3628
- * plan), so a single DeleteObject + journal-update pass processes
3629
- * each key once.
3630
- *
3631
- * Empty `prefixes` ⇒ empty plan (caller didn't opt in).
3632
- */
3633
- function computeDecommissionPlan(
3634
- journal: SyncJournal,
3635
- prefixes: string[],
3636
- policy: "currency-gated" | "owned-only" | "all",
3637
- alreadyPlanned: ReadonlySet<string>,
3638
- ): string[] {
3639
- if (prefixes.length === 0) return [];
3640
- // `currency-gated` exists to gate `propagateDeletes` on per-file proof of
3641
- // currency (HEAD compare). Decommission is unconditional by design — we
3642
- // don't need a HEAD check to know whether the key belongs here, the
3643
- // caller has asserted it doesn't. So for the direction-of-origin safety
3644
- // gate we collapse `currency-gated` to the `owned-only` behavior:
3645
- // peer-written entries (direction:'down') are skipped. That's the
3646
- // conservative choice — a peer who wrote into this bucket should
3647
- // reconcile their own entry, not have us blast it away on their behalf.
3648
- const requireOwned = policy === "owned-only" || policy === "currency-gated";
3649
- const out: string[] = [];
3650
- for (const [relativeKey, entry] of Object.entries(journal.files)) {
3651
- if (alreadyPlanned.has(relativeKey)) continue;
3652
- const matches = prefixes.some(
3653
- (prefix) =>
3654
- prefix === "" ||
3655
- relativeKey === prefix ||
3656
- relativeKey.startsWith(`${prefix}/`),
3657
- );
3658
- if (!matches) continue;
3659
- if (requireOwned && entry.direction !== "up") continue;
3660
- out.push(relativeKey);
3661
- }
3662
- return out;
3663
- }