sealkeep 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /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>;
|