@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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 +16 -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 +165 -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 +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -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 +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -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/journal.ts DELETED
@@ -1,765 +0,0 @@
1
- /**
2
- * Sync journal — tracks per-file state (hash, size, last-synced direction) so
3
- * sync/share can detect local edits that would be clobbered by a blind pull.
4
- *
5
- * ADR-0001 Phase 5: the journal is sharded by company slug and lives in
6
- * `~/.hq/`, not inside the HQ content root. One monolithic journal per HQ
7
- * install conflates state across companies and forces every runner to
8
- * serialize through the same file — splitting it lets `hq-sync-runner
9
- * --companies` fan out without contention, and a corrupted shard only affects
10
- * one company.
11
- *
12
- * Path: `{stateDir}/sync-journal.{slug}.json`, where `stateDir` resolves to
13
- * `HQ_STATE_DIR` (if set) or `~/.hq`.
14
- */
15
-
16
- import * as fs from "fs";
17
- import * as os from "os";
18
- import * as path from "path";
19
- import * as crypto from "crypto";
20
- import type { SyncJournal, JournalEntry, PullRecord, V3JournalState, V3JournalWalPayload } from "./types.js";
21
- import { toPosixKey } from "./s3.js";
22
- import { StateStore } from "./sync/state-store.js";
23
-
24
- /** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
25
- export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
26
-
27
- /** Current journal schema version written by all v2-aware writers. */
28
- export const JOURNAL_VERSION_CURRENT = "2" as const;
29
-
30
- /**
31
- * Retain bounded pull history per company. Scope-shrink logic only needs the
32
- * newest record, but a small tail keeps diagnostics useful without letting
33
- * long-running sync loops grow journals forever.
34
- */
35
- export const MAX_PULLS_PER_COMPANY = 50;
36
-
37
- const JOURNAL_FILE_PREFIX = "sync-journal.";
38
- const JOURNAL_FILE_SUFFIX = ".json";
39
- const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
40
- const JOURNAL_WAL_RECORD_TYPE = 1;
41
-
42
-
43
- /**
44
- * Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
45
- * non-standard installs; otherwise falls back to `~/.hq`.
46
- */
47
- export function getStateDir(): string {
48
- return process.env.HQ_STATE_DIR ?? path.join(os.homedir(), ".hq");
49
- }
50
-
51
- /**
52
- * Filename-safe form of a slug. Slugs from vault-service are already
53
- * URL-safe, but this guards against paths, dots, or anything the filesystem
54
- * might interpret. Empty-or-invalid slugs throw rather than silently writing
55
- * to a shared "sync-journal..json" file.
56
- */
57
- function sanitizeSlug(slug: string): string {
58
- if (!slug) {
59
- throw new Error("journal: slug is required (empty or undefined)");
60
- }
61
- const cleaned = slug.replace(/[^a-zA-Z0-9_-]/g, "_");
62
- if (!cleaned || /^[_-]+$/.test(cleaned)) {
63
- throw new Error(`journal: slug "${slug}" sanitizes to an empty identifier`);
64
- }
65
- return cleaned;
66
- }
67
-
68
- export function getJournalPath(slug: string): string {
69
- return path.join(
70
- getStateDir(),
71
- `${JOURNAL_FILE_PREFIX}${sanitizeSlug(slug)}${JOURNAL_FILE_SUFFIX}`,
72
- );
73
- }
74
-
75
- /**
76
- * Reserved journal slug for the personal-vault fanout slot in the `--companies`
77
- * runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`,
78
- * `personal/`, …) and journals hq-root-relative keys; its `syncRoot` is the HQ
79
- * root itself.
80
- *
81
- * It MUST NOT share a journal with any real cloud company. Previously the slot
82
- * used the literal slug `"personal"`, which collided with the
83
- * `companies/personal` company (whose entity slug is also `"personal"`). The
84
- * two targets have different sync roots, so the company's whole-tree
85
- * `computeDeletePlan` walked the shared `sync-journal.personal.json`, resolved
86
- * the vault's hq-root keys against `hqRoot/companies/personal` (where they
87
- * don't exist), tombstoned them as "remote already 404", and dropped them from
88
- * the journal — only for the vault slot to re-upload them next cycle. ~190
89
- * `.claude/skills/*` files churned every sync.
90
- *
91
- * This sentinel value can never be produced by a real company slug from the
92
- * entity service (which yields URL-safe lowercase slugs without leading
93
- * underscores), and it survives `sanitizeSlug` unchanged (only `[a-zA-Z0-9_-]`
94
- * chars; the embedded letters keep it off the all-`[_-]` reject path).
95
- */
96
- export const PERSONAL_VAULT_JOURNAL_SLUG = "__hq_personal_vault__";
97
-
98
- /**
99
- * One-time seed migration for the personal-vault journal slug.
100
- *
101
- * Before this fix the personal-vault slot journaled under the slug
102
- * `"personal"`. After the fix it journals under
103
- * `PERSONAL_VAULT_JOURNAL_SLUG`. Without a seed, the first run under the new
104
- * slug would start from an empty journal and re-upload the entire HQ overlay.
105
- *
106
- * To avoid that mass re-upload, this copies the legacy `sync-journal.personal.json`
107
- * to `sync-journal.__hq_personal_vault__.json` exactly once: only when the new
108
- * file does NOT exist and the legacy file DOES. Idempotent — a no-op when the
109
- * new file already exists or the legacy file is absent.
110
- *
111
- * The legacy `personal` journal is left untouched (it is still the journal for
112
- * the real `companies/personal` company). After the seed, both journals
113
- * converge after one cleanup cycle: the legacy `personal` journal tombstones
114
- * the now-foreign hq-root keys once; the new vault journal tombstones any
115
- * companies/personal-relative keys once. That single convergence pass is
116
- * expected and harmless.
117
- */
118
- export function migratePersonalVaultJournal(): void {
119
- const newPath = getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG);
120
- if (fs.existsSync(newPath)) return;
121
- const legacyPath = getJournalPath("personal");
122
- if (!fs.existsSync(legacyPath)) return;
123
- const legacy = readJournal("personal");
124
- writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, legacy);
125
- }
126
-
127
- /**
128
- * Read a per-company journal from disk.
129
- *
130
- * Back-compat (US-005, v1 → v2): a v1 file on disk is returned as-is with
131
- * `version: "1"` and no `pulls` field. The in-place migration to v2 happens
132
- * the first time `writeJournal` runs — `migrateToV2` ensures any journal
133
- * passed to the writer carries `version: "2"`, `pulls: []`, and the rest of
134
- * the v2 shape. This keeps `readJournal` deterministic + cheap and confines
135
- * the side effect (schema bump on disk) to writes.
136
- *
137
- * When the file doesn't exist, we return a fresh v2 journal directly — new
138
- * installs never pass through v1 on disk.
139
- */
140
- export function readJournal(slug: string): SyncJournal {
141
- if (StateStore.exists(getStateDir(), journalScopeId(slug))) {
142
- return openJournalStore(slug, emptyJournal()).getState().journal;
143
- }
144
- const journalPath = getJournalPath(slug);
145
- let legacy: SyncJournal;
146
- if (fs.existsSync(journalPath)) {
147
- legacy = readJournalFileWithLastGood(journalPath);
148
- } else {
149
- legacy = emptyJournal();
150
- }
151
- migrateToV2(legacy);
152
- return openJournalStore(slug, legacy).getState().journal;
153
- }
154
-
155
- function emptyJournal(): SyncJournal {
156
- return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
157
- }
158
-
159
- function journalScopeId(slug: string): string {
160
- return `journal:${sanitizeSlug(slug)}`;
161
- }
162
-
163
- function openJournalStore(slug: string, initialJournal: SyncJournal): StateStore<V3JournalState> {
164
- return StateStore.open({
165
- rootDir: getStateDir(),
166
- scopeId: journalScopeId(slug),
167
- initialState: { journal: initialJournal },
168
- reduce: (state, record) => {
169
- if (record.type !== JOURNAL_WAL_RECORD_TYPE) {
170
- throw new Error(`journal: unexpected v3 WAL record type ${record.type}`);
171
- }
172
- const payload = record.payload as Partial<V3JournalWalPayload>;
173
- if (!payload || typeof payload !== "object" || !isJournal(payload.journal)) {
174
- throw new Error("journal: v3 WAL record has an invalid journal payload");
175
- }
176
- return { ...state, journal: payload.journal };
177
- },
178
- });
179
- }
180
-
181
- function isJournal(value: unknown): value is SyncJournal {
182
- return typeof value === "object" && value !== null &&
183
- ((value as SyncJournal).version === "1" || (value as SyncJournal).version === "2") &&
184
- typeof (value as SyncJournal).lastSync === "string" &&
185
- typeof (value as SyncJournal).files === "object" &&
186
- (value as SyncJournal).files !== null;
187
- }
188
-
189
- function parseJournalContent(content: string): SyncJournal {
190
- if (content.length === 0) {
191
- throw new SyntaxError("journal JSON is empty");
192
- }
193
- return JSON.parse(content) as SyncJournal;
194
- }
195
-
196
- function isCorruptJournalJson(err: unknown): boolean {
197
- return err instanceof SyntaxError;
198
- }
199
-
200
- function readJournalFileWithLastGood(journalPath: string): SyncJournal {
201
- let primaryErr: unknown;
202
- try {
203
- return parseJournalContent(fs.readFileSync(journalPath, "utf-8"));
204
- } catch (err) {
205
- if (!isCorruptJournalJson(err)) throw err;
206
- primaryErr = err;
207
- }
208
-
209
- try {
210
- return parseJournalContent(
211
- fs.readFileSync(lastGoodJournalPath(journalPath), "utf-8"),
212
- );
213
- } catch {
214
- throw primaryErr;
215
- }
216
- }
217
-
218
- /** One enumerated journal shard: its recovered slug, on-disk path, contents. */
219
- export interface JournalSummary {
220
- /**
221
- * Slug recovered from the `sync-journal.<slug>.json` filename — the
222
- * sanitized form the engine wrote (e.g. a company slug,
223
- * `PERSONAL_VAULT_JOURNAL_SLUG`, or the legacy `"personal"`).
224
- */
225
- slug: string;
226
- /** Absolute path to the journal file. */
227
- path: string;
228
- /** Parsed journal contents. */
229
- journal: SyncJournal;
230
- }
231
-
232
- /**
233
- * Enumerate every sync journal present in the state dir.
234
- *
235
- * The engine SHARDS journals by slug (ADR-0001 Phase 5): the personal-vault
236
- * fanout slot under `PERSONAL_VAULT_JOURNAL_SLUG`, one shard per cloud company,
237
- * and the legacy `"personal"` shard. A caller that reads a single fixed path
238
- * therefore only ever sees one scope — and a caller that mistakes a non-slug
239
- * value for a slug (e.g. `hq sync status` passing the HQ-root PATH, which
240
- * `sanitizeSlug` mangles into `_Users_<user>_hq`) sees a slug the engine never
241
- * writes, and reports "no journal" right after a successful sync. Any surface
242
- * that wants the COMPLETE local sync picture must read ALL shards via this
243
- * helper rather than reconstructing a path.
244
- *
245
- * Slugs are recovered from each filename. A shard that fails to read or parse
246
- * is skipped rather than thrown — one corrupt shard must not blind the caller
247
- * to the healthy ones. Results are sorted by slug for deterministic output.
248
- */
249
- export function listJournals(): JournalSummary[] {
250
- const dir = getStateDir();
251
- let names: string[];
252
- try {
253
- names = fs.readdirSync(dir);
254
- } catch {
255
- return []; // state dir absent → no journals yet
256
- }
257
- const out: JournalSummary[] = [];
258
- for (const name of names) {
259
- if (
260
- !name.startsWith(JOURNAL_FILE_PREFIX) ||
261
- !name.endsWith(JOURNAL_FILE_SUFFIX)
262
- ) {
263
- continue;
264
- }
265
- const slug = name.slice(
266
- JOURNAL_FILE_PREFIX.length,
267
- name.length - JOURNAL_FILE_SUFFIX.length,
268
- );
269
- if (!slug) continue; // guard against a stray "sync-journal..json"
270
- const filePath = path.join(dir, name);
271
- try {
272
- // Prefer the v3 store when this legacy filename is only its immutable
273
- // compatibility locator; otherwise this performs the v2 last-good read.
274
- const journal = readJournal(slug);
275
- out.push({ slug, path: filePath, journal });
276
- } catch (err) {
277
- if (!isCorruptJournalJson(err)) throw err;
278
- // Corrupt shard with no usable last-good — skip; don't blind the caller
279
- // to the rest. Genuine IO/permission failures surface to the caller.
280
- }
281
- }
282
- out.sort((a, b) => a.slug.localeCompare(b.slug));
283
- return out;
284
- }
285
-
286
- /**
287
- * Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files`
288
- * map. Such clients stamped keys with the OS path separator ("\\"), e.g.
289
- * `projects\\forecast-development\\x.csv`. A backslash key is a live data-loss
290
- * hazard for the cross-machine delete planner (Bug #9): it never matches the
291
- * forward-slash remote LIST, so the planner classifies the still-present local
292
- * file as remote-deleted, and `path.join(companyRoot, key)` collapses the
293
- * backslashes back onto the REAL POSIX file — which the executor then unlinks
294
- * (ridge incident, feedback_b8d09d0f: a single pull deleted ~36 live files,
295
- * with 587 backslash keys left in the journal as a recurring landmine).
296
- *
297
- * Rewriting every key to its canonical POSIX form on load removes the hazard
298
- * idempotently — a clean (all-POSIX) journal is returned untouched. Merge rule:
299
- * if a key's POSIX twin already exists, the POSIX entry is authoritative (it
300
- * round-tripped through an up-to-date client) and the malformed duplicate is
301
- * dropped; otherwise the entry is moved to its POSIX key. Returns the number of
302
- * keys rewritten (0 for a clean journal) for telemetry and test assertions.
303
- */
304
- export function normalizeJournalKeys(journal: SyncJournal): number {
305
- if (!journal.files) return 0;
306
- let rewritten = 0;
307
- // Snapshot keys up front — we mutate journal.files during the walk.
308
- for (const key of Object.keys(journal.files)) {
309
- const posix = toPosixKey(key);
310
- if (posix === key) continue; // already canonical (no backslash)
311
- const entry = journal.files[key];
312
- delete journal.files[key];
313
- rewritten++;
314
- // POSIX twin already present → it wins; drop the malformed duplicate.
315
- if (!(posix in journal.files)) {
316
- journal.files[posix] = entry;
317
- }
318
- }
319
- return rewritten;
320
- }
321
-
322
- /**
323
- * Coerce any-version journal into a v2 shape. Idempotent for v2 inputs.
324
- * Mutates the input and returns it for chainable use. Call this immediately
325
- * after `readJournal` if your code-path needs the v2 fields.
326
- *
327
- * v1 → v2 contract: every existing `files[]` entry is preserved as-is; no
328
- * tombstone fields are inserted (legacy entries are NOT scope-shrink
329
- * tombstones). `pulls` becomes `[]` (empty history → treat last scope as
330
- * "all" in the scope-shrink algorithm).
331
- */
332
- export function migrateToV2(journal: SyncJournal): SyncJournal {
333
- // Backslash-key normalization runs UNCONDITIONALLY — a poisoned journal can
334
- // already carry version "2" (the landmine predates the schema bump), so this
335
- // must precede the v2 early-return or already-v2 journals stay poisoned.
336
- normalizeJournalKeys(journal);
337
- if (journal.version === "2" && Array.isArray(journal.pulls)) {
338
- return journal;
339
- }
340
- journal.version = JOURNAL_VERSION_CURRENT;
341
- if (!Array.isArray(journal.pulls)) journal.pulls = [];
342
- return journal;
343
- }
344
-
345
- /**
346
- * Write a journal to disk, migrating to the current schema version in-place.
347
- * `migrateToV2` mutates the passed-in object — callers that hold a reference
348
- * after the write will see the v2 shape.
349
- */
350
- export function writeJournal(slug: string, journal: SyncJournal): void {
351
- migrateToV2(journal);
352
- const store = openJournalStore(slug, journal);
353
- // The initial v2 → v3 migration is a durable snapshot. Every normal write
354
- // thereafter is one framed append + fdatasync, never a whole-journal rewrite.
355
- if (store.wasCreated) {
356
- writeLegacyJournalMirror(slug, journal);
357
- } else {
358
- store.append(JOURNAL_WAL_RECORD_TYPE, { journal });
359
- // The JSON file remains a compatibility mirror for shipped CLI/status
360
- // consumers that have not moved to StateStore yet. It is never used for
361
- // recovery once a v3 generation exists; the framed WAL above is the sole
362
- // authority and hot path. Keep the mirror current so those consumers do
363
- // not observe stale sync state during the phased migration.
364
- writeLegacyJournalMirror(slug, journal);
365
- }
366
- }
367
-
368
- /**
369
- * Keep the v2-shaped filename as a compatibility mirror for older CLI/status
370
- * surfaces. The v3 state store is authoritative after migration; this mirror
371
- * is atomically replaced only to preserve existing readers until their owner
372
- * migrates them. WAL recovery never consults it once a v3 generation exists.
373
- */
374
- function writeLegacyJournalMirror(slug: string, journal: SyncJournal): void {
375
- const journalPath = getJournalPath(slug);
376
- fs.mkdirSync(path.dirname(journalPath), { recursive: true });
377
- const temporary = path.join(
378
- path.dirname(journalPath),
379
- `.${path.basename(journalPath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`,
380
- );
381
- let fd: number | undefined;
382
- try {
383
- fd = fs.openSync(temporary, "wx");
384
- fs.writeFileSync(fd, JSON.stringify(journal));
385
- fs.fdatasyncSync(fd);
386
- fs.closeSync(fd);
387
- fd = undefined;
388
- fs.renameSync(temporary, journalPath);
389
- } catch (error) {
390
- try { fs.rmSync(temporary, { force: true }); } catch { /* preserve mirror write failure */ }
391
- throw new Error(`journal: failed to update v3 compatibility mirror ${journalPath}`, { cause: error });
392
- } finally {
393
- if (fd !== undefined) fs.closeSync(fd);
394
- }
395
- }
396
-
397
- function lastGoodJournalPath(journalPath: string): string {
398
- return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
399
- }
400
-
401
-
402
- export function hashFile(filePath: string): string {
403
- const content = fs.readFileSync(filePath);
404
- return crypto.createHash("sha256").update(content).digest("hex");
405
- }
406
-
407
- /**
408
- * Marker prepended to a symlink's target string before hashing for the
409
- * journal. Mirrors the wire-side `SYMLINK_BODY_PREFIX` constant in
410
- * `s3.ts` — same purpose, different namespace.
411
- *
412
- * Without this marker, a symlink to `real.md` and a regular file whose
413
- * contents are exactly the bytes `real.md` produce identical journal
414
- * hashes (both `sha256("real.md")`). When `skipUnchanged` is enabled,
415
- * the planner would treat a regular-file → symlink replacement as
416
- * "no change" and never upload the new symlink, leaving the remote
417
- * representation stale forever — the pull side would then also see no
418
- * drift via ETag and never repair.
419
- *
420
- * Hashing `sha256(prefix + target)` makes the two representations
421
- * structurally inequal in journal-hash space, so skip-unchanged can
422
- * never confuse them. The hash always varies with the target string,
423
- * so target rewrites still re-fire uploads as expected.
424
- */
425
- export const SYMLINK_HASH_PREFIX = "hq-symlink:";
426
-
427
- /**
428
- * Compute the journal hash for a symlink. Always use this helper
429
- * (never inline `crypto.createHash` with the raw target) so the
430
- * push side, the pull-planner, and the post-download stamp stay in
431
- * lockstep on the prefixed-hash convention.
432
- */
433
- export function hashSymlinkTarget(target: string): string {
434
- return crypto
435
- .createHash("sha256")
436
- .update(SYMLINK_HASH_PREFIX + target)
437
- .digest("hex");
438
- }
439
-
440
- /**
441
- * Record a per-file journal entry after a transfer.
442
- *
443
- * `direction` (`"up"` = pushed / locally authored, `"down"` = pulled) is what
444
- * lets scope-shrink tell your own work apart from a mirror of someone else's.
445
- * It underpins the US-006 push-only sessions contract: a session transcript
446
- * this machine authored and pushed is stamped `direction:"up"`, so even though
447
- * `sessions/` is excluded from every pull scope, `buildScopeShrinkPlan` skips
448
- * `direction:"up"` entries and never orphans it. (Sessions fetched on demand
449
- * via `hq files get` are pulled — `direction:"down"` — but ride the pin union
450
- * in the caller's inclusion prefixSet, so they are likewise never pruned.)
451
- */
452
- /**
453
- * Thrown when a caller tries to journal a path that is not on disk.
454
- *
455
- * Deliberately loud rather than a silent skip: a caller reaching this has a
456
- * real ordering bug, and swallowing it would reintroduce exactly the silent
457
- * drift this guard exists to prevent.
458
- */
459
- export class PrematureJournalEntryError extends Error {
460
- constructor(readonly relativePath: string, readonly absolutePath: string) {
461
- super(
462
- `refusing to journal '${relativePath}': no file at '${absolutePath}'. ` +
463
- `A journal entry must be written only AFTER its file is on disk.`,
464
- );
465
- this.name = "PrematureJournalEntryError";
466
- }
467
- }
468
-
469
- /**
470
- * Optional fields of a journal entry.
471
- *
472
- * These are an OBJECT rather than trailing positional parameters because they
473
- * were four adjacent optionals, two of them `string`, and callers drifted:
474
- * two conflict-resolution sites in `cli/sync.ts` passed their `"file" |
475
- * "symlink"` value into the `createdBySub` slot, so `entry.kind` was silently
476
- * never set on any conflict-resolved entry. That is not cosmetic — `entry.kind`
477
- * gates delete-intent minting (`markLocalDeleteIntent` requires it to match,
478
- * and the watcher skips any entry without one), so a file that had ever been
479
- * through conflict resolution could never be deleted, even with a live watcher.
480
- * Named fields make that class of mistake unrepresentable.
481
- */
482
- export interface UpdateEntryOptions {
483
- remoteEtag?: string;
484
- mtimeMs?: number;
485
- /** Object's `created-by-sub` S3 metadata. Download path only. */
486
- createdBySub?: string;
487
- kind?: "file" | "symlink";
488
- }
489
-
490
- /**
491
- * Record a journal entry for a file that is CONFIRMED present on disk.
492
- *
493
- * `verifyAbsolutePath` is required, and the entry is written only after an
494
- * `lstat` proves the file exists. This is a load-bearing invariant, not a
495
- * defensive nicety: a journal entry says "this key was synced and the local
496
- * copy is at this hash". Once delete propagation authorizes on ETag currency
497
- * alone (no watcher-minted intent), an entry whose file was never actually
498
- * written becomes indistinguishable from "the user deleted this file" — and
499
- * the next push issues a remote DeleteObject for a file that was only ever
500
- * premature bookkeeping.
501
- *
502
- * Every existing caller already had a `fs.lstatSync` in hand and passed values
503
- * derived from it, so this makes an existing convention structural. The point
504
- * is that a FUTURE caller cannot get it wrong: with the check inside this
505
- * function there is no longer a way to add a call site that records an entry
506
- * for a file that is not there.
507
- *
508
- * `lstat` (not `stat`) so a dangling symlink still counts as present — the
509
- * link itself is the synced object, and its target may legitimately be absent.
510
- *
511
- * @throws PrematureJournalEntryError when nothing exists at `verifyAbsolutePath`.
512
- */
513
- export function updateEntry(
514
- journal: SyncJournal,
515
- relativePath: string,
516
- hash: string,
517
- size: number,
518
- direction: "up" | "down",
519
- verifyAbsolutePath: string,
520
- opts: UpdateEntryOptions = {},
521
- ): void {
522
- try {
523
- fs.lstatSync(verifyAbsolutePath);
524
- } catch {
525
- throw new PrematureJournalEntryError(relativePath, verifyAbsolutePath);
526
- }
527
- const { remoteEtag, mtimeMs, createdBySub, kind } = opts;
528
- const entry: JournalEntry = {
529
- hash,
530
- size,
531
- syncedAt: new Date().toISOString(),
532
- direction,
533
- };
534
- if (remoteEtag !== undefined && remoteEtag !== "") {
535
- entry.remoteEtag = normalizeEtag(remoteEtag);
536
- }
537
- if (mtimeMs !== undefined) {
538
- entry.mtimeMs = mtimeMs;
539
- }
540
- // Authorship (scope-shrink guard input). Stamped from the object's
541
- // `created-by-sub` S3 metadata on download. Only persisted when present so
542
- // legacy journals and author-less uploads stay byte-identical.
543
- if (createdBySub !== undefined && createdBySub !== "") {
544
- entry.createdBySub = createdBySub;
545
- }
546
- if (kind !== undefined) {
547
- entry.kind = kind;
548
- }
549
- journal.files[relativePath] = entry;
550
- journal.lastSync = new Date().toISOString();
551
- }
552
-
553
- /**
554
- * S3 returns ETags wrapped in literal double-quotes (e.g. `"d41d8cd9..."`).
555
- * Strip them so equality comparisons across HEAD / GET / PUT responses are
556
- * stable regardless of which AWS SDK call surfaced the value.
557
- */
558
- export function normalizeEtag(etag: string): string {
559
- if (!etag) return "";
560
- return etag.replace(/^"|"$/g, "");
561
- }
562
-
563
- export function getEntry(
564
- journal: SyncJournal,
565
- relativePath: string,
566
- ): JournalEntry | undefined {
567
- return journal.files[relativePath];
568
- }
569
-
570
- export function removeEntry(
571
- journal: SyncJournal,
572
- relativePath: string,
573
- ): void {
574
- delete journal.files[relativePath];
575
- }
576
-
577
- // ─── Journal v2 (US-005): pulls, tombstones, GC ─────────────────────────────
578
-
579
- /** Crockford base32 alphabet (ULID-compatible). */
580
- const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
581
-
582
- /**
583
- * Generate a ULID-shaped 26-char identifier without adding a runtime dep.
584
- * Format: 10-char base32 of the current millisecond timestamp + 16-char
585
- * base32 of random bytes. Lexically sortable, time-prefixed — same property
586
- * that makes ULIDs useful for `pulls[]` ordering.
587
- *
588
- * We don't need full ULID spec compliance (monotonic counter, randomness
589
- * spec) — just sortable + collision-resistant enough that two pulls
590
- * issued in the same millisecond by different processes don't clash.
591
- * 80 bits of randomness is plenty.
592
- */
593
- export function generatePullId(now: number = Date.now()): string {
594
- let time = now;
595
- const timeChars: string[] = [];
596
- for (let i = 0; i < 10; i++) {
597
- timeChars.unshift(CROCKFORD[time % 32]!);
598
- time = Math.floor(time / 32);
599
- }
600
- const randBytes = crypto.randomBytes(10); // 80 bits
601
- const randChars: string[] = [];
602
- // Encode 10 random bytes (80 bits) into 16 base32 chars.
603
- let buf = 0;
604
- let bits = 0;
605
- for (let i = 0; i < randBytes.length; i++) {
606
- buf = (buf << 8) | randBytes[i]!;
607
- bits += 8;
608
- while (bits >= 5) {
609
- bits -= 5;
610
- randChars.push(CROCKFORD[(buf >> bits) & 0x1f]!);
611
- }
612
- }
613
- if (bits > 0) {
614
- randChars.push(CROCKFORD[(buf << (5 - bits)) & 0x1f]!);
615
- }
616
- return timeChars.join("") + randChars.slice(0, 16).join("");
617
- }
618
-
619
- /**
620
- * Find the most-recent `PullRecord` for a company in the journal. Returns
621
- * `undefined` when no record exists — scope-shrink callers treat that as
622
- * "no prior scope; nothing to shrink".
623
- *
624
- * Order by `completedAt` descending — `pullId` is lexically sortable but
625
- * `completedAt` is what semantically represents "most recent successful
626
- * pull state at last close".
627
- */
628
- export function lastPullRecord(
629
- journal: SyncJournal,
630
- companyUid: string,
631
- ): PullRecord | undefined {
632
- if (!journal.pulls || journal.pulls.length === 0) return undefined;
633
- let best: PullRecord | undefined;
634
- for (const p of journal.pulls) {
635
- if (p.companyUid !== companyUid) continue;
636
- if (!best || p.completedAt > best.completedAt) best = p;
637
- }
638
- return best;
639
- }
640
-
641
- function trimPullRecords(journal: SyncJournal): void {
642
- const pulls = journal.pulls;
643
- if (!pulls || pulls.length === 0) return;
644
-
645
- const byCompany = new Map<string, Array<{ record: PullRecord; index: number }>>();
646
- pulls.forEach((record, index) => {
647
- const entries = byCompany.get(record.companyUid);
648
- if (entries) entries.push({ record, index });
649
- else byCompany.set(record.companyUid, [{ record, index }]);
650
- });
651
-
652
- const keep = new Set<number>();
653
- for (const entries of byCompany.values()) {
654
- entries
655
- .sort((a, b) => {
656
- const byCompletedAt = b.record.completedAt.localeCompare(
657
- a.record.completedAt,
658
- );
659
- return byCompletedAt === 0 ? b.index - a.index : byCompletedAt;
660
- })
661
- .slice(0, MAX_PULLS_PER_COMPANY)
662
- .forEach((entry) => keep.add(entry.index));
663
- }
664
-
665
- journal.pulls = pulls.filter((_, index) => keep.has(index));
666
- }
667
-
668
- /** Append a `PullRecord` (mutates `journal.pulls`) and cap retained history. */
669
- export function appendPullRecord(
670
- journal: SyncJournal,
671
- record: PullRecord,
672
- ): void {
673
- migrateToV2(journal);
674
- journal.pulls!.push(record);
675
- trimPullRecords(journal);
676
- }
677
-
678
- /**
679
- * Write a journal tombstone entry for `relativePath`. Used by the scope-
680
- * shrink algorithm (US-005) and by `hq sync narrow --apply` (US-007).
681
- *
682
- * Tombstones intentionally keep the old `hash` / `size` / `syncedAt` /
683
- * `direction` so a recovery flow could see what was there before pruning.
684
- * They are GC'd after `TOMBSTONE_TTL_MS` via `gcTombstones`.
685
- */
686
- export function tombstoneEntry(
687
- journal: SyncJournal,
688
- relativePath: string,
689
- reason: "scope_shrink" | "narrow_apply" | "manual" | "local-delete",
690
- now: string = new Date().toISOString(),
691
- ): void {
692
- const entry = journal.files[relativePath];
693
- if (!entry) return;
694
- entry.removedAt = now;
695
- entry.removedReason = reason;
696
- }
697
-
698
- /**
699
- * Mark a journal entry for a user-authorized local delete. Callers must
700
- * capture the local hash and kind before removing the object; an absent path
701
- * alone is never deletion intent.
702
- */
703
- export function markLocalDeleteIntent(
704
- journal: SyncJournal,
705
- relativePath: string,
706
- localHash: string,
707
- localKind: "file" | "symlink",
708
- expectedRemoteEtag: string,
709
- ): boolean {
710
- const entry = journal.files[relativePath];
711
- if (
712
- !entry?.remoteEtag ||
713
- entry.remoteEtag !== normalizeEtag(expectedRemoteEtag) ||
714
- entry.hash !== localHash ||
715
- entry.kind !== localKind
716
- ) {
717
- return false;
718
- }
719
- entry.localDeleteIntent = {
720
- version: 1,
721
- remoteEtag: entry.remoteEtag,
722
- localHash,
723
- localKind,
724
- };
725
- return true;
726
- }
727
-
728
- /** Invalidate a prior delete authorization after a watcher-observed recreate. */
729
- export function clearLocalDeleteIntent(
730
- journal: SyncJournal,
731
- relativePath: string,
732
- ): boolean {
733
- const entry = journal.files[relativePath];
734
- if (!entry?.localDeleteIntent) return false;
735
- delete entry.localDeleteIntent;
736
- return true;
737
- }
738
-
739
- /** True if the entry is a tombstone (set by `tombstoneEntry`). */
740
- export function isTombstone(entry: JournalEntry | undefined): boolean {
741
- return !!entry && typeof entry.removedAt === "string";
742
- }
743
-
744
- /**
745
- * Garbage-collect tombstones older than `TOMBSTONE_TTL_MS` from
746
- * `journal.files`. Returns the number removed. Cheap — single pass over
747
- * the files map, no I/O. Safe to call at the start AND end of every
748
- * `pullAll` per-company leg; both runs are idempotent.
749
- */
750
- export function gcTombstones(
751
- journal: SyncJournal,
752
- now: number = Date.now(),
753
- ): number {
754
- let removed = 0;
755
- for (const [path, entry] of Object.entries(journal.files)) {
756
- if (!entry.removedAt) continue;
757
- const removedTime = Date.parse(entry.removedAt);
758
- if (Number.isNaN(removedTime)) continue;
759
- if (now - removedTime > TOMBSTONE_TTL_MS) {
760
- delete journal.files[path];
761
- removed++;
762
- }
763
- }
764
- return removed;
765
- }