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,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The segment manifest — the list of immutable sealed segments that make up
|
|
3
|
+
* the content index — and the compaction that keeps that list short.
|
|
4
|
+
*
|
|
5
|
+
* Sealed segments never change, so the index over them need not be rewritten
|
|
6
|
+
* either: appending memory means appending a segment, removing an archive
|
|
7
|
+
* means tombstoning it, and staying fast means occasionally streaming a few
|
|
8
|
+
* small segments into one bigger immutable one. The manifest itself is the
|
|
9
|
+
* only thing ever rewritten, and every rewrite is temp-write + rename under a
|
|
10
|
+
* lock, so two processes can never interleave a read-modify-write.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
16
|
+
import { fail } from "./errors.js";
|
|
17
|
+
import { MAX_POSTINGS_PER_TOKEN, } from "./index-segment-types.js";
|
|
18
|
+
function manifestPath(dataDir) {
|
|
19
|
+
return join(dataDir, "index", "segments", "manifest.json");
|
|
20
|
+
}
|
|
21
|
+
function emptyManifest() {
|
|
22
|
+
return { version: 1, generation: "", segments: [], tombstones: [] };
|
|
23
|
+
}
|
|
24
|
+
/** Atomic publication that never leaves this call's temp behind on error.
|
|
25
|
+
* Mirrors `writeIndexFile` in src/search.ts, which is not exported. */
|
|
26
|
+
async function writeManifestFile(target, contents) {
|
|
27
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
28
|
+
try {
|
|
29
|
+
await writeFile(temp, contents, { mode: 0o600 });
|
|
30
|
+
await rename(temp, target);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
// After a successful rename this is an ENOENT no-op; after a failed write
|
|
34
|
+
// or rename it removes exactly the temp this call created.
|
|
35
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Mirrors the lock discipline `withContextStateLock` (src/agent-context.ts)
|
|
39
|
+
// uses, with the numbers this spec calls for: a bounded wait via short
|
|
40
|
+
// sleeps, and stealing only a lock whose owner is provably gone.
|
|
41
|
+
const LOCK_WAIT_BUDGET_MS = 30_000;
|
|
42
|
+
const LOCK_POLL_MS = 20;
|
|
43
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
44
|
+
/** True if a stale lock was removed and the caller should retry the open. */
|
|
45
|
+
async function stealStaleManifestLock(lockPath) {
|
|
46
|
+
try {
|
|
47
|
+
const owner = JSON.parse(await readFile(lockPath, "utf8"));
|
|
48
|
+
let alive = typeof owner.pid === "number";
|
|
49
|
+
if (alive) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(owner.pid, 0);
|
|
52
|
+
}
|
|
53
|
+
catch (signalError) {
|
|
54
|
+
alive = signalError.code === "EPERM";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const age = Date.now() - (typeof owner.at === "number" ? owner.at : (await stat(lockPath)).mtimeMs);
|
|
58
|
+
if (alive && age <= LOCK_STALE_MS)
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// `open(path, "wx")` makes the lock visible before its tiny owner record
|
|
63
|
+
// is fully written. An unreadable *fresh* file is held, not stale.
|
|
64
|
+
const age = await stat(lockPath).then((file) => Date.now() - file.mtimeMs).catch(() => Infinity);
|
|
65
|
+
if (age <= LOCK_STALE_MS)
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
await unlink(lockPath).catch(() => undefined);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
async function withManifestLock(path, work) {
|
|
72
|
+
const lockPath = `${path}.lock`;
|
|
73
|
+
const token = randomUUID();
|
|
74
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
75
|
+
const deadline = Date.now() + LOCK_WAIT_BUDGET_MS;
|
|
76
|
+
let handle;
|
|
77
|
+
for (;;) {
|
|
78
|
+
try {
|
|
79
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
80
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), token }));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== "EEXIST")
|
|
85
|
+
throw error;
|
|
86
|
+
if (await stealStaleManifestLock(lockPath))
|
|
87
|
+
continue;
|
|
88
|
+
if (Date.now() >= deadline)
|
|
89
|
+
fail("internal", "timed out waiting for the segment manifest lock", { path: lockPath });
|
|
90
|
+
await delay(LOCK_POLL_MS);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return await work();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await handle.close().catch(() => undefined);
|
|
98
|
+
try {
|
|
99
|
+
const owner = JSON.parse(await readFile(lockPath, "utf8"));
|
|
100
|
+
if (owner.token === token)
|
|
101
|
+
await unlink(lockPath);
|
|
102
|
+
}
|
|
103
|
+
catch { /* a stale-lock recovery already replaced or removed it */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function parseManifest(raw, path) {
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(raw);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return fail("internal", "segment manifest is corrupt", { path });
|
|
113
|
+
}
|
|
114
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
115
|
+
return fail("internal", "segment manifest is corrupt", { path });
|
|
116
|
+
}
|
|
117
|
+
const value = parsed;
|
|
118
|
+
if (!Array.isArray(value.segments) || !Array.isArray(value.tombstones)) {
|
|
119
|
+
return fail("internal", "segment manifest is corrupt", { path });
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
version: 1,
|
|
123
|
+
generation: typeof value.generation === "string" ? value.generation : "",
|
|
124
|
+
segments: value.segments,
|
|
125
|
+
tombstones: value.tombstones.filter((id) => typeof id === "string"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function readManifest(dataDir) {
|
|
129
|
+
const path = manifestPath(dataDir);
|
|
130
|
+
try {
|
|
131
|
+
return parseManifest(await readFile(path, "utf8"), path);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === "ENOENT")
|
|
135
|
+
return emptyManifest();
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Read-modify-write under the manifest lock. Every successful write gets a
|
|
140
|
+
* fresh `generation`, whether or not `mutate` actually changed anything. */
|
|
141
|
+
export async function updateManifest(dataDir, mutate) {
|
|
142
|
+
const path = manifestPath(dataDir);
|
|
143
|
+
return withManifestLock(path, async () => {
|
|
144
|
+
const current = await readManifest(dataDir);
|
|
145
|
+
const next = await mutate(current);
|
|
146
|
+
const stamped = { ...next, version: 1, generation: randomUUID() };
|
|
147
|
+
await writeManifestFile(path, JSON.stringify(stamped, null, 2) + "\n");
|
|
148
|
+
return stamped;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
export async function appendSegment(dataDir, entry) {
|
|
152
|
+
return updateManifest(dataDir, (current) => {
|
|
153
|
+
if (current.segments.some((segment) => segment.id === entry.id)) {
|
|
154
|
+
fail("invalid_argument", `segment "${entry.id}" is already present in the manifest`, { segmentId: entry.id });
|
|
155
|
+
}
|
|
156
|
+
return { ...current, segments: [...current.segments, entry] };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export async function retireArchives(dataDir, archiveIds) {
|
|
160
|
+
return updateManifest(dataDir, (current) => {
|
|
161
|
+
const tombstones = new Set(current.tombstones);
|
|
162
|
+
for (const id of archiveIds)
|
|
163
|
+
tombstones.add(id);
|
|
164
|
+
return { ...current, tombstones: [...tombstones] };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const DEFAULT_MAX_SEGMENTS = 8;
|
|
168
|
+
const DEFAULT_MAX_SMALL_BYTES = 8 * 1024 * 1024;
|
|
169
|
+
const MIN_SMALL_GROUP = 4;
|
|
170
|
+
/**
|
|
171
|
+
* Ids to merge, or `[]`. A "remote" segment (the whole account index pulled
|
|
172
|
+
* from the cloud) is never a candidate: it never merges with a locally-built
|
|
173
|
+
* one, and it does not count toward the segment-count trigger either.
|
|
174
|
+
*/
|
|
175
|
+
export function planCompaction(manifest, options) {
|
|
176
|
+
const maxSegments = options?.maxSegments ?? DEFAULT_MAX_SEGMENTS;
|
|
177
|
+
const maxSmallBytes = options?.maxSmallBytes ?? DEFAULT_MAX_SMALL_BYTES;
|
|
178
|
+
const local = manifest.segments.filter((segment) => segment.origin !== "remote");
|
|
179
|
+
const ids = new Set();
|
|
180
|
+
if (local.length > maxSegments) {
|
|
181
|
+
let largest = local[0];
|
|
182
|
+
for (const segment of local)
|
|
183
|
+
if (segment.bytes > largest.bytes)
|
|
184
|
+
largest = segment;
|
|
185
|
+
for (const segment of local)
|
|
186
|
+
if (segment.id !== largest.id)
|
|
187
|
+
ids.add(segment.id);
|
|
188
|
+
}
|
|
189
|
+
const small = local.filter((segment) => segment.bytes < maxSmallBytes);
|
|
190
|
+
if (small.length >= MIN_SMALL_GROUP)
|
|
191
|
+
for (const segment of small)
|
|
192
|
+
ids.add(segment.id);
|
|
193
|
+
if (ids.size < 2)
|
|
194
|
+
return [];
|
|
195
|
+
const order = new Map(manifest.segments.map((segment, index) => [segment.id, index]));
|
|
196
|
+
return [...ids].sort((a, b) => order.get(a) - order.get(b));
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Streaming merge
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
const ROW_YIELD_INTERVAL = 2_048;
|
|
202
|
+
// A captured tool artifact, not a real session line; a real summary always
|
|
203
|
+
// beats it regardless of which segment it came from.
|
|
204
|
+
const JUNK_SUMMARY = /^\s*<(?:system-reminder|local-command-stdout|task-notification|command-name|command-message)/;
|
|
205
|
+
function isJunkSummary(value) {
|
|
206
|
+
return typeof value !== "string" || JUNK_SUMMARY.test(value);
|
|
207
|
+
}
|
|
208
|
+
/** A value that isn't a captured artifact beats one that is; otherwise the
|
|
209
|
+
* later (by manifest order) segment's value wins, like every other field. */
|
|
210
|
+
function betterSummary(existing, incoming) {
|
|
211
|
+
if (existing === undefined)
|
|
212
|
+
return incoming;
|
|
213
|
+
if (incoming === undefined)
|
|
214
|
+
return existing;
|
|
215
|
+
if (isJunkSummary(incoming) && !isJunkSummary(existing))
|
|
216
|
+
return existing;
|
|
217
|
+
return incoming;
|
|
218
|
+
}
|
|
219
|
+
function unionArrayField(existing, incoming) {
|
|
220
|
+
if (!Array.isArray(incoming))
|
|
221
|
+
return existing;
|
|
222
|
+
if (!Array.isArray(existing))
|
|
223
|
+
return incoming;
|
|
224
|
+
const seen = new Set(existing.map((item) => JSON.stringify(item)));
|
|
225
|
+
const merged = existing.slice();
|
|
226
|
+
for (const item of incoming) {
|
|
227
|
+
const key = JSON.stringify(item);
|
|
228
|
+
if (!seen.has(key)) {
|
|
229
|
+
seen.add(key);
|
|
230
|
+
merged.push(item);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return merged;
|
|
234
|
+
}
|
|
235
|
+
/** Later (by manifest order) wins field by field, except `summary` and the
|
|
236
|
+
* unioned `commits`/`files` arrays. */
|
|
237
|
+
function mergeMeta(base, incoming) {
|
|
238
|
+
const merged = { ...base };
|
|
239
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
240
|
+
if (key === "summary")
|
|
241
|
+
merged[key] = betterSummary(merged[key], value);
|
|
242
|
+
else if (key === "commits" || key === "files")
|
|
243
|
+
merged[key] = unionArrayField(merged[key], value);
|
|
244
|
+
else
|
|
245
|
+
merged[key] = value;
|
|
246
|
+
}
|
|
247
|
+
return merged;
|
|
248
|
+
}
|
|
249
|
+
function postingArchiveId(posting) {
|
|
250
|
+
return posting.split("@")[0];
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Ascending by the covered archive's `archivedAt`. Postings with no known
|
|
254
|
+
* date sort first (oldest), so capping to the newest never keeps a guess over
|
|
255
|
+
* a dated session; ties (including every undated posting, among themselves)
|
|
256
|
+
* keep the stable union order they arrived in.
|
|
257
|
+
*/
|
|
258
|
+
function chronologicalPostings(postings, archivedAt) {
|
|
259
|
+
return postings
|
|
260
|
+
.map((posting, position) => ({ posting, position, at: archivedAt.get(postingArchiveId(posting)) }))
|
|
261
|
+
.sort((left, right) => {
|
|
262
|
+
if (left.at && right.at)
|
|
263
|
+
return left.at.localeCompare(right.at) || left.position - right.position;
|
|
264
|
+
if (left.at)
|
|
265
|
+
return 1;
|
|
266
|
+
if (right.at)
|
|
267
|
+
return -1;
|
|
268
|
+
return left.position - right.position;
|
|
269
|
+
})
|
|
270
|
+
.map((entry) => entry.posting);
|
|
271
|
+
}
|
|
272
|
+
async function openCursor(rows) {
|
|
273
|
+
const iterator = rows[Symbol.asyncIterator]();
|
|
274
|
+
const { value, done } = await iterator.next();
|
|
275
|
+
return { iterator, current: done ? null : value };
|
|
276
|
+
}
|
|
277
|
+
async function advanceCursor(cursor) {
|
|
278
|
+
const { value, done } = await cursor.iterator.next();
|
|
279
|
+
cursor.current = done ? null : value;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The compaction merge on its own: the union/cap/tombstone/summary rules that
|
|
283
|
+
* turn several sealed segments' rows into one segment's worth, streamed so no
|
|
284
|
+
* more than one batch of archive or token rows is held at once.
|
|
285
|
+
*
|
|
286
|
+
* `readers` must be given oldest first (manifest order) — every "later wins"
|
|
287
|
+
* rule below is defined relative to that order. Meta rows are the one
|
|
288
|
+
* exception to streaming: a segment holds only a few hundred bytes of
|
|
289
|
+
* metadata per archive, so the whole merged map is kept in memory (also
|
|
290
|
+
* needed to rank token postings by `archivedAt`) and emitted last.
|
|
291
|
+
*/
|
|
292
|
+
export async function* mergeSegmentRows(readers, tombstones, options) {
|
|
293
|
+
let produced = 0;
|
|
294
|
+
const maybeYield = async () => {
|
|
295
|
+
produced += 1;
|
|
296
|
+
if (produced % ROW_YIELD_INTERVAL === 0)
|
|
297
|
+
await options?.onYield?.();
|
|
298
|
+
};
|
|
299
|
+
// "m": collected first (small; also feeds the "t" ranking below) but
|
|
300
|
+
// emitted last, in region order.
|
|
301
|
+
const meta = new Map();
|
|
302
|
+
for (const reader of readers) {
|
|
303
|
+
for await (const row of reader.rows("meta", { signal: options?.signal })) {
|
|
304
|
+
options?.signal?.throwIfAborted();
|
|
305
|
+
const [, archiveId, metaObject] = row;
|
|
306
|
+
if (tombstones.has(archiveId))
|
|
307
|
+
continue;
|
|
308
|
+
const existing = meta.get(archiveId);
|
|
309
|
+
meta.set(archiveId, existing ? mergeMeta(existing, metaObject) : metaObject);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const archivedAt = new Map();
|
|
313
|
+
for (const [archiveId, metaObject] of meta) {
|
|
314
|
+
const value = metaObject.archivedAt;
|
|
315
|
+
if (typeof value === "string")
|
|
316
|
+
archivedAt.set(archiveId, value);
|
|
317
|
+
}
|
|
318
|
+
// "a": k-way merge by archive id; the richest (longest) row wins, later
|
|
319
|
+
// segment wins ties.
|
|
320
|
+
const archiveCursors = await Promise.all(readers.map((reader) => openCursor(reader.rows("archives", { signal: options?.signal }))));
|
|
321
|
+
for (;;) {
|
|
322
|
+
options?.signal?.throwIfAborted();
|
|
323
|
+
let minId = null;
|
|
324
|
+
for (const cursor of archiveCursors) {
|
|
325
|
+
const id = cursor.current?.[1];
|
|
326
|
+
if (id !== undefined && (minId === null || id < minId))
|
|
327
|
+
minId = id;
|
|
328
|
+
}
|
|
329
|
+
if (minId === null)
|
|
330
|
+
break;
|
|
331
|
+
let winner = null;
|
|
332
|
+
for (const cursor of archiveCursors) {
|
|
333
|
+
if (!cursor.current || cursor.current[1] !== minId)
|
|
334
|
+
continue;
|
|
335
|
+
if (!winner || cursor.current.length >= winner.length)
|
|
336
|
+
winner = cursor.current;
|
|
337
|
+
await advanceCursor(cursor);
|
|
338
|
+
}
|
|
339
|
+
if (!tombstones.has(minId)) {
|
|
340
|
+
yield winner;
|
|
341
|
+
await maybeYield();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// "t": k-way merge by token; union the postings, drop tombstoned ones, cap
|
|
345
|
+
// to the newest MAX_POSTINGS_PER_TOKEN. A token left with none is dropped.
|
|
346
|
+
const tokenCursors = await Promise.all(readers.map((reader) => openCursor(reader.rows("tokens", { signal: options?.signal }))));
|
|
347
|
+
for (;;) {
|
|
348
|
+
options?.signal?.throwIfAborted();
|
|
349
|
+
let minToken = null;
|
|
350
|
+
for (const cursor of tokenCursors) {
|
|
351
|
+
const token = cursor.current?.[1];
|
|
352
|
+
if (token !== undefined && (minToken === null || token < minToken))
|
|
353
|
+
minToken = token;
|
|
354
|
+
}
|
|
355
|
+
if (minToken === null)
|
|
356
|
+
break;
|
|
357
|
+
const seen = new Set();
|
|
358
|
+
const union = [];
|
|
359
|
+
for (const cursor of tokenCursors) {
|
|
360
|
+
if (!cursor.current || cursor.current[1] !== minToken)
|
|
361
|
+
continue;
|
|
362
|
+
for (const posting of cursor.current[2])
|
|
363
|
+
if (!seen.has(posting)) {
|
|
364
|
+
seen.add(posting);
|
|
365
|
+
union.push(posting);
|
|
366
|
+
}
|
|
367
|
+
await advanceCursor(cursor);
|
|
368
|
+
}
|
|
369
|
+
const live = union.filter((posting) => !tombstones.has(postingArchiveId(posting)));
|
|
370
|
+
if (live.length) {
|
|
371
|
+
const ranked = chronologicalPostings(live, archivedAt);
|
|
372
|
+
const kept = ranked.length > MAX_POSTINGS_PER_TOKEN ? ranked.slice(-MAX_POSTINGS_PER_TOKEN) : ranked;
|
|
373
|
+
yield ["t", minToken, kept];
|
|
374
|
+
await maybeYield();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const archiveId of [...meta.keys()].sort()) {
|
|
378
|
+
yield ["m", archiveId, meta.get(archiveId)];
|
|
379
|
+
await maybeYield();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** Replace every id in `mergedIds` with `entry`, at the position the newest
|
|
383
|
+
* (last, by manifest/newest-last order) of them held; other segments keep
|
|
384
|
+
* their relative order. */
|
|
385
|
+
function replaceCompactedSegments(segments, mergedIds, entry) {
|
|
386
|
+
let newestIndex = -1;
|
|
387
|
+
segments.forEach((segment, index) => { if (mergedIds.has(segment.id))
|
|
388
|
+
newestIndex = index; });
|
|
389
|
+
const result = [];
|
|
390
|
+
segments.forEach((segment, index) => {
|
|
391
|
+
if (!mergedIds.has(segment.id)) {
|
|
392
|
+
result.push(segment);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (index === newestIndex)
|
|
396
|
+
result.push(entry);
|
|
397
|
+
});
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
export async function compactSegments(dataDir, phrase, segmentIds, tools, options) {
|
|
401
|
+
options?.signal?.throwIfAborted();
|
|
402
|
+
// Nothing to merge into: the position-of-the-newest-merged-segment
|
|
403
|
+
// placement below has no meaning, and the new segment `write` would produce
|
|
404
|
+
// could never be referenced by the manifest.
|
|
405
|
+
if (segmentIds.length === 0)
|
|
406
|
+
fail("invalid_argument", "compaction requires at least one segment id", { segmentIds });
|
|
407
|
+
const manifest = await readManifest(dataDir);
|
|
408
|
+
const position = new Map(manifest.segments.map((segment, index) => [segment.id, index]));
|
|
409
|
+
for (const id of segmentIds) {
|
|
410
|
+
if (!position.has(id))
|
|
411
|
+
fail("invalid_argument", `segment "${id}" is not in the manifest`, { segmentId: id });
|
|
412
|
+
}
|
|
413
|
+
// Merge rules ("later wins") are defined relative to manifest order, not
|
|
414
|
+
// whatever order the caller happened to list ids in.
|
|
415
|
+
const mergedIds = new Set(segmentIds);
|
|
416
|
+
const orderedIds = [...mergedIds].sort((a, b) => position.get(a) - position.get(b));
|
|
417
|
+
const tombstones = new Set(manifest.tombstones);
|
|
418
|
+
const readers = [];
|
|
419
|
+
try {
|
|
420
|
+
for (const id of orderedIds)
|
|
421
|
+
readers.push(await tools.open(dataDir, phrase, id));
|
|
422
|
+
const entry = await tools.write(dataDir, phrase, { segmentId: randomUUID(), builtAt: new Date().toISOString(), rows: mergeSegmentRows(readers, tombstones, options) }, options);
|
|
423
|
+
// Only after the manifest is durably updated do the old segments go —
|
|
424
|
+
// a crash between these two steps leaves the old segments plus one
|
|
425
|
+
// unreferenced new one, which `orphanSegmentIds` is for.
|
|
426
|
+
await updateManifest(dataDir, (current) => ({
|
|
427
|
+
...current,
|
|
428
|
+
segments: replaceCompactedSegments(current.segments, mergedIds, entry),
|
|
429
|
+
}));
|
|
430
|
+
for (const id of orderedIds)
|
|
431
|
+
await tools.remove(dataDir, id);
|
|
432
|
+
return entry;
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
for (const reader of readers)
|
|
436
|
+
await reader.close();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Segment ids present on disk that the manifest does not list. */
|
|
440
|
+
export async function orphanSegmentIds(dataDir, listed) {
|
|
441
|
+
const manifest = await readManifest(dataDir);
|
|
442
|
+
const known = new Set(manifest.segments.map((segment) => segment.id));
|
|
443
|
+
return (await listed(dataDir)).filter((id) => !known.has(id));
|
|
444
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ContentIndex } from "./search.js";
|
|
2
|
+
/**
|
|
3
|
+
* The part of a content index whose meaning is committed by a publication
|
|
4
|
+
* receipt. The extraction policy is required and included in every entry;
|
|
5
|
+
* `builtAt` is intentionally absent because republishing identical search
|
|
6
|
+
* knowledge later must not invalidate every archive requirement.
|
|
7
|
+
*/
|
|
8
|
+
export type IndexPublicationProofSource = Pick<ContentIndex, "archives" | "tokens" | "meta"> & {
|
|
9
|
+
/** The transcript-extraction semantics used to produce these postings. */
|
|
10
|
+
extractionPolicy: number;
|
|
11
|
+
};
|
|
12
|
+
export type IndexPublicationProofOptions = {
|
|
13
|
+
extractionPolicy: number;
|
|
14
|
+
/** Writers use the lexical default. `wire` exists only for authenticated
|
|
15
|
+
* legacy-v3 readers which independently prove token-row uniqueness. */
|
|
16
|
+
tokenOrder?: "lexical" | "wire";
|
|
17
|
+
};
|
|
18
|
+
export type IndexPublicationCoverage = Record<string, string>;
|
|
19
|
+
export type IndexPublicationProof = {
|
|
20
|
+
/** Every archive/catalog row committed by the generation, including bases. */
|
|
21
|
+
coverage: IndexPublicationCoverage;
|
|
22
|
+
/** Zero means catalog-only; a directly searchable head must be greater than zero. */
|
|
23
|
+
effectivePostingCounts: Record<string, number>;
|
|
24
|
+
};
|
|
25
|
+
export declare const INDEX_PUBLICATION_DIGEST_ALGORITHM: "sha256";
|
|
26
|
+
export declare const INDEX_PUBLICATION_MAX_POSTINGS_PER_TOKEN = 256;
|
|
27
|
+
export declare class IndexPublicationProofError extends Error {
|
|
28
|
+
readonly code = "INDEX_PUBLICATION_PROOF_INVALID";
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Incremental coverage for the exact rows an index checkpoint emits.
|
|
33
|
+
*
|
|
34
|
+
* The writer should call `addArchive` from its archive-row loop, `addToken`
|
|
35
|
+
* with the already chronological/capped posting array used for that exact
|
|
36
|
+
* token row, `addMetadata` from its metadata-row loop, then `finish`. Construct
|
|
37
|
+
* it with the same positive extraction policy written in the JSONL header. This
|
|
38
|
+
* avoids cloning or traversing a 600 MB index a second time, and it cannot race
|
|
39
|
+
* indexing lanes because the proof consumes the same values as the JSONL row.
|
|
40
|
+
* Token rows must be supplied in lexical order by default; enforcing that
|
|
41
|
+
* keeps new coverage independent of object insertion order. The authenticated
|
|
42
|
+
* legacy-v3 reader may explicitly preserve wire order after independently
|
|
43
|
+
* proving uniqueness with bounded state. `finish` also returns exact posting
|
|
44
|
+
* counts: zero is valid catalog coverage for a delta base, but must not be used
|
|
45
|
+
* as proof that a requested/head archive is directly searchable.
|
|
46
|
+
*/
|
|
47
|
+
export declare class IndexPublicationCoverageBuilder {
|
|
48
|
+
#private;
|
|
49
|
+
constructor(options: IndexPublicationProofOptions);
|
|
50
|
+
addArchive(refValue: string, row: readonly string[]): void;
|
|
51
|
+
/** Add one effective `t` row, after chronology and the 256-entry cap. */
|
|
52
|
+
addToken(token: string, postings: readonly string[]): void;
|
|
53
|
+
addMetadata(refValue: string, value: unknown): void;
|
|
54
|
+
finish(): IndexPublicationProof;
|
|
55
|
+
}
|
|
56
|
+
export declare function createIndexPublicationCoverageBuilder(options: IndexPublicationProofOptions): IndexPublicationCoverageBuilder;
|
|
57
|
+
/**
|
|
58
|
+
* Produce the exact per-archive semantic coverage committed by a search-index
|
|
59
|
+
* generation.
|
|
60
|
+
*
|
|
61
|
+
* Each SHA-256 binds the extraction policy, archive reference, its complete
|
|
62
|
+
* archive row, matching metadata (missing and `{}` differ), and every
|
|
63
|
+
* token/posting occurrence that
|
|
64
|
+
* survives the real JSONL serializer's chronological 256-posting cap. Object
|
|
65
|
+
* key order never matters. Duplicate posting occurrences do matter. Orphan or
|
|
66
|
+
* malformed data throws, so damaged input can never become positive proof.
|
|
67
|
+
* Catalog-only delta bases remain in `coverage` with a posting count of zero;
|
|
68
|
+
* callers authorizing reclaim must demand a positive count for the logical
|
|
69
|
+
* session head and exact coverage for every base needed to restore it.
|
|
70
|
+
*
|
|
71
|
+
* Runtime is O(A log A + T log T + sum(P_t log P_t)): one traversal of the
|
|
72
|
+
* sorted archive/token maps and each sorted posting list, with O(A) hash state.
|
|
73
|
+
*/
|
|
74
|
+
export declare function buildIndexPublicationProof(index: IndexPublicationProofSource): IndexPublicationProof;
|
|
75
|
+
/** Convenience for publishers that only need the canonical CAS coverage map. */
|
|
76
|
+
export declare function buildIndexPublicationCoverage(index: IndexPublicationProofSource): IndexPublicationCoverage;
|
|
77
|
+
/** Canonical, lexically sorted object JSON suitable for an opaque CAS field. */
|
|
78
|
+
export declare function canonicalIndexPublicationCoverageJson(value: unknown): string;
|
|
79
|
+
/**
|
|
80
|
+
* Parse only the canonical representation produced above. Byte-for-byte
|
|
81
|
+
* reserialization rejects duplicate JSON keys as well as ambiguous whitespace
|
|
82
|
+
* or ordering, instead of letting JSON.parse silently keep a last value.
|
|
83
|
+
*/
|
|
84
|
+
export declare function parseIndexPublicationCoverageJson(raw: string): IndexPublicationCoverage;
|