knodin 0.8.4 → 0.8.6

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 (34) hide show
  1. package/dist/bin/cli.js +272 -24
  2. package/dist/src/cli-model.js +26 -0
  3. package/dist/src/engine/candidate-database.js +167 -0
  4. package/dist/src/engine/embeddings.js +8 -5
  5. package/dist/src/engine/index.js +1244 -305
  6. package/dist/src/engine/prune.js +8 -0
  7. package/dist/src/file-metrics.js +153 -0
  8. package/dist/src/repair-lease.js +8 -5
  9. package/dist/src/response-budget.js +2 -1
  10. package/dist/src/shared-index/artifact.js +65 -0
  11. package/dist/src/shared-index/cache.js +267 -0
  12. package/dist/src/shared-index/compatibility.js +70 -0
  13. package/dist/src/shared-index/config.js +224 -0
  14. package/dist/src/shared-index/contract.js +117 -0
  15. package/dist/src/shared-index/manifest.js +71 -0
  16. package/dist/src/shared-index/opportunistic.js +37 -0
  17. package/dist/src/shared-index/overlay.js +98 -0
  18. package/dist/src/shared-index/provenance.js +149 -0
  19. package/dist/src/shared-index/publisher.js +198 -0
  20. package/dist/src/shared-index/restore.js +209 -0
  21. package/dist/src/shared-index/s3-client.js +124 -0
  22. package/dist/src/shared-index/schemas.js +86 -0
  23. package/dist/src/shared-index/selection.js +111 -0
  24. package/dist/src/tools/knodin-tools.js +70 -3
  25. package/docs/CLI.md +39 -0
  26. package/docs/MCP.md +12 -0
  27. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  28. package/docs/releases/0.8.5.md +25 -0
  29. package/docs/releases/0.8.6.md +10 -0
  30. package/package.json +12 -3
  31. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  32. package/schemas/shared-index-config-v1.schema.json +97 -0
  33. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  34. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -0,0 +1,149 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { Database } from "../engine/sqlite.js";
