sealkeep 0.9.0 → 0.11.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 (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +252 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
  16. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
  17. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  18. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  20. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  21. package/dist/site/index.html +1808 -1904
  22. package/dist/site/llms.txt +67 -0
  23. package/dist/site/trust/architecture-data-flow.html +53 -0
  24. package/dist/site/trust/audit-roadmap.html +37 -0
  25. package/dist/site/trust/deployment-responsibility.html +11 -0
  26. package/dist/site/trust/dpa-sample.html +30 -0
  27. package/dist/site/trust/release-provenance.html +21 -0
  28. package/dist/site/trust/subprocessors.html +15 -0
  29. package/dist/site/trust/threat-model.html +34 -0
  30. package/dist/site/trust/toms.html +41 -0
  31. package/dist/site/trust-document.css +32 -0
  32. package/dist/site/trust.html +73 -0
  33. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  34. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  35. package/dist/site/visual/index.html +18 -0
  36. package/dist/site.zip +0 -0
  37. package/dist/src/activity.d.ts +9 -0
  38. package/dist/src/activity.js +90 -1
  39. package/dist/src/adapters.d.ts +175 -5
  40. package/dist/src/adapters.js +961 -73
  41. package/dist/src/agent-context.d.ts +135 -0
  42. package/dist/src/agent-context.js +1059 -0
  43. package/dist/src/archive-copies.d.ts +47 -0
  44. package/dist/src/archive-copies.js +179 -0
  45. package/dist/src/audit.d.ts +1 -1
  46. package/dist/src/audit.js +29 -4
  47. package/dist/src/autopilot.d.ts +52 -7
  48. package/dist/src/autopilot.js +143 -25
  49. package/dist/src/background-bandwidth.d.ts +46 -0
  50. package/dist/src/background-bandwidth.js +301 -0
  51. package/dist/src/background-cpu.d.ts +82 -0
  52. package/dist/src/background-cpu.js +212 -0
  53. package/dist/src/background-worker-error.d.ts +12 -0
  54. package/dist/src/background-worker-error.js +18 -0
  55. package/dist/src/branding.d.ts +24 -1
  56. package/dist/src/branding.js +31 -1
  57. package/dist/src/bridge.d.ts +233 -0
  58. package/dist/src/bridge.js +604 -0
  59. package/dist/src/byte-stream.d.ts +91 -0
  60. package/dist/src/byte-stream.js +385 -0
  61. package/dist/src/chunk-store.d.ts +41 -8
  62. package/dist/src/chunk-store.js +161 -65
  63. package/dist/src/cli.js +1698 -163
  64. package/dist/src/cloud.d.ts +841 -31
  65. package/dist/src/cloud.js +3196 -277
  66. package/dist/src/context-background.d.ts +37 -0
  67. package/dist/src/context-background.js +309 -0
  68. package/dist/src/context-drain-child.d.ts +1 -0
  69. package/dist/src/context-drain-child.js +98 -0
  70. package/dist/src/context-reader.d.ts +118 -0
  71. package/dist/src/context-reader.js +447 -0
  72. package/dist/src/control-plane/auth.d.ts +32 -4
  73. package/dist/src/control-plane/auth.js +85 -24
  74. package/dist/src/control-plane/server.js +19 -6
  75. package/dist/src/control-plane.d.ts +17 -1
  76. package/dist/src/control-plane.js +32 -6
  77. package/dist/src/crypto.d.ts +1 -1
  78. package/dist/src/crypto.js +5 -5
  79. package/dist/src/daemon-lease.d.ts +70 -0
  80. package/dist/src/daemon-lease.js +420 -0
  81. package/dist/src/daemon.d.ts +94 -1
  82. package/dist/src/daemon.js +1082 -105
  83. package/dist/src/darwin-service-policy.d.ts +41 -0
  84. package/dist/src/darwin-service-policy.js +60 -0
  85. package/dist/src/dashboard-cli.js +15 -15
  86. package/dist/src/device-authorization.d.ts +37 -0
  87. package/dist/src/device-authorization.js +199 -0
  88. package/dist/src/device-enrollment.d.ts +91 -0
  89. package/dist/src/device-enrollment.js +349 -0
  90. package/dist/src/disk.d.ts +17 -12
  91. package/dist/src/disk.js +43 -17
  92. package/dist/src/doctor.d.ts +35 -1
  93. package/dist/src/doctor.js +332 -41
  94. package/dist/src/durable-ticket-lock.d.ts +24 -0
  95. package/dist/src/durable-ticket-lock.js +232 -0
  96. package/dist/src/enroll.d.ts +1 -1
  97. package/dist/src/enroll.js +13 -7
  98. package/dist/src/env.d.ts +10 -1
  99. package/dist/src/env.js +11 -3
  100. package/dist/src/errors.d.ts +8 -8
  101. package/dist/src/errors.js +6 -6
  102. package/dist/src/flush.d.ts +12 -0
  103. package/dist/src/flush.js +37 -0
  104. package/dist/src/heartbeat.d.ts +86 -12
  105. package/dist/src/heartbeat.js +415 -29
  106. package/dist/src/index-background-watchdog.d.ts +1 -0
  107. package/dist/src/index-background-watchdog.js +94 -0
  108. package/dist/src/index-background-work.d.ts +21 -0
  109. package/dist/src/index-background-work.js +25 -0
  110. package/dist/src/index-background.d.ts +64 -0
  111. package/dist/src/index-background.js +394 -0
  112. package/dist/src/index-build-child.d.ts +1 -0
  113. package/dist/src/index-build-child.js +109 -0
  114. package/dist/src/index-manifest.d.ts +52 -0
  115. package/dist/src/index-manifest.js +444 -0
  116. package/dist/src/index-publication-proof.d.ts +84 -0
  117. package/dist/src/index-publication-proof.js +380 -0
  118. package/dist/src/index-publication-state.d.ts +149 -0
  119. package/dist/src/index-publication-state.js +696 -0
  120. package/dist/src/index-publication-verifier.d.ts +89 -0
  121. package/dist/src/index-publication-verifier.js +341 -0
  122. package/dist/src/index-publish.d.ts +62 -0
  123. package/dist/src/index-publish.js +540 -0
  124. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  125. package/dist/src/index-scratch-cleanup.js +166 -0
  126. package/dist/src/index-segment-types.d.ts +132 -0
  127. package/dist/src/index-segment-types.js +21 -0
  128. package/dist/src/index-segments.d.ts +9 -0
  129. package/dist/src/index-segments.js +516 -0
  130. package/dist/src/index-store.d.ts +123 -0
  131. package/dist/src/index-store.js +495 -0
  132. package/dist/src/index-sync.d.ts +91 -15
  133. package/dist/src/index-sync.js +286 -53
  134. package/dist/src/index-upgrade-publication.d.ts +30 -0
  135. package/dist/src/index-upgrade-publication.js +179 -0
  136. package/dist/src/integration-manager.d.ts +32 -0
  137. package/dist/src/integration-manager.js +394 -0
  138. package/dist/src/leakscan.js +1 -1
  139. package/dist/src/local-api.d.ts +56 -15
  140. package/dist/src/local-api.js +4979 -541
  141. package/dist/src/machine-settings.d.ts +51 -0
  142. package/dist/src/machine-settings.js +166 -0
  143. package/dist/src/managed-chunks.d.ts +5 -2
  144. package/dist/src/managed-chunks.js +14 -14
  145. package/dist/src/mcp-install.d.ts +11 -9
  146. package/dist/src/mcp-install.js +73 -28
  147. package/dist/src/mcp-workspace.d.ts +18 -0
  148. package/dist/src/mcp-workspace.js +50 -0
  149. package/dist/src/mcp.js +294 -25
  150. package/dist/src/migrate.js +27 -21
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5940 -297
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +226 -30
  249. package/dist/src/vault.js +1776 -192
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Publishing segments to the account index, and pulling a peer's.
3
+ *
4
+ * A segment is already the right unit to share: it is sealed once and never
5
+ * rewritten, so "publish" is just "upload the ones a peer has not seen" and
6
+ * "pull" is just "download the ones this machine has not seen" — no merge,
7
+ * no whole-index transfer. Scope is the account only (the same person's
8
+ * machines, identified by `machineIdentity`); a team shard keeps publishing
9
+ * the single legacy blob untouched.
10
+ *
11
+ * Remote layout, next to the blob's own keys (`segmentKeys` in
12
+ * `index-sync.ts`):
13
+ * …/segments/<machineId>/<segmentId>.skseg[.json]/.skdir the sealed files,
14
+ * bytes unchanged — they are sealed to the phrase already.
15
+ * …/segments/<machineId>/head.json plaintext: this machine's own
16
+ * (`origin: "local"`) segments and its current tombstones.
17
+ * …/segments/heads.json plaintext phonebook of every
18
+ * machine's `{ generation, publishedAt }` — the only way a peer is
19
+ * discovered, since the store has no listing.
20
+ *
21
+ * `heads.json` is a plain read-modify-write with no lock: two publishers at
22
+ * once can drop each other's entry. That is accepted, not a bug — a machine
23
+ * re-adds itself the next time IT publishes something, so the map heals.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
27
+ import { dirname, join } from "node:path";
28
+ import { machineIdentity } from "./cloud.js";
29
+ import { readManifest, updateManifest } from "./index-manifest.js";
30
+ import { openSegment, removeSegment, segmentPaths, writeSegment } from "./index-segments.js";
31
+ import { normalizeContentIndex, loadContentIndexFromFile, pullRemoteIndexIfNewerVerified } from "./search.js";
32
+ const segmentsDir = (dataDir) => join(dataDir, "index", "segments");
33
+ const publishedStatePath = (dataDir) => join(dataDir, "index", "segments", "published.json");
34
+ const pulledStatePath = (dataDir) => join(dataDir, "index", "segments", "pulled.json");
35
+ const publishedBlobStatePath = (dataDir) => join(dataDir, "index", "segments", "published-blob.json");
36
+ const pulledBlobStatePath = (dataDir) => join(dataDir, "index", "segments", "pulled-blob.json");
37
+ /** Atomic publication that never leaves this call's temp behind on error.
38
+ * Mirrors the private `writeIndexFile` in src/search.ts, reimplemented here
39
+ * since that helper is not exported. */
40
+ async function writeJsonFile(target, value) {
41
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
42
+ const temp = `${target}.${randomUUID()}.tmp`;
43
+ try {
44
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
45
+ await rename(temp, target);
46
+ }
47
+ finally {
48
+ await rm(temp, { force: true }).catch(() => undefined);
49
+ }
50
+ }
51
+ async function readPublishedBlobState(dataDir) {
52
+ try {
53
+ const parsed = JSON.parse(await readFile(publishedBlobStatePath(dataDir), "utf8"));
54
+ if (typeof parsed.generation !== "string" || typeof parsed.builtAt !== "string")
55
+ return null;
56
+ return { version: 1, generation: parsed.generation, builtAt: parsed.builtAt };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** Called by `publishFromSegments` (src/index-store.ts) after a successful
63
+ * push, so `publishPending` and the blob-fallback `pullSegments` both know
64
+ * which generation and builtAt reached the store last. */
65
+ export async function writePublishedBlobState(dataDir, state) {
66
+ await writeJsonFile(publishedBlobStatePath(dataDir), state);
67
+ }
68
+ async function readPulledBlobState(dataDir) {
69
+ try {
70
+ const parsed = JSON.parse(await readFile(pulledBlobStatePath(dataDir), "utf8"));
71
+ if (typeof parsed.builtAt !== "string")
72
+ return null;
73
+ return { version: 1, builtAt: parsed.builtAt };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ async function writePulledBlobState(dataDir, state) {
80
+ await writeJsonFile(pulledBlobStatePath(dataDir), state);
81
+ }
82
+ async function readPublishedState(dataDir) {
83
+ try {
84
+ const parsed = JSON.parse(await readFile(publishedStatePath(dataDir), "utf8"));
85
+ if (typeof parsed.machineId !== "string" || typeof parsed.generation !== "string" || !Array.isArray(parsed.segments))
86
+ return null;
87
+ return {
88
+ version: 1,
89
+ machineId: parsed.machineId,
90
+ generation: parsed.generation,
91
+ segments: parsed.segments.filter((id) => typeof id === "string"),
92
+ };
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /**
99
+ * Uploads every local segment this machine has not yet published, deletes the
100
+ * remote copies of ids compaction or retirement has since replaced locally,
101
+ * republishes this machine's `head.json`, and re-registers it in the shared
102
+ * `heads.json`. Idempotent by construction: `head.json` is written only after
103
+ * every object it lists has actually landed, so a crash between any two steps
104
+ * is simply repaired by the next call, and nothing is re-uploaded because
105
+ * `published.json` is the last thing written.
106
+ */
107
+ export async function publishSegments(dataDir, phrase, store, options = {}) {
108
+ const segments = store?.segments;
109
+ // No generic keyed storage (managed/team today) or no account machine
110
+ // identity (offline, or no Sealkeep Cloud account at all): either way there
111
+ // is no `<machineId>/…` this call could address. The next successful call
112
+ // catches up in full, since `published.json` still names the old machine.
113
+ const machine = segments ? await machineIdentity(dataDir, undefined, { signal: options.signal }).catch(() => null) : null;
114
+ if (!segments || !machine)
115
+ return { published: 0, removed: 0, skipped: true };
116
+ const machineId = String(machine.device_id);
117
+ const manifest = await readManifest(dataDir);
118
+ const state = await readPublishedState(dataDir);
119
+ if (state && state.machineId === machineId && state.generation === manifest.generation) {
120
+ return { published: 0, removed: 0 };
121
+ }
122
+ // Only this machine's own rows are ever this machine's to publish or
123
+ // retract — a segment learned from a peer (`origin: "remote"`) is that
124
+ // peer's to publish, never republished here.
125
+ const local = manifest.segments.filter((segment) => segment.origin !== "remote");
126
+ const localIds = new Set(local.map((segment) => segment.id));
127
+ const previouslyPublished = state && state.machineId === machineId ? state.segments : [];
128
+ const toUpload = local.filter((segment) => !previouslyPublished.includes(segment.id));
129
+ const toRemove = previouslyPublished.filter((id) => !localIds.has(id));
130
+ let published = 0;
131
+ for (const segment of toUpload) {
132
+ options.signal?.throwIfAborted();
133
+ const paths = await segmentPaths(dataDir, segment.id);
134
+ // The three local files, as paths: `put` streams the body instead of
135
+ // buffering a potentially large segment whole.
136
+ await segments.put(`${machineId}/${segment.id}.skseg`, paths.body, { signal: options.signal });
137
+ await segments.put(`${machineId}/${segment.id}.skseg.json`, paths.envelope, { signal: options.signal });
138
+ await segments.put(`${machineId}/${segment.id}.skdir`, paths.directory, { signal: options.signal });
139
+ published += 1;
140
+ await options.onYield?.();
141
+ }
142
+ let removed = 0;
143
+ for (const id of toRemove) {
144
+ options.signal?.throwIfAborted();
145
+ await segments.del(`${machineId}/${id}.skseg`, { signal: options.signal });
146
+ await segments.del(`${machineId}/${id}.skseg.json`, { signal: options.signal });
147
+ await segments.del(`${machineId}/${id}.skdir`, { signal: options.signal });
148
+ removed += 1;
149
+ await options.onYield?.();
150
+ }
151
+ const head = {
152
+ version: 1,
153
+ machineId,
154
+ generation: manifest.generation,
155
+ publishedAt: new Date().toISOString(),
156
+ segments: local.map((segment) => ({
157
+ id: segment.id,
158
+ builtAt: segment.builtAt,
159
+ archives: segment.archives,
160
+ tokens: segment.tokens,
161
+ bytes: segment.bytes,
162
+ archiveIds: segment.archiveIds ?? [],
163
+ })),
164
+ tombstones: manifest.tombstones,
165
+ };
166
+ await segments.put(`${machineId}/head.json`, Buffer.from(`${JSON.stringify(head, null, 2)}\n`, "utf8"), { signal: options.signal });
167
+ await healHeads(segments, machineId, { generation: manifest.generation, publishedAt: head.publishedAt }, options.signal);
168
+ await writeJsonFile(publishedStatePath(dataDir), {
169
+ version: 1, machineId, generation: manifest.generation, segments: local.map((segment) => segment.id),
170
+ });
171
+ return { published, removed };
172
+ }
173
+ /**
174
+ * True when a segments-mode vault has local segment content the account
175
+ * store has not seen yet — the daemon's cue to run a catch-up pass even when
176
+ * nothing is left to index. `published.json` (a segment store) and
177
+ * `published-blob.json` (the blob fallback) each name the manifest
178
+ * generation their own kind of publish last reached; either matching the
179
+ * current manifest counts as "nothing pending", since a vault only ever uses
180
+ * one or the other depending on what the account store supports. A blob-mode
181
+ * vault (not segments) always answers false: `indexArchiveTokens` already
182
+ * publishes inline at seal time there, so the daemon needs no extra nudge.
183
+ */
184
+ export async function publishPending(dataDir) {
185
+ const { segmentsEnabled } = await import("./index-store.js");
186
+ if (!await segmentsEnabled(dataDir))
187
+ return false;
188
+ const manifest = await readManifest(dataDir);
189
+ const [published, publishedBlob] = await Promise.all([readPublishedState(dataDir), readPublishedBlobState(dataDir)]);
190
+ const upToDate = published?.generation === manifest.generation || publishedBlob?.generation === manifest.generation;
191
+ return !upToDate;
192
+ }
193
+ function parseHeadsFile(raw) {
194
+ if (!raw)
195
+ return { version: 1, machines: {} };
196
+ try {
197
+ const parsed = JSON.parse(raw.toString("utf8"));
198
+ const machines = {};
199
+ for (const [id, value] of Object.entries(parsed.machines ?? {})) {
200
+ const entry = value;
201
+ if (typeof entry?.generation === "string" && typeof entry?.publishedAt === "string") {
202
+ machines[id] = { generation: entry.generation, publishedAt: entry.publishedAt };
203
+ }
204
+ }
205
+ return { version: 1, machines };
206
+ }
207
+ catch {
208
+ return { version: 1, machines: {} };
209
+ }
210
+ }
211
+ async function healHeads(segments, machineId, entry, signal) {
212
+ const current = parseHeadsFile(await segments.get("heads.json", { signal }));
213
+ current.machines[machineId] = entry;
214
+ await segments.put("heads.json", Buffer.from(`${JSON.stringify(current, null, 2)}\n`, "utf8"), { signal });
215
+ }
216
+ function parseMachineHead(raw) {
217
+ try {
218
+ const parsed = JSON.parse(raw.toString("utf8"));
219
+ if (typeof parsed.machineId !== "string" || !Array.isArray(parsed.segments) || !Array.isArray(parsed.tombstones))
220
+ return null;
221
+ const segments = [];
222
+ for (const candidate of parsed.segments) {
223
+ const entry = candidate;
224
+ if (typeof entry?.id === "string" && typeof entry.builtAt === "string"
225
+ && typeof entry.archives === "number" && typeof entry.tokens === "number" && typeof entry.bytes === "number") {
226
+ segments.push({
227
+ id: entry.id, builtAt: entry.builtAt, archives: entry.archives, tokens: entry.tokens, bytes: entry.bytes,
228
+ archiveIds: Array.isArray(entry.archiveIds) ? entry.archiveIds.filter((id) => typeof id === "string") : [],
229
+ });
230
+ }
231
+ }
232
+ return {
233
+ version: 1,
234
+ machineId: parsed.machineId,
235
+ generation: typeof parsed.generation === "string" ? parsed.generation : "",
236
+ publishedAt: typeof parsed.publishedAt === "string" ? parsed.publishedAt : new Date(0).toISOString(),
237
+ segments,
238
+ tombstones: parsed.tombstones.filter((id) => typeof id === "string"),
239
+ };
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ async function readPulledState(dataDir) {
246
+ try {
247
+ const parsed = JSON.parse(await readFile(pulledStatePath(dataDir), "utf8"));
248
+ const machines = {};
249
+ for (const [id, generation] of Object.entries(parsed.machines ?? {}))
250
+ if (typeof generation === "string")
251
+ machines[id] = generation;
252
+ return { version: 1, machines };
253
+ }
254
+ catch {
255
+ return { version: 1, machines: {} };
256
+ }
257
+ }
258
+ /** Downloads a not-yet-local segment and verifies it opens under this
259
+ * phrase before trusting it. A segment that will not open (truncated
260
+ * transfer, a peer's write this machine caught mid-publish) is removed and
261
+ * skipped rather than left half-written: the peer's head still lists it, so
262
+ * the next pull retries it once the peer's own upload has finished. */
263
+ async function downloadAndVerifySegment(dataDir, phrase, segments, machineId, segmentId, signal) {
264
+ const paths = await segmentPaths(dataDir, segmentId);
265
+ const [body, envelope, directory] = await Promise.all([
266
+ segments.get(`${machineId}/${segmentId}.skseg`, { signal }),
267
+ segments.get(`${machineId}/${segmentId}.skseg.json`, { signal }),
268
+ segments.get(`${machineId}/${segmentId}.skdir`, { signal }),
269
+ ]);
270
+ if (!body || !envelope || !directory) {
271
+ await removeSegment(dataDir, segmentId);
272
+ return false;
273
+ }
274
+ await mkdir(segmentsDir(dataDir), { recursive: true, mode: 0o700 });
275
+ await writeFile(paths.body, body, { mode: 0o600 });
276
+ await writeFile(paths.envelope, envelope, { mode: 0o600 });
277
+ await writeFile(paths.directory, directory, { mode: 0o600 });
278
+ try {
279
+ const reader = await openSegment(dataDir, phrase, segmentId);
280
+ await reader.close();
281
+ return true;
282
+ }
283
+ catch {
284
+ await removeSegment(dataDir, segmentId);
285
+ return false;
286
+ }
287
+ }
288
+ /** A decrypted index with any id this machine must never adopt from a peer's
289
+ * blob dropped: its own local records (already covered by its own segments)
290
+ * and anything it has tombstoned (retired, and not to be resurrected by a
291
+ * copy of the vault from before the retirement). */
292
+ function withoutIds(index, exclude) {
293
+ const archives = {};
294
+ for (const [id, line] of Object.entries(index.archives))
295
+ if (!exclude.has(id))
296
+ archives[id] = line;
297
+ const tokens = {};
298
+ for (const [token, postings] of Object.entries(index.tokens)) {
299
+ const kept = postings.filter((posting) => !exclude.has(posting.split("@")[0]));
300
+ if (kept.length > 0)
301
+ tokens[token] = kept;
302
+ }
303
+ const meta = {};
304
+ for (const [id, value] of Object.entries(index.meta ?? {}))
305
+ if (!exclude.has(id))
306
+ meta[id] = value;
307
+ return { version: 2, extractionPolicy: index.extractionPolicy, builtAt: index.builtAt, archives, tokens, meta };
308
+ }
309
+ // A blob this size is a big vault's own foreground publication (segments
310
+ // mode never produces one this large itself — see the background child's
311
+ // `publishMaxTokens`), not something worth decrypting and re-deriving from
312
+ // inside whatever process happened to call pullSegments (a hook, a daemon
313
+ // tick): it would not fit that process's memory budget.
314
+ const MAX_BLOB_FALLBACK_BYTES = 64 * 1024 * 1024;
315
+ const blobSentinelMachineId = "blob";
316
+ /** The published account index's size, without downloading it: the managed
317
+ * plane's CAS head carries the blob receipt, and a plain object store's
318
+ * envelope carries the chunk table. `null` when neither can say. */
319
+ async function remoteBlobBytes(store, signal) {
320
+ if (store.cas) {
321
+ const head = await store.cas.head({ signal }).catch(() => null);
322
+ if (head && head.status === "ok" && head.head)
323
+ return head.head.receipt.blob.bytes;
324
+ }
325
+ const envelope = await store.meta({ signal }).catch(() => null);
326
+ if (!envelope)
327
+ return null;
328
+ return envelope.chunks.reduce((sum, chunk) => sum + chunk.storedBytes, 0);
329
+ }
330
+ /**
331
+ * Step 2 addendum: a store with no generic keyed storage (managed today)
332
+ * cannot hold segments at all — see docs/design/index-publish.md. Its single
333
+ * legacy blob slot is still readable, though, so this converts THAT into one
334
+ * ordinary `origin: "remote"` segment instead of a segments-mode vault on
335
+ * such a store seeing nothing a peer published. There is exactly one blob,
336
+ * so exactly one such entry ever exists — the sentinel `machineId: "blob"` —
337
+ * replaced whole (never merged with its own previous self) on every
338
+ * conversion, the same "remove old, add new, then clean up files" order
339
+ * `pullSegments`'s per-peer loop uses.
340
+ */
341
+ async function pullFromBlobFallback(dataDir, phrase, store, options) {
342
+ const nothing = { machines: 0, added: 0, removed: 0 };
343
+ let tempPath = null;
344
+ try {
345
+ const [published, pulledBefore] = await Promise.all([readPublishedBlobState(dataDir), readPulledBlobState(dataDir)]);
346
+ // Size first, from metadata alone: a blob too large to convert must not be
347
+ // downloaded at all. The managed plane answers from its CAS head receipt,
348
+ // a plain object store from the envelope's chunk table.
349
+ const remoteBytes = await remoteBlobBytes(store, options.signal);
350
+ if (remoteBytes !== null && remoteBytes > MAX_BLOB_FALLBACK_BYTES)
351
+ return { ...nothing, skipped: true };
352
+ await mkdir(segmentsDir(dataDir), { recursive: true, mode: 0o700 });
353
+ tempPath = join(segmentsDir(dataDir), `.blob-pull-${randomUUID()}.tmp`);
354
+ // The account index is fetched exactly the way the legacy blob build
355
+ // fetched it: a managed plane publishes through its CAS generation lane
356
+ // and leaves the plain `meta`/`blob` objects weeks stale, so asking those
357
+ // directly reads an ancient envelope and concludes there is nothing new.
358
+ // The sink lands the bytes in this temp file; the blob's own path is
359
+ // never written in segments mode.
360
+ let envelope = null;
361
+ let tooLarge = false;
362
+ // "Newer than what we already converted", not "newer than our own blob":
363
+ // in segments mode this machine's blob is only ever a publication of its
364
+ // own segments.
365
+ const knownBuiltAt = pulledBefore?.builtAt ?? published?.builtAt ?? null;
366
+ const outcome = await pullRemoteIndexIfNewerVerified(dataDir, phrase, store, knownBuiltAt, async (remoteEnvelope, ciphertext) => {
367
+ // A second guard for a store whose metadata did not say: the bytes are
368
+ // already here, so this only avoids converting them.
369
+ const storedBytes = remoteEnvelope.chunks.reduce((sum, chunk) => sum + chunk.storedBytes, 0);
370
+ if (storedBytes > MAX_BLOB_FALLBACK_BYTES) {
371
+ tooLarge = true;
372
+ return;
373
+ }
374
+ envelope = remoteEnvelope;
375
+ await writeFile(tempPath, ciphertext, { mode: 0o600 });
376
+ }, { signal: options.signal, onYield: options.onYield }, true);
377
+ if (tooLarge)
378
+ return { ...nothing, skipped: true };
379
+ if (!outcome.pulled || !envelope)
380
+ return nothing;
381
+ const remoteBuiltAt = outcome.remoteBuiltAt ?? envelope.manifest?.createdAt ?? null;
382
+ if (!remoteBuiltAt)
383
+ return nothing;
384
+ // The identical safe reader loadContentIndex uses for its own local
385
+ // blob — streaming with a bounded legacy fallback — pointed at the
386
+ // downloaded temp instead: this is never written to the blob's own path.
387
+ const decoded = normalizeContentIndex(await loadContentIndexFromFile(envelope, tempPath, phrase, { signal: options.signal, onYield: options.onYield }));
388
+ const { listArchives } = await import("./vault.js");
389
+ const manifest = await readManifest(dataDir);
390
+ const exclude = new Set([...(await listArchives(dataDir)).map((record) => record.id), ...manifest.tombstones]);
391
+ const filtered = withoutIds(decoded, exclude);
392
+ const remoteArchiveIds = Object.keys(filtered.archives);
393
+ if (remoteArchiveIds.length === 0) {
394
+ // Every archive this blob carries is already ours or tombstoned —
395
+ // including a blob that is really just OUR OWN publication echoed
396
+ // back by a peer that also has no `segments` support: that peer's own
397
+ // publish step merges everything it holds (its local rows plus
398
+ // whatever it converted from us) into the one shared slot, unasked,
399
+ // which changes the slot's builtAt without changing what it means for
400
+ // ITS bytes to be "ours". Filtering by content rather than trusting
401
+ // builtAt alone is what makes "a build on A never adopts its own
402
+ // blob" hold even through that echo. Nothing to convert, but this
403
+ // builtAt has been seen, so the next unchanged call is a fast no-op.
404
+ await writePulledBlobState(dataDir, { version: 1, builtAt: remoteBuiltAt });
405
+ return { machines: 1, added: 0, removed: 0 };
406
+ }
407
+ const { segmentRowsFromIndex, writeCoverageFromManifest } = await import("./index-store.js");
408
+ const entry = await writeSegment(dataDir, phrase, {
409
+ segmentId: randomUUID(),
410
+ builtAt: new Date().toISOString(),
411
+ rows: segmentRowsFromIndex(filtered),
412
+ });
413
+ const withEntry = {
414
+ ...entry, origin: "remote", machineId: blobSentinelMachineId, archiveIds: [], remoteArchiveIds,
415
+ };
416
+ let previousId = null;
417
+ await updateManifest(dataDir, (current) => {
418
+ const existing = current.segments.find((segment) => segment.origin === "remote" && segment.machineId === blobSentinelMachineId);
419
+ previousId = existing?.id ?? null;
420
+ return {
421
+ ...current,
422
+ segments: [
423
+ ...current.segments.filter((segment) => !(segment.origin === "remote" && segment.machineId === blobSentinelMachineId)),
424
+ withEntry,
425
+ ],
426
+ };
427
+ });
428
+ if (previousId)
429
+ await removeSegment(dataDir, previousId);
430
+ await writePulledBlobState(dataDir, { version: 1, builtAt: remoteBuiltAt });
431
+ await writeCoverageFromManifest(dataDir);
432
+ await options.onYield?.();
433
+ return { machines: 1, added: 1, removed: previousId ? 1 : 0 };
434
+ }
435
+ catch (error) {
436
+ if (options.signal?.aborted)
437
+ throw error;
438
+ // A bad transfer, a blob that will not decrypt, or a network hiccup is
439
+ // not a hard failure: the next call's meta() check simply tries again.
440
+ return nothing;
441
+ }
442
+ finally {
443
+ if (tempPath)
444
+ await rm(tempPath, { force: true }).catch(() => undefined);
445
+ }
446
+ }
447
+ /**
448
+ * Reads the shared `heads.json`, then for every other machine whose
449
+ * generation has moved since this machine last reconciled it: fetches that
450
+ * machine's `head.json`, downloads whichever listed segments are not already
451
+ * local, verifies each, and replaces that machine's `origin: "remote"`
452
+ * entries in the local manifest with the current set in one `updateManifest`
453
+ * — dropping local files for ids it no longer lists only after the manifest
454
+ * no longer references them. A machine whose `head.json` has vanished (404)
455
+ * loses its remote entries entirely.
456
+ *
457
+ * A store with no generic keyed storage (managed today) has no `<machineId>`
458
+ * objects to read at all; `pullFromBlobFallback` converts its single legacy
459
+ * blob slot instead (step 2 addendum — see docs/design/index-publish.md).
460
+ */
461
+ export async function pullSegments(dataDir, phrase, store, options = {}) {
462
+ if (!store)
463
+ return { machines: 0, added: 0, removed: 0, skipped: true };
464
+ if (!store.segments)
465
+ return pullFromBlobFallback(dataDir, phrase, store, options);
466
+ const segments = store.segments;
467
+ const machine = await machineIdentity(dataDir, undefined, { signal: options.signal }).catch(() => null);
468
+ if (!machine)
469
+ return { machines: 0, added: 0, removed: 0, skipped: true };
470
+ const thisMachineId = String(machine.device_id);
471
+ const heads = parseHeadsFile(await segments.get("heads.json", { signal: options.signal }));
472
+ const pulled = await readPulledState(dataDir);
473
+ let machinesProcessed = 0;
474
+ let added = 0;
475
+ let removed = 0;
476
+ for (const [peerId, summary] of Object.entries(heads.machines)) {
477
+ options.signal?.throwIfAborted();
478
+ if (peerId === thisMachineId)
479
+ continue;
480
+ if (pulled.machines[peerId] === summary.generation)
481
+ continue;
482
+ // Re-read every iteration: an earlier peer in this same loop may have
483
+ // just rewritten the manifest, and `priorForPeer`/`knownElsewhere` below
484
+ // must reflect it.
485
+ const manifest = await readManifest(dataDir);
486
+ const priorForPeer = manifest.segments.filter((entry) => entry.origin === "remote" && entry.machineId === peerId);
487
+ const priorById = new Map(priorForPeer.map((entry) => [entry.id, entry]));
488
+ // A ready-made SegmentEntry never overwritten below: this peer's own
489
+ // segment ids the manifest already carries some other way (never
490
+ // expected — ids are random — but never trusted blindly either).
491
+ const knownElsewhere = new Set(manifest.segments.filter((entry) => !(entry.origin === "remote" && entry.machineId === peerId)).map((entry) => entry.id));
492
+ const headRaw = await segments.get(`${peerId}/head.json`, { signal: options.signal });
493
+ const head = headRaw ? parseMachineHead(headRaw) : null;
494
+ // The peer's FULL current set, not just what changed this call: a
495
+ // tombstone-only republish (nothing added or removed) must still keep
496
+ // every segment this machine already trusts from this peer, or it would
497
+ // read as "everything just went stale".
498
+ const current = [];
499
+ if (head) {
500
+ for (const listed of head.segments) {
501
+ options.signal?.throwIfAborted();
502
+ const existing = priorById.get(listed.id);
503
+ if (existing) {
504
+ current.push(existing);
505
+ continue;
506
+ }
507
+ if (knownElsewhere.has(listed.id))
508
+ continue;
509
+ if (!await downloadAndVerifySegment(dataDir, phrase, segments, peerId, listed.id, options.signal))
510
+ continue;
511
+ current.push({
512
+ id: listed.id, builtAt: listed.builtAt, archives: listed.archives, tokens: listed.tokens, bytes: listed.bytes,
513
+ origin: "remote", machineId: peerId, archiveIds: [], remoteArchiveIds: listed.archiveIds,
514
+ });
515
+ added += 1;
516
+ await options.onYield?.();
517
+ }
518
+ }
519
+ const keptIds = new Set(current.map((entry) => entry.id));
520
+ const staleIds = priorForPeer.map((entry) => entry.id).filter((id) => !keptIds.has(id));
521
+ await updateManifest(dataDir, (manifestNow) => ({
522
+ ...manifestNow,
523
+ segments: [
524
+ ...manifestNow.segments.filter((entry) => !(entry.origin === "remote" && entry.machineId === peerId)),
525
+ ...current,
526
+ ],
527
+ tombstones: head ? [...new Set([...manifestNow.tombstones, ...head.tombstones])] : manifestNow.tombstones,
528
+ }));
529
+ // Only once the manifest no longer points at them: a crash before this
530
+ // point leaves an orphan file for the next compaction's orphan sweep.
531
+ for (const id of staleIds) {
532
+ await removeSegment(dataDir, id);
533
+ removed += 1;
534
+ }
535
+ pulled.machines[peerId] = summary.generation;
536
+ machinesProcessed += 1;
537
+ }
538
+ await writeJsonFile(pulledStatePath(dataDir), pulled);
539
+ return { machines: machinesProcessed, added, removed };
540
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * One daemon-owned historical child gets one private scratch namespace.
3
+ * Foreground/manual builders deliberately keep using the ordinary index
4
+ * directory, so recovery of a dead child can never unlink their live files.
5
+ */
6
+ export declare function backgroundIndexScratchDirectory(dataDir: string, generation: string): string;
7
+ /** Create or reopen only the exact namespace assigned to this worker. */
8
+ export declare function ensureBackgroundIndexScratch(dataDir: string, generation: string): Promise<string>;
9
+ /**
10
+ * Remove scratch for one process generation after its durable child barrier
11
+ * has proved that exact worker dead. The caller owns that proof; this helper
12
+ * intentionally cannot scan legacy/global names because another foreground
13
+ * builder may be using them.
14
+ *
15
+ * Preflight every entry before deleting any. An unknown name, symlink or
16
+ * directory leaves the entire namespace intact for diagnosis. Concurrent
17
+ * successor contenders may safely repeat the same cleanup.
18
+ */
19
+ export declare function cleanupInterruptedIndexScratch(dataDir: string, generation: string): Promise<void>;