6
+ import { resolveDbPath, resolveStateDir } from "../engine/state-paths.js";
7
+ const counts = z
8
+ .object({
9
+ committedPaths: z.number().int().nonnegative(),
10
+ stagedPaths: z.number().int().nonnegative(),
11
+ workingTreePaths: z.number().int().nonnegative(),
12
+ deletedPaths: z.number().int().nonnegative(),
13
+ untrackedPaths: z.number().int().nonnegative(),
14
+ })
15
+ .strict();
16
+ export const sharedIndexProvenanceSchema = z
17
+ .object({
18
+ schemaVersion: z.literal(1),
19
+ enabled: z.boolean(),
20
+ state: z.enum([
21
+ "disabled",
22
+ "not-configured",
23
+ "configuration-invalid",
24
+ "local-only",
25
+ "restore-pending",
26
+ "restored-and-verified",
27
+ "restore-failed",
28
+ ]),
29
+ registrationId: z.string().optional(),
30
+ baseCommit: z
31
+ .string()
32
+ .regex(/^[a-f0-9]{40,64}$/)
33
+ .optional(),
34
+ manifestDigest: z
35
+ .string()
36
+ .regex(/^[a-f0-9]{64}$/)
37
+ .optional(),
38
+ source: z.enum(["local-index", "shared-snapshot"]),
39
+ cache: z.enum(["not-used", "downloaded", "hit", "unavailable", "corrupt-refetched"]).optional(),
40
+ overlay: counts.optional(),
41
+ failureCategory: z
42
+ .enum([
43
+ "authentication",
44
+ "authorization",
45
+ "not-found",
46
+ "timeout",
47
+ "integrity",
48
+ "compatibility",
49
+ "configuration",
50
+ "unavailable",
51
+ ])
52
+ .optional(),
53
+ effectiveTreeFingerprint: z
54
+ .string()
55
+ .regex(/^[a-f0-9]{64}$/)
56
+ .optional(),
57
+ restoredAt: z.iso.datetime().optional(),
58
+ })
59
+ .strict()
60
+ .superRefine((value, context) => {
61
+ if (value.state === "restored-and-verified" &&
62
+ (!value.registrationId ||
63
+ !value.baseCommit ||
64
+ !value.manifestDigest ||
65
+ !value.cache ||
66
+ !value.overlay ||
67
+ !value.effectiveTreeFingerprint ||
68
+ !value.restoredAt))
69
+ context.addIssue({ code: "custom", message: "verified provenance is incomplete" });
70
+ if (value.state === "restore-failed" && !value.failureCategory)
71
+ context.addIssue({ code: "custom", message: "failed provenance has no category" });
72
+ });
73
+ export const EMPTY_OVERLAY = {
74
+ committedPaths: 0,
75
+ stagedPaths: 0,
76
+ workingTreePaths: 0,
77
+ deletedPaths: 0,
78
+ untrackedPaths: 0,
79
+ };
80
+ function atomicJson(target, value) {
81
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
82
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
83
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 });
84
+ const descriptor = fs.openSync(temporary, "r");
85
+ try {
86
+ fs.fsyncSync(descriptor);
87
+ }
88
+ finally {
89
+ fs.closeSync(descriptor);
90
+ }
91
+ fs.renameSync(temporary, target);
92
+ }
93
+ export function readSharedIndexProvenance(repo) {
94
+ // The active database is authoritative. A sidecar from an older restore must
95
+ // never survive a later local rebuild as misleading current provenance.
96
+ const databasePath = resolveDbPath(repo);
97
+ if (!fs.existsSync(databasePath))
98
+ return null;
99
+ try {
100
+ const db = new Database(databasePath, { readonly: true });
101
+ try {
102
+ const get = (key) => db.query("SELECT value FROM meta WHERE key = ?").get(key)
103
+ ?.value ?? null;
104
+ const registrationId = get("sharedIndexRegistrationId");
105
+ const baseCommit = get("sharedIndexBaseCommit");
106
+ const manifestDigest = get("sharedIndexManifestDigest");
107
+ if (!registrationId || !baseCommit || !manifestDigest)
108
+ return null;
109
+ return sharedIndexProvenanceSchema.parse({
110
+ schemaVersion: 1,
111
+ enabled: true,
112
+ state: "restored-and-verified",
113
+ registrationId,
114
+ baseCommit,
115
+ manifestDigest,
116
+ source: "shared-snapshot",
117
+ cache: get("sharedIndexCache") ?? "unavailable",
118
+ overlay: JSON.parse(get("sharedIndexOverlay") ?? "null"),
119
+ effectiveTreeFingerprint: get("sharedIndexEffectiveTreeFingerprint") ?? undefined,
120
+ restoredAt: get("sharedIndexRestoredAt") ?? undefined,
121
+ });
122
+ }
123
+ finally {
124
+ db.close();
125
+ }
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ export function writeSharedIndexProvenance(repo, value) {
132
+ atomicJson(path.join(resolveStateDir(repo), "shared-index-provenance.json"), sharedIndexProvenanceSchema.parse(value));
133
+ }
134
+ export function readSharedRestoreAttempt(repo) {
135
+ try {
136
+ const value = JSON.parse(fs.readFileSync(path.join(resolveStateDir(repo), "shared-index-last-attempt.json"), "utf8"));
137
+ if (value.schemaVersion !== 1 ||
138
+ (value.outcome !== "restored" && value.outcome !== "failed") ||
139
+ !Number.isFinite(Date.parse(value.attemptedAt)))
140
+ return null;
141
+ return value;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ export function writeSharedRestoreAttempt(repo, value) {
148
+ atomicJson(path.join(resolveStateDir(repo), "shared-index-last-attempt.json"), value);
149
+ }
@@ -0,0 +1,198 @@
1
+ import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { createZstdCompress } from "node:zlib";
8
+ import { Database } from "../engine/sqlite.js";
9
+ import { resolveDbPath } from "../engine/state-paths.js";
10
+ import { currentSharedIndexCompatibility } from "./compatibility.js";
11
+ import { canonicalManifestBytes, SHARED_INDEX_LIMITS, sha256Hex, sharedBranchMatches, sharedRepositoryPrefix, } from "./contract.js";
12
+ import { branchPointerKey, compatibilityFingerprint, signatureKey, validatePointerBinding, verifyManifest, } from "./manifest.js";
13
+ import { SharedIndexError } from "./s3-client.js";
14
+ import { parseBranchPointer, parseManifest } from "./schemas.js";
15
+ /** Stamp the exact compatibility fingerprint into the checkpointed publisher DB. */
16
+ export function prepareSharedPublisherDatabase(repo) {
17
+ const databasePath = resolveDbPath(repo);
18
+ if (!fs.existsSync(databasePath))
19
+ throw new SharedIndexError("configuration", "shared-index publisher database is missing");
20
+ const compatibility = currentSharedIndexCompatibility();
21
+ const inputFingerprint = compatibilityFingerprint(compatibility);
22
+ const db = new Database(databasePath);
23
+ try {
24
+ const commit = db.query("SELECT value FROM meta WHERE key='lastIndexedHead'").get()
25
+ ?.value ?? "";
26
+ if (!/^[a-f0-9]{40,64}$/.test(commit))
27
+ throw new SharedIndexError("integrity", "shared-index publisher database has no valid indexed commit");
28
+ const schema = db
29
+ .query("PRAGMA user_version")
30
+ .get()?.user_version;
31
+ if (schema !== compatibility.databaseSchemaVersion)
32
+ throw new SharedIndexError("compatibility", "shared-index publisher database schema is incompatible");
33
+ db.run("INSERT INTO meta(key, value) VALUES ('sharedIndexInputFingerprint', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [inputFingerprint]);
34
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
35
+ return { schemaVersion: 1, compatibility, inputFingerprint, commit };
36
+ }
37
+ finally {
38
+ db.close();
39
+ for (const suffix of ["-wal", "-shm"])
40
+ fs.rmSync(`${databasePath}${suffix}`, { force: true });
41
+ }
42
+ }
43
+ function safeOutputDirectory(target) {
44
+ const resolved = path.resolve(target);
45
+ if (fs.existsSync(resolved)) {
46
+ const stat = fs.lstatSync(resolved);
47
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(resolved).length > 0)
48
+ throw new SharedIndexError("configuration", "shared-index publisher output must be a new or empty non-symlink directory");
49
+ }
50
+ else
51
+ fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
52
+ fs.chmodSync(resolved, 0o700);
53
+ return resolved;
54
+ }
55
+ function countingHash(hash, count) {
56
+ return new Transform({
57
+ transform(chunk, _encoding, callback) {
58
+ count.bytes += chunk.byteLength;
59
+ hash.update(chunk);
60
+ callback(null, chunk);
61
+ },
62
+ });
63
+ }
64
+ export async function createSharedPublisherBundle(options) {
65
+ if (!options.config.enabled)
66
+ throw new SharedIndexError("configuration", "shared-index publishing is disabled");
67
+ if (!options.config.branches.some((pattern) => sharedBranchMatches(pattern, options.branch)))
68
+ throw new SharedIndexError("configuration", "shared-index publishing branch is not configured");
69
+ const metadata = prepareSharedPublisherDatabase(options.repo);
70
+ const head = child_process
71
+ .execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
72
+ cwd: options.repo,
73
+ encoding: "utf8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ })
76
+ .trim();
77
+ if (head !== metadata.commit)
78
+ throw new SharedIndexError("integrity", "shared-index publisher graph does not match repository HEAD");
79
+ const output = safeOutputDirectory(options.outputDirectory);
80
+ const graphFile = path.join(output, "graph.sqlite.zst");
81
+ const manifestFile = path.join(output, "manifest.json");
82
+ const signatureFile = path.join(output, "manifest.sig");
83
+ const pointerFile = path.join(output, "branch-pointer.json");
84
+ const databasePath = resolveDbPath(options.repo);
85
+ const uncompressedHash = crypto.createHash("sha256");
86
+ const compressedHash = crypto.createHash("sha256");
87
+ const uncompressed = { bytes: 0 };
88
+ const compressed = { bytes: 0 };
89
+ await pipeline(fs.createReadStream(databasePath), countingHash(uncompressedHash, uncompressed), createZstdCompress(), countingHash(compressedHash, compressed), fs.createWriteStream(graphFile, { flags: "wx", mode: 0o600 }));
90
+ if (uncompressed.bytes <= 0 ||
91
+ uncompressed.bytes > SHARED_INDEX_LIMITS.uncompressedBytes ||
92
+ compressed.bytes <= 0 ||
93
+ compressed.bytes > SHARED_INDEX_LIMITS.compressedBytes)
94
+ throw new SharedIndexError("integrity", "shared-index publisher graph exceeds contract bounds");
95
+ const immutable = `${sharedRepositoryPrefix(options.config.repository)}snapshots/${metadata.commit}/${metadata.inputFingerprint}`;
96
+ const graphKey = `${immutable}/graph.sqlite.zst`;
97
+ const manifestKey = `${immutable}/manifest.json`;
98
+ const manifest = {
99
+ schemaVersion: 1,
100
+ registrationId: options.config.registrationId,
101
+ repository: options.config.repository,
102
+ commit: metadata.commit,
103
+ generatedAt: new Date().toISOString(),
104
+ compatibility: metadata.compatibility,
105
+ graph: {
106
+ key: graphKey,
107
+ compressedSha256: compressedHash.digest("hex"),
108
+ compressedBytes: compressed.bytes,
109
+ uncompressedSha256: uncompressedHash.digest("hex"),
110
+ uncompressedBytes: uncompressed.bytes,
111
+ },
112
+ };
113
+ const manifestBytes = canonicalManifestBytes(manifest);
114
+ fs.writeFileSync(manifestFile, manifestBytes, { flag: "wx", mode: 0o600 });
115
+ const bundle = {
116
+ schemaVersion: 1,
117
+ branch: options.branch,
118
+ commit: metadata.commit,
119
+ inputFingerprint: metadata.inputFingerprint,
120
+ manifestKey,
121
+ manifestSha256: sha256Hex(manifestBytes),
122
+ signatureKey: signatureKey(manifestKey),
123
+ pointerKey: branchPointerKey(options.config, options.branch),
124
+ graphKey,
125
+ region: options.config.aws.region,
126
+ bucket: options.config.aws.bucket,
127
+ keyId: options.config.signing.keyId,
128
+ files: {
129
+ graph: graphFile,
130
+ manifest: manifestFile,
131
+ signature: signatureFile,
132
+ pointer: pointerFile,
133
+ },
134
+ };
135
+ fs.writeFileSync(path.join(output, "bundle.json"), `${JSON.stringify(bundle)}\n`, {
136
+ flag: "wx",
137
+ mode: 0o600,
138
+ });
139
+ return bundle;
140
+ }
141
+ export function finalizeSharedPublisherBundle(options) {
142
+ const bundle = options.bundle;
143
+ const directory = path.dirname(path.resolve(bundle.files?.manifest ?? ""));
144
+ const expectedFiles = {
145
+ graph: "graph.sqlite.zst",
146
+ manifest: "manifest.json",
147
+ signature: "manifest.sig",
148
+ pointer: "branch-pointer.json",
149
+ };
150
+ if (bundle.schemaVersion !== 1 ||
151
+ !/^[a-f0-9]{40,64}$/.test(bundle.commit) ||
152
+ !/^[a-f0-9]{64}$/.test(bundle.inputFingerprint) ||
153
+ !/^[a-f0-9]{64}$/.test(bundle.manifestSha256) ||
154
+ bundle.region !== options.config.aws.region ||
155
+ bundle.bucket !== options.config.aws.bucket ||
156
+ bundle.keyId !== options.config.signing.keyId ||
157
+ Object.entries(expectedFiles).some(([name, filename]) => path.resolve(bundle.files[name] ?? "") !==
158
+ path.join(directory, filename)))
159
+ throw new SharedIndexError("integrity", "shared-index publisher bundle descriptor is invalid");
160
+ const manifestBytes = fs.readFileSync(options.bundle.files.manifest);
161
+ const signature = fs.readFileSync(options.bundle.files.signature);
162
+ const manifest = parseManifest(manifestBytes);
163
+ if (!options.config.branches.some((pattern) => sharedBranchMatches(pattern, bundle.branch)) ||
164
+ bundle.graphKey !== manifest.graph.key ||
165
+ bundle.signatureKey !== signatureKey(bundle.manifestKey) ||
166
+ sha256Hex(manifestBytes) !== bundle.manifestSha256)
167
+ throw new SharedIndexError("integrity", "shared-index publisher bundle bindings are invalid");
168
+ const pointer = parseBranchPointer(canonicalManifestBytes({
169
+ schemaVersion: 1,
170
+ registrationId: options.config.registrationId,
171
+ repository: options.config.repository,
172
+ branch: options.bundle.branch,
173
+ commit: options.bundle.commit,
174
+ inputFingerprint: options.bundle.inputFingerprint,
175
+ manifestKey: options.bundle.manifestKey,
176
+ manifestSha256: options.bundle.manifestSha256,
177
+ updatedAt: new Date().toISOString(),
178
+ }));
179
+ validatePointerBinding(options.config, pointer);
180
+ verifyManifest(options.config, pointer, manifestBytes, manifest, signature, manifest.compatibility);
181
+ const pointerBytes = canonicalManifestBytes(pointer);
182
+ fs.writeFileSync(options.bundle.files.pointer, pointerBytes, { flag: "wx", mode: 0o600 });
183
+ return pointerBytes;
184
+ }
185
+ export function guardPublisherPointer(options) {
186
+ const pointer = parseBranchPointer(options.existingPointerBytes);
187
+ if (pointer.branch !== options.branch)
188
+ throw new SharedIndexError("integrity", "shared-index existing pointer branch is invalid");
189
+ validatePointerBinding(options.config, pointer);
190
+ if (pointer.commit === options.newCommit)
191
+ return;
192
+ const relation = child_process.spawnSync("git", ["merge-base", "--is-ancestor", pointer.commit, options.newCommit], { cwd: options.repo, stdio: "ignore" });
193
+ if (relation.status !== 0)
194
+ throw new SharedIndexError("compatibility", "shared-index publisher refuses a backward or diverged pointer update");
195
+ }
196
+ export function publisherPointerKey(config, branch) {
197
+ return branchPointerKey(config, branch);
198
+ }
@@ -0,0 +1,209 @@
1
+ import fs from "node:fs";
2
+ import { Database } from "../engine/sqlite.js";
3
+ import { acquireRepairLease } from "../repair-lease.js";
4
+ import { decompressArtifactToCandidate } from "./artifact.js";
5
+ import { SharedIndexCache } from "./cache.js";
6
+ import { canonicalManifestBytes, sha256Hex } from "./contract.js";
7
+ import { deriveSharedOverlay } from "./overlay.js";
8
+ import { readSharedIndexProvenance, writeSharedIndexProvenance, writeSharedRestoreAttempt, } from "./provenance.js";
9
+ import { SharedIndexError, } from "./s3-client.js";
10
+ import { selectSharedSnapshot } from "./selection.js";
11
+ function progress(listener, phase, message, extra = {}) {
12
+ try {
13
+ listener?.({ phase, timestamp: new Date().toISOString(), message, ...extra });
14
+ }
15
+ catch {
16
+ // Observability cannot change restore correctness.
17
+ }
18
+ }
19
+ function boundedFailure(error) {
20
+ if (error instanceof SharedIndexError)
21
+ return { category: error.category, reason: error.message.slice(0, 512) };
22
+ return { category: "unavailable", reason: "shared-index restore failed" };
23
+ }
24
+ function metadata(db, key) {
25
+ return (db.query("SELECT value FROM meta WHERE key = ?").get(key)?.value ??
26
+ null);
27
+ }
28
+ function validateSnapshotDatabase(databasePath, commit, inputFingerprint, databaseSchemaVersion) {
29
+ const db = new Database(databasePath, { readonly: true });
30
+ try {
31
+ const schema = db
32
+ .query("PRAGMA user_version")
33
+ .get()?.user_version;
34
+ if (schema !== databaseSchemaVersion)
35
+ throw new SharedIndexError("integrity", "shared-index database schema does not match signed manifest");
36
+ if (metadata(db, "lastIndexedHead") !== commit)
37
+ throw new SharedIndexError("integrity", "shared-index database commit does not match signed manifest");
38
+ const embeddedFingerprint = metadata(db, "sharedIndexInputFingerprint");
39
+ if (embeddedFingerprint !== inputFingerprint)
40
+ throw new SharedIndexError("integrity", "shared-index database compatibility does not match signed manifest");
41
+ }
42
+ finally {
43
+ db.close();
44
+ }
45
+ }
46
+ function stampCandidate(databasePath, values) {
47
+ const db = new Database(databasePath);
48
+ try {
49
+ db.run("BEGIN IMMEDIATE");
50
+ for (const [key, value] of Object.entries(values))
51
+ db.run("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [key, value]);
52
+ db.run("COMMIT");
53
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
54
+ }
55
+ catch (error) {
56
+ try {
57
+ db.run("ROLLBACK");
58
+ }
59
+ catch {
60
+ // The original database failure is authoritative.
61
+ }
62
+ throw error;
63
+ }
64
+ finally {
65
+ db.close();
66
+ }
67
+ for (const suffix of ["-wal", "-shm"])
68
+ fs.rmSync(`${databasePath}${suffix}`, { force: true });
69
+ }
70
+ export async function restoreSharedIndex(options) {
71
+ const startedAt = Date.now();
72
+ let candidate;
73
+ let lease;
74
+ let promoted = false;
75
+ let lifecycleLease;
76
+ try {
77
+ if (!options.config.enabled)
78
+ throw new SharedIndexError("configuration", "shared-index restore is disabled");
79
+ lifecycleLease = acquireRepairLease(options.repo, "shared pull");
80
+ progress(options.onProgress, "discovery", "Discovering shared snapshots");
81
+ const selection = await selectSharedSnapshot({
82
+ repo: options.repo,
83
+ config: options.config,
84
+ store: options.store,
85
+ clientCompatibility: options.clientCompatibility,
86
+ signal: options.signal,
87
+ });
88
+ const selected = selection.selected;
89
+ if (!selected) {
90
+ const first = selection.rejected[0];
91
+ throw new SharedIndexError(first?.category ?? "not-found", first?.reason ?? "no usable shared-index snapshot was found");
92
+ }
93
+ progress(options.onProgress, "verification", "Verified signed shared snapshot");
94
+ const cache = options.cache ?? new SharedIndexCache();
95
+ progress(options.onProgress, "download", "Resolving immutable shared artifact");
96
+ lease = await cache.acquire(options.store, selected.manifest, options.signal);
97
+ progress(options.onProgress, "download", lease.state === "hit" ? "Using verified cache entry" : "Verified shared artifact download", { bytes: selected.manifest.graph.compressedBytes });
98
+ candidate = await options.engine.createCandidate(options.repo, { empty: true });
99
+ progress(options.onProgress, "decompression", "Decompressing into candidate graph");
100
+ await decompressArtifactToCandidate({
101
+ repo: options.repo,
102
+ candidate,
103
+ artifact: lease,
104
+ manifest: selected.manifest,
105
+ signal: options.signal,
106
+ });
107
+ validateSnapshotDatabase(candidate.databasePath, selected.pointer.commit, selected.pointer.inputFingerprint, selected.manifest.compatibility.databaseSchemaVersion);
108
+ const overlay = deriveSharedOverlay(options.repo, selected.pointer.commit);
109
+ const restoredAt = new Date().toISOString();
110
+ stampCandidate(candidate.databasePath, {
111
+ sharedIndexRegistrationId: options.config.registrationId,
112
+ sharedIndexBaseCommit: selected.pointer.commit,
113
+ sharedIndexManifestDigest: sha256Hex(selected.manifestBytes),
114
+ sharedIndexInputFingerprint: selected.pointer.inputFingerprint,
115
+ sharedIndexEffectiveTreeFingerprint: overlay.effectiveTreeFingerprint,
116
+ sharedIndexOverlay: canonicalManifestBytes(overlay.counts).toString("utf8"),
117
+ sharedIndexCache: lease.state,
118
+ sharedIndexRestoredAt: restoredAt,
119
+ });
120
+ progress(options.onProgress, "reconciliation", "Reconciling local repository overlay", {
121
+ completed: 0,
122
+ total: overlay.changedPaths.length,
123
+ });
124
+ await options.engine.reconcileCandidate(candidate, options.repo, overlay.changedPaths, {
125
+ onProgress: (_phase, completed, message, details) => {
126
+ const elapsedMs = Math.max(1, Date.now() - startedAt);
127
+ const total = details?.phaseTotal;
128
+ const ratePerSecond = completed > 0 ? completed / (elapsedMs / 1000) : undefined;
129
+ progress(options.onProgress, "reconciliation", message, {
130
+ completed,
131
+ ...(total !== undefined ? { total } : {}),
132
+ ...(ratePerSecond !== undefined ? { ratePerSecond } : {}),
133
+ ...(ratePerSecond && total !== undefined && total > completed
134
+ ? { etaSeconds: (total - completed) / ratePerSecond }
135
+ : {}),
136
+ elapsedMs,
137
+ });
138
+ },
139
+ });
140
+ progress(options.onProgress, "audit", "Deep-auditing reconciled candidate");
141
+ const audit = await options.engine.auditCandidate(candidate, { mode: "deep" });
142
+ if (!audit.healthy)
143
+ throw new SharedIndexError("integrity", `shared-index candidate audit failed (${audit.issues.slice(0, 8).join(",")})`);
144
+ progress(options.onProgress, "promotion", "Promoting verified candidate graph");
145
+ const promotion = await options.engine.promoteCandidate(candidate);
146
+ promoted = true;
147
+ const provenance = {
148
+ schemaVersion: 1,
149
+ enabled: true,
150
+ state: "restored-and-verified",
151
+ registrationId: options.config.registrationId,
152
+ baseCommit: selected.pointer.commit,
153
+ manifestDigest: sha256Hex(selected.manifestBytes),
154
+ source: "shared-snapshot",
155
+ cache: lease.state,
156
+ overlay: overlay.counts,
157
+ effectiveTreeFingerprint: overlay.effectiveTreeFingerprint,
158
+ restoredAt,
159
+ };
160
+ try {
161
+ writeSharedIndexProvenance(options.repo, provenance);
162
+ writeSharedRestoreAttempt(options.repo, {
163
+ schemaVersion: 1,
164
+ attemptedAt: new Date().toISOString(),
165
+ outcome: "restored",
166
+ });
167
+ }
168
+ catch {
169
+ // The promoted database contains the same provenance metadata and remains
170
+ // authoritative if the convenience sidecar cannot be written.
171
+ }
172
+ progress(options.onProgress, "completed", "Shared snapshot restored and verified");
173
+ return { restored: true, provenance, backupPath: promotion.backupPath };
174
+ }
175
+ catch (error) {
176
+ const failure = boundedFailure(error);
177
+ if (!promoted) {
178
+ try {
179
+ writeSharedRestoreAttempt(options.repo, {
180
+ schemaVersion: 1,
181
+ attemptedAt: new Date().toISOString(),
182
+ outcome: "failed",
183
+ failureCategory: failure.category,
184
+ });
185
+ }
186
+ catch {
187
+ // A diagnostic write cannot alter active graph preservation.
188
+ }
189
+ }
190
+ progress(options.onProgress, "failed", failure.reason);
191
+ return {
192
+ restored: false,
193
+ ...failure,
194
+ activeProvenance: readSharedIndexProvenance(options.repo),
195
+ };
196
+ }
197
+ finally {
198
+ lease?.release();
199
+ if (candidate && !promoted) {
200
+ try {
201
+ await options.engine.discardCandidate(candidate);
202
+ }
203
+ catch {
204
+ // Candidate cleanup never justifies touching the active graph.
205
+ }
206
+ }
207
+ lifecycleLease?.release();
208
+ }
209
+ }
@@ -0,0 +1,124 @@
1
+ import { Readable } from "node:stream";
2
+ import { GetObjectCommand, HeadObjectCommand, S3Client, } from "@aws-sdk/client-s3";
3
+ import { NodeHttpHandler } from "@smithy/node-http-handler";
4
+ import { sharedRepositoryPrefix } from "./contract.js";
5
+ export class SharedIndexError extends Error {
6
+ category;
7
+ constructor(category, message) {
8
+ super(message);
9
+ this.name = "SharedIndexError";
10
+ this.category = category;
11
+ }
12
+ }
13
+ function classified(error) {
14
+ if (error instanceof SharedIndexError)
15
+ return error;
16
+ const record = typeof error === "object" && error !== null ? error : {};
17
+ const name = typeof record.name === "string" ? record.name : "";
18
+ const status = Number(record.$metadata?.httpStatusCode);
19
+ if ([
20
+ "CredentialsProviderError",
21
+ "TokenProviderError",
22
+ "ExpiredToken",
23
+ "InvalidAccessKeyId",
24
+ "UnrecognizedClientException",
25
+ ].includes(name))
26
+ return new SharedIndexError("authentication", "shared-index AWS authentication failed");
27
+ if (name === "AccessDenied" || name === "AccessDeniedException" || status === 403)
28
+ return new SharedIndexError("authorization", "shared-index object access was denied");
29
+ if (["NoSuchKey", "NotFound", "NoSuchBucket"].includes(name) || status === 404)
30
+ return new SharedIndexError("not-found", "shared-index object was not found");
31
+ if (name === "AbortError" || name === "TimeoutError" || name === "RequestTimeout")
32
+ return new SharedIndexError("timeout", "shared-index object request timed out or was cancelled");
33
+ return new SharedIndexError("unavailable", "shared-index object service is unavailable");
34
+ }
35
+ function requestSignal(signal, timeoutMs) {
36
+ const timeout = AbortSignal.timeout(timeoutMs);
37
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
38
+ }
39
+ function readableBody(body) {
40
+ if (body instanceof Readable)
41
+ return body;
42
+ if (body && typeof body === "object" && "transformToWebStream" in body) {
43
+ const stream = body.transformToWebStream();
44
+ return Readable.fromWeb(stream);
45
+ }
46
+ throw new SharedIndexError("unavailable", "shared-index object response had no readable body");
47
+ }
48
+ export class AwsSharedObjectStore {
49
+ client;
50
+ bucket;
51
+ prefix;
52
+ constructor(config, client) {
53
+ if (!config.enabled)
54
+ throw new SharedIndexError("configuration", "shared-index object access is disabled");
55
+ this.bucket = config.aws.bucket;
56
+ this.prefix = sharedRepositoryPrefix(config.repository);
57
+ const clientConfig = {
58
+ region: config.aws.region,
59
+ maxAttempts: 3,
60
+ requestHandler: new NodeHttpHandler({
61
+ connectionTimeout: 5_000,
62
+ requestTimeout: 30_000,
63
+ }),
64
+ };
65
+ // Omitting `credentials` intentionally selects the SDK's native Node default
66
+ // provider chain (environment, shared config/SSO, web identity, ECS, EC2).
67
+ this.client = client ?? new S3Client(clientConfig);
68
+ }
69
+ key(key) {
70
+ if (!key.startsWith(this.prefix) ||
71
+ key.length <= this.prefix.length ||
72
+ key.includes("\\") ||
73
+ key.split("/").some((segment) => segment === "" || segment === "." || segment === ".."))
74
+ throw new SharedIndexError("configuration", "shared-index object key escaped its repository prefix");
75
+ return key;
76
+ }
77
+ async head(key, options = {}) {
78
+ try {
79
+ const response = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: this.key(key) }), { abortSignal: requestSignal(options.signal, options.timeoutMs ?? 15_000) });
80
+ const bytes = Number(response.ContentLength);
81
+ if (!Number.isSafeInteger(bytes) || bytes < 0)
82
+ throw new SharedIndexError("integrity", "shared-index object size metadata is invalid");
83
+ return {
84
+ bytes,
85
+ ...(typeof response.ETag === "string" ? { etag: response.ETag } : {}),
86
+ };
87
+ }
88
+ catch (error) {
89
+ throw classified(error);
90
+ }
91
+ }
92
+ async get(key, limits) {
93
+ if (!Number.isSafeInteger(limits.maxBytes) || limits.maxBytes <= 0)
94
+ throw new SharedIndexError("configuration", "shared-index download limit is invalid");
95
+ try {
96
+ const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.key(key) }), { abortSignal: requestSignal(limits.signal, limits.timeoutMs ?? 30_000) });
97
+ const declared = Number(response.ContentLength);
98
+ if (Number.isFinite(declared) && declared > limits.maxBytes)
99
+ throw new SharedIndexError("integrity", "shared-index object exceeds the download limit");
100
+ const source = readableBody(response.Body);
101
+ let received = 0;
102
+ return Readable.from((async function* bounded() {
103
+ try {
104
+ for await (const chunk of source) {
105
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
106
+ received += bytes.length;
107
+ if (received > limits.maxBytes)
108
+ throw new SharedIndexError("integrity", "shared-index object exceeded the download limit");
109
+ yield bytes;
110
+ }
111
+ }
112
+ finally {
113
+ source.destroy();
114
+ }
115
+ })());
116
+ }
117
+ catch (error) {
118
+ throw classified(error);
119
+ }
120
+ }
121
+ }
122
+ export function createSharedObjectStore(config, client) {
123
+ return config.enabled ? new AwsSharedObjectStore(config, client) : null;
124
+ }