@hraness/kb 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
constants,
|
|
4
|
+
type BigIntStats,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import {
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
open,
|
|
10
|
+
opendir,
|
|
11
|
+
realpath,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
type FileHandle,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
import type { Note } from "./graph.js";
|
|
20
|
+
import {
|
|
21
|
+
MAX_NOTE_UTF8_BYTES,
|
|
22
|
+
MAX_SCANNED_NOTES,
|
|
23
|
+
MAX_VAULT_UTF8_BYTES,
|
|
24
|
+
VaultScanBudgetError,
|
|
25
|
+
} from "./vault.js";
|
|
26
|
+
|
|
27
|
+
const LEASE_VERSION = 1;
|
|
28
|
+
const PROJECTION_VERSION = 2;
|
|
29
|
+
const DEFAULT_LEASE_WAIT_MS = 30_000;
|
|
30
|
+
const DEFAULT_LEASE_POLL_MS = 25;
|
|
31
|
+
const MAX_LEASE_WAIT_MS = 10 * 60_000;
|
|
32
|
+
const MAX_LEASE_POLL_MS = 1_000;
|
|
33
|
+
const GENERATION_PREFIX = "generation-";
|
|
34
|
+
const READER_PREFIX = ".reader-";
|
|
35
|
+
const MANIFEST_NAME = "manifest.json";
|
|
36
|
+
const OWNER_NAME = "owner.json";
|
|
37
|
+
const SNAPSHOT_OWNER_NAME = ".hraness-kb-semantic-cache.json";
|
|
38
|
+
const SNAPSHOT_OWNER_VERSION = 1;
|
|
39
|
+
const SNAPSHOT_OWNER_KIND = "@hraness/kb/semantic-projection-cache";
|
|
40
|
+
const MAX_OWNER_BYTES = 4 * 1_024;
|
|
41
|
+
const MAX_MANIFEST_BYTES = 64 * 1_024 * 1_024;
|
|
42
|
+
const MAX_CACHE_DIRECTORY_ENTRIES = 1_024;
|
|
43
|
+
const MAX_INDEX_IDENTITY_BYTES = 16 * 1_024;
|
|
44
|
+
const MAX_INDEX_IDENTITY_STRING_BYTES = 4 * 1_024;
|
|
45
|
+
const MAX_INDEX_IDENTITY_ENTRIES = 64;
|
|
46
|
+
|
|
47
|
+
type LeaseOwner = {
|
|
48
|
+
readonly version: typeof LEASE_VERSION;
|
|
49
|
+
readonly pid: number;
|
|
50
|
+
readonly token: string;
|
|
51
|
+
readonly acquiredAt: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type SnapshotCacheOwner = {
|
|
55
|
+
readonly version: typeof SNAPSHOT_OWNER_VERSION;
|
|
56
|
+
readonly kind: typeof SNAPSHOT_OWNER_KIND;
|
|
57
|
+
readonly databaseIdentity: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type SemanticWriterLeaseOptions = {
|
|
61
|
+
readonly waitMs?: number;
|
|
62
|
+
readonly pollMs?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type SemanticGenerationWriterLeaseOptions = SemanticWriterLeaseOptions & {
|
|
66
|
+
/** Wait for every reader, including readers of this generation, before mutating. */
|
|
67
|
+
readonly excludeReaders?: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type SemanticWriterLease = {
|
|
71
|
+
/** Idempotently release this process-owned database lease. */
|
|
72
|
+
readonly release: () => Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Everything that determines whether two processes may safely share one semantic index. */
|
|
76
|
+
export type SemanticIndexIdentity = {
|
|
77
|
+
readonly producer: {
|
|
78
|
+
readonly package: string;
|
|
79
|
+
readonly schema: number;
|
|
80
|
+
};
|
|
81
|
+
readonly indexer: {
|
|
82
|
+
readonly package: string;
|
|
83
|
+
readonly version: string;
|
|
84
|
+
};
|
|
85
|
+
readonly collection: {
|
|
86
|
+
readonly name: string;
|
|
87
|
+
readonly pattern: string;
|
|
88
|
+
readonly ignore: readonly string[];
|
|
89
|
+
readonly globalContext: string;
|
|
90
|
+
readonly pathContexts: readonly {
|
|
91
|
+
readonly path: string;
|
|
92
|
+
readonly context: string;
|
|
93
|
+
}[];
|
|
94
|
+
};
|
|
95
|
+
readonly embedding: {
|
|
96
|
+
readonly model: string;
|
|
97
|
+
readonly chunkStrategy: string;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type SemanticProjectionManifest = {
|
|
102
|
+
readonly version: typeof PROJECTION_VERSION;
|
|
103
|
+
readonly indexIdentity: SemanticIndexIdentity;
|
|
104
|
+
readonly root: string;
|
|
105
|
+
readonly generation: string;
|
|
106
|
+
readonly notes: readonly {
|
|
107
|
+
readonly path: string;
|
|
108
|
+
readonly sha256: string;
|
|
109
|
+
readonly bytes: number;
|
|
110
|
+
}[];
|
|
111
|
+
readonly totalBytes: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type SemanticProjection = {
|
|
115
|
+
readonly root: string;
|
|
116
|
+
readonly manifest: SemanticProjectionManifest;
|
|
117
|
+
/** Release the generation reference after the QMD store no longer needs it. */
|
|
118
|
+
readonly release: () => Promise<void>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type SemanticProjectionDescription = {
|
|
122
|
+
readonly database: string;
|
|
123
|
+
readonly snapshotDirectory: string;
|
|
124
|
+
readonly generationPath: string;
|
|
125
|
+
readonly manifest: SemanticProjectionManifest;
|
|
126
|
+
readonly manifestText: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function errorCode(error: unknown): string | undefined {
|
|
130
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
|
|
131
|
+
const code = Reflect.get(error, "code");
|
|
132
|
+
return typeof code === "string" ? code : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function checkedLeaseBound(
|
|
136
|
+
value: number | undefined,
|
|
137
|
+
fallback: number,
|
|
138
|
+
maximum: number,
|
|
139
|
+
label: string,
|
|
140
|
+
): number {
|
|
141
|
+
const result = value ?? fallback;
|
|
142
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > maximum) {
|
|
143
|
+
throw new RangeError(`${label} must be an integer from 1 through ${maximum}.`);
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkedIdentityString(value: string, label: string): string {
|
|
153
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
154
|
+
if (value === "" || bytes > MAX_INDEX_IDENTITY_STRING_BYTES) {
|
|
155
|
+
throw new RangeError(
|
|
156
|
+
`${label} must contain from 1 through ${MAX_INDEX_IDENTITY_STRING_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validatedIndexIdentity(identity: SemanticIndexIdentity): SemanticIndexIdentity {
|
|
163
|
+
if (!Number.isSafeInteger(identity.producer.schema) || identity.producer.schema < 1) {
|
|
164
|
+
throw new RangeError("Semantic index producer schema must be a positive safe integer.");
|
|
165
|
+
}
|
|
166
|
+
if (identity.collection.ignore.length > MAX_INDEX_IDENTITY_ENTRIES
|
|
167
|
+
|| identity.collection.pathContexts.length > MAX_INDEX_IDENTITY_ENTRIES) {
|
|
168
|
+
throw new RangeError(
|
|
169
|
+
`Semantic index identity collections are limited to ${MAX_INDEX_IDENTITY_ENTRIES} entries.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const validated: SemanticIndexIdentity = {
|
|
173
|
+
producer: {
|
|
174
|
+
package: checkedIdentityString(
|
|
175
|
+
identity.producer.package,
|
|
176
|
+
"Semantic index producer package",
|
|
177
|
+
),
|
|
178
|
+
schema: identity.producer.schema,
|
|
179
|
+
},
|
|
180
|
+
indexer: {
|
|
181
|
+
package: checkedIdentityString(identity.indexer.package, "Semantic indexer package"),
|
|
182
|
+
version: checkedIdentityString(identity.indexer.version, "Semantic indexer version"),
|
|
183
|
+
},
|
|
184
|
+
collection: {
|
|
185
|
+
name: checkedIdentityString(identity.collection.name, "Semantic collection name"),
|
|
186
|
+
pattern: checkedIdentityString(identity.collection.pattern, "Semantic collection pattern"),
|
|
187
|
+
ignore: identity.collection.ignore.map((pattern) =>
|
|
188
|
+
checkedIdentityString(pattern, "Semantic collection ignore pattern")),
|
|
189
|
+
globalContext: checkedIdentityString(
|
|
190
|
+
identity.collection.globalContext,
|
|
191
|
+
"Semantic collection global context",
|
|
192
|
+
),
|
|
193
|
+
pathContexts: identity.collection.pathContexts.map((entry) => ({
|
|
194
|
+
path: checkedIdentityString(entry.path, "Semantic collection context path"),
|
|
195
|
+
context: checkedIdentityString(entry.context, "Semantic collection path context"),
|
|
196
|
+
})),
|
|
197
|
+
},
|
|
198
|
+
embedding: {
|
|
199
|
+
model: checkedIdentityString(identity.embedding.model, "Semantic embedding model"),
|
|
200
|
+
chunkStrategy: checkedIdentityString(
|
|
201
|
+
identity.embedding.chunkStrategy,
|
|
202
|
+
"Semantic embedding chunk strategy",
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
if (Buffer.byteLength(JSON.stringify(validated), "utf8") > MAX_INDEX_IDENTITY_BYTES) {
|
|
207
|
+
throw new RangeError(
|
|
208
|
+
`Semantic index identity exceeds the ${MAX_INDEX_IDENTITY_BYTES.toLocaleString("en-US")}-byte limit.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return validated;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function boundedMetadataText(path: string, maximum: number, label: string): Promise<string> {
|
|
215
|
+
const pathBefore = await lstat(path, { bigint: true });
|
|
216
|
+
if (pathBefore.isSymbolicLink() || !pathBefore.isFile() || pathBefore.nlink !== 1n) {
|
|
217
|
+
throw new Error(`${label} must be a regular, singly linked file.`);
|
|
218
|
+
}
|
|
219
|
+
const handle = await open(
|
|
220
|
+
path,
|
|
221
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
222
|
+
);
|
|
223
|
+
try {
|
|
224
|
+
const before = await handle.stat({ bigint: true });
|
|
225
|
+
if (!before.isFile()
|
|
226
|
+
|| before.nlink !== 1n
|
|
227
|
+
|| before.dev !== pathBefore.dev
|
|
228
|
+
|| before.ino !== pathBefore.ino) {
|
|
229
|
+
throw new Error(`${label} must be a regular, singly linked file.`);
|
|
230
|
+
}
|
|
231
|
+
if (before.size > BigInt(maximum)) {
|
|
232
|
+
throw new RangeError(
|
|
233
|
+
`${label} exceeds the ${maximum.toLocaleString("en-US")}-byte metadata limit.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const chunks: Uint8Array[] = [];
|
|
237
|
+
let bytes = 0;
|
|
238
|
+
for (;;) {
|
|
239
|
+
const remaining = maximum - bytes;
|
|
240
|
+
const buffer = new Uint8Array(Math.min(64 * 1_024, remaining + 1));
|
|
241
|
+
const result = await handle.read(buffer, 0, buffer.byteLength, null);
|
|
242
|
+
if (result.bytesRead === 0) break;
|
|
243
|
+
bytes += result.bytesRead;
|
|
244
|
+
if (bytes > maximum) {
|
|
245
|
+
throw new RangeError(
|
|
246
|
+
`${label} exceeds the ${maximum.toLocaleString("en-US")}-byte metadata limit.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
chunks.push(buffer.slice(0, result.bytesRead));
|
|
250
|
+
}
|
|
251
|
+
const after = await handle.stat({ bigint: true });
|
|
252
|
+
let pathAfter: BigIntStats;
|
|
253
|
+
try {
|
|
254
|
+
pathAfter = await lstat(path, { bigint: true });
|
|
255
|
+
} catch {
|
|
256
|
+
throw new Error(`${label} changed while it was being read; retry.`);
|
|
257
|
+
}
|
|
258
|
+
if (!after.isFile()
|
|
259
|
+
|| after.nlink !== 1n
|
|
260
|
+
|| after.dev !== before.dev
|
|
261
|
+
|| after.ino !== before.ino
|
|
262
|
+
|| after.size !== BigInt(bytes)
|
|
263
|
+
|| pathAfter.dev !== before.dev
|
|
264
|
+
|| pathAfter.ino !== before.ino) {
|
|
265
|
+
throw new Error(`${label} changed while it was being read; retry.`);
|
|
266
|
+
}
|
|
267
|
+
const joined = new Uint8Array(bytes);
|
|
268
|
+
let offset = 0;
|
|
269
|
+
for (const chunk of chunks) {
|
|
270
|
+
joined.set(chunk, offset);
|
|
271
|
+
offset += chunk.byteLength;
|
|
272
|
+
}
|
|
273
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(joined);
|
|
274
|
+
} finally {
|
|
275
|
+
await handle.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function safeDirectory(path: string, label: string): Promise<"absent" | "directory"> {
|
|
280
|
+
try {
|
|
281
|
+
const metadata = await lstat(path);
|
|
282
|
+
if (metadata.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link.`);
|
|
283
|
+
if (!metadata.isDirectory()) throw new Error(`${label} must be a directory.`);
|
|
284
|
+
return "directory";
|
|
285
|
+
} catch (error: unknown) {
|
|
286
|
+
if (errorCode(error) === "ENOENT") return "absent";
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function canonicalProspectiveDirectory(path: string): Promise<string> {
|
|
292
|
+
const missing: string[] = [];
|
|
293
|
+
let candidate = resolve(path);
|
|
294
|
+
for (;;) {
|
|
295
|
+
try {
|
|
296
|
+
return resolve(await realpath(candidate), ...missing.toReversed());
|
|
297
|
+
} catch (error: unknown) {
|
|
298
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
299
|
+
const parent = dirname(candidate);
|
|
300
|
+
if (parent === candidate) throw error;
|
|
301
|
+
missing.push(basename(candidate));
|
|
302
|
+
candidate = parent;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function validateSemanticDatabaseFile(path: string): Promise<void> {
|
|
308
|
+
let metadata: BigIntStats;
|
|
309
|
+
try {
|
|
310
|
+
metadata = await lstat(path, { bigint: true });
|
|
311
|
+
} catch (error: unknown) {
|
|
312
|
+
if (errorCode(error) === "ENOENT") return;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
if (metadata.isSymbolicLink()) {
|
|
316
|
+
throw new Error("Semantic database must not be a symbolic link.");
|
|
317
|
+
}
|
|
318
|
+
if (!metadata.isFile() || metadata.nlink !== 1n) {
|
|
319
|
+
throw new Error("Semantic database must be a regular, singly linked file.");
|
|
320
|
+
}
|
|
321
|
+
const handle = await open(
|
|
322
|
+
path,
|
|
323
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
324
|
+
);
|
|
325
|
+
try {
|
|
326
|
+
const current = await handle.stat({ bigint: true });
|
|
327
|
+
if (!current.isFile()
|
|
328
|
+
|| current.nlink !== 1n
|
|
329
|
+
|| current.dev !== metadata.dev
|
|
330
|
+
|| current.ino !== metadata.ino) {
|
|
331
|
+
throw new Error("Semantic database must be a regular, singly linked file.");
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
await handle.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function canonicalSemanticDatabasePath(database: string): Promise<string> {
|
|
339
|
+
const requested = resolve(database);
|
|
340
|
+
const canonicalParent = await canonicalProspectiveDirectory(dirname(requested));
|
|
341
|
+
const canonical = join(canonicalParent, basename(requested));
|
|
342
|
+
await validateSemanticDatabaseFile(canonical);
|
|
343
|
+
try {
|
|
344
|
+
return await realpath(canonical);
|
|
345
|
+
} catch (error: unknown) {
|
|
346
|
+
if (errorCode(error) === "ENOENT") return canonical;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function settledSemanticDatabasePath(database: string): Promise<string> {
|
|
352
|
+
const canonical = await canonicalSemanticDatabasePath(database);
|
|
353
|
+
await mkdir(dirname(canonical), { recursive: true, mode: 0o700 });
|
|
354
|
+
const settledParent = await realpath(dirname(canonical));
|
|
355
|
+
if (settledParent !== dirname(canonical)) {
|
|
356
|
+
throw new Error("Semantic database parent changed while settling its identity; retry.");
|
|
357
|
+
}
|
|
358
|
+
let created: FileHandle | undefined;
|
|
359
|
+
try {
|
|
360
|
+
created = await open(
|
|
361
|
+
canonical,
|
|
362
|
+
constants.O_WRONLY
|
|
363
|
+
| constants.O_CREAT
|
|
364
|
+
| constants.O_EXCL
|
|
365
|
+
| constants.O_NOFOLLOW
|
|
366
|
+
| constants.O_NONBLOCK,
|
|
367
|
+
0o600,
|
|
368
|
+
);
|
|
369
|
+
} catch (error: unknown) {
|
|
370
|
+
if (errorCode(error) !== "EEXIST") throw error;
|
|
371
|
+
} finally {
|
|
372
|
+
await created?.close();
|
|
373
|
+
}
|
|
374
|
+
await validateSemanticDatabaseFile(canonical);
|
|
375
|
+
return await realpath(canonical);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function pathIsWithin(root: string, candidate: string): boolean {
|
|
379
|
+
return candidate === root || confinedPath(root, candidate);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function assertSemanticCacheOutsideVault(root: string, database: string): void {
|
|
383
|
+
const ownedPaths = [
|
|
384
|
+
database,
|
|
385
|
+
`${database}.writer-lease`,
|
|
386
|
+
`${database}.snapshot`,
|
|
387
|
+
];
|
|
388
|
+
if (ownedPaths.some((path) => pathIsWithin(root, path) || pathIsWithin(path, root))) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
"Semantic database, lease, and snapshot paths must not overlap the vault root.",
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Resolve a database identity without writing and reject cache paths inside the vault. */
|
|
396
|
+
export async function resolveSemanticDatabase(
|
|
397
|
+
database: string,
|
|
398
|
+
root: string,
|
|
399
|
+
): Promise<string> {
|
|
400
|
+
const [canonicalDatabase, canonicalRoot] = await Promise.all([
|
|
401
|
+
canonicalSemanticDatabasePath(database),
|
|
402
|
+
realpath(root),
|
|
403
|
+
]);
|
|
404
|
+
assertSemanticCacheOutsideVault(canonicalRoot, canonicalDatabase);
|
|
405
|
+
return canonicalDatabase;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function parsedOwner(value: unknown): LeaseOwner | null {
|
|
409
|
+
if (!isRecord(value)
|
|
410
|
+
|| value.version !== LEASE_VERSION
|
|
411
|
+
|| !Number.isSafeInteger(value.pid)
|
|
412
|
+
|| (value.pid as number) < 1
|
|
413
|
+
|| typeof value.token !== "string"
|
|
414
|
+
|| value.token.length < 16
|
|
415
|
+
|| value.token.length > 128
|
|
416
|
+
|| typeof value.acquiredAt !== "string") {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
version: LEASE_VERSION,
|
|
421
|
+
pid: value.pid as number,
|
|
422
|
+
token: value.token,
|
|
423
|
+
acquiredAt: value.acquiredAt,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function readOwner(path: string): Promise<LeaseOwner | null> {
|
|
428
|
+
try {
|
|
429
|
+
return parsedOwner(JSON.parse(
|
|
430
|
+
await boundedMetadataText(join(path, OWNER_NAME), MAX_OWNER_BYTES, "Semantic lease owner"),
|
|
431
|
+
) as unknown);
|
|
432
|
+
} catch (error: unknown) {
|
|
433
|
+
if (error instanceof RangeError) throw error;
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function processIsAlive(pid: number): boolean {
|
|
439
|
+
try {
|
|
440
|
+
process.kill(pid, 0);
|
|
441
|
+
return true;
|
|
442
|
+
} catch (error: unknown) {
|
|
443
|
+
return errorCode(error) !== "ESRCH";
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function recoverDeadLease(path: string): Promise<boolean> {
|
|
448
|
+
if (await safeDirectory(path, "Semantic writer lease") === "absent") return false;
|
|
449
|
+
const owner = await readOwner(path);
|
|
450
|
+
if (owner === null || processIsAlive(owner.pid)) return false;
|
|
451
|
+
const confirmed = await readOwner(path);
|
|
452
|
+
if (confirmed?.pid !== owner.pid || confirmed.token !== owner.token) return false;
|
|
453
|
+
const tombstone = `${path}.dead-${owner.token}-${randomUUID()}`;
|
|
454
|
+
try {
|
|
455
|
+
await rename(path, tombstone);
|
|
456
|
+
} catch (error: unknown) {
|
|
457
|
+
if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(errorCode(error) ?? "")) return false;
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
await rm(tombstone, { recursive: true, force: true });
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function acquireWriterLeaseState(
|
|
465
|
+
database: string,
|
|
466
|
+
options: SemanticWriterLeaseOptions,
|
|
467
|
+
): Promise<{
|
|
468
|
+
readonly path: string;
|
|
469
|
+
readonly owner: LeaseOwner;
|
|
470
|
+
readonly database: string;
|
|
471
|
+
}> {
|
|
472
|
+
const waitMs = checkedLeaseBound(
|
|
473
|
+
options.waitMs,
|
|
474
|
+
DEFAULT_LEASE_WAIT_MS,
|
|
475
|
+
MAX_LEASE_WAIT_MS,
|
|
476
|
+
"Semantic writer lease wait",
|
|
477
|
+
);
|
|
478
|
+
const pollMs = checkedLeaseBound(
|
|
479
|
+
options.pollMs,
|
|
480
|
+
DEFAULT_LEASE_POLL_MS,
|
|
481
|
+
MAX_LEASE_POLL_MS,
|
|
482
|
+
"Semantic writer lease poll interval",
|
|
483
|
+
);
|
|
484
|
+
const canonicalDatabase = await settledSemanticDatabasePath(database);
|
|
485
|
+
const path = `${canonicalDatabase}.writer-lease`;
|
|
486
|
+
const owner: LeaseOwner = {
|
|
487
|
+
version: LEASE_VERSION,
|
|
488
|
+
pid: process.pid,
|
|
489
|
+
token: randomUUID(),
|
|
490
|
+
acquiredAt: new Date().toISOString(),
|
|
491
|
+
};
|
|
492
|
+
const startedAt = Date.now();
|
|
493
|
+
for (;;) {
|
|
494
|
+
await safeDirectory(path, "Semantic writer lease");
|
|
495
|
+
const claim = `${path}.claim-${process.pid}-${owner.token}`;
|
|
496
|
+
await mkdir(claim, { mode: 0o700 });
|
|
497
|
+
try {
|
|
498
|
+
await writeFile(join(claim, OWNER_NAME), `${JSON.stringify(owner)}\n`, {
|
|
499
|
+
encoding: "utf8",
|
|
500
|
+
flag: "wx",
|
|
501
|
+
mode: 0o600,
|
|
502
|
+
});
|
|
503
|
+
try {
|
|
504
|
+
await rename(claim, path);
|
|
505
|
+
const lease = { path, owner, database: canonicalDatabase };
|
|
506
|
+
try {
|
|
507
|
+
await validateSemanticDatabaseFile(canonicalDatabase);
|
|
508
|
+
return lease;
|
|
509
|
+
} catch (error: unknown) {
|
|
510
|
+
await releaseWriterLease(lease);
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
} catch (error: unknown) {
|
|
514
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(errorCode(error) ?? "")) throw error;
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
await rm(claim, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
if (await recoverDeadLease(path)) continue;
|
|
520
|
+
const elapsed = Date.now() - startedAt;
|
|
521
|
+
if (elapsed >= waitMs) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`Timed out after ${waitMs}ms waiting for the semantic writer lease for ${JSON.stringify(resolve(database))}.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
await Bun.sleep(Math.min(pollMs, waitMs - elapsed));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function releaseWriterLease(
|
|
531
|
+
lease: { readonly path: string; readonly owner: LeaseOwner },
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
if (await safeDirectory(lease.path, "Semantic writer lease") === "absent") return;
|
|
534
|
+
const current = await readOwner(lease.path);
|
|
535
|
+
if (current?.pid !== lease.owner.pid || current.token !== lease.owner.token) return;
|
|
536
|
+
const tombstone = `${lease.path}.release-${lease.owner.token}`;
|
|
537
|
+
try {
|
|
538
|
+
await rename(lease.path, tombstone);
|
|
539
|
+
} catch (error: unknown) {
|
|
540
|
+
if (errorCode(error) === "ENOENT") return;
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
await rm(tombstone, { recursive: true, force: true });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Serialize QMD database mutations across processes with dead-owner recovery. */
|
|
547
|
+
export async function acquireSemanticWriterLease(
|
|
548
|
+
database: string,
|
|
549
|
+
options: SemanticWriterLeaseOptions = {},
|
|
550
|
+
): Promise<SemanticWriterLease> {
|
|
551
|
+
const lease = await acquireWriterLeaseState(database, options);
|
|
552
|
+
let released = false;
|
|
553
|
+
return {
|
|
554
|
+
release: async () => {
|
|
555
|
+
if (released) return;
|
|
556
|
+
released = true;
|
|
557
|
+
await releaseWriterLease(lease);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Run one bounded operation while owning a database-scoped writer lease. */
|
|
563
|
+
export async function withSemanticWriterLease<Value>(
|
|
564
|
+
database: string,
|
|
565
|
+
operation: () => Promise<Value>,
|
|
566
|
+
options: SemanticWriterLeaseOptions = {},
|
|
567
|
+
): Promise<Value> {
|
|
568
|
+
const lease = await acquireSemanticWriterLease(database, options);
|
|
569
|
+
try {
|
|
570
|
+
return await operation();
|
|
571
|
+
} finally {
|
|
572
|
+
await lease.release();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function semanticWriterLeasePath(database: string): string {
|
|
577
|
+
return `${resolve(database)}.writer-lease`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function confinedPath(root: string, path: string): boolean {
|
|
581
|
+
const candidate = relative(root, path);
|
|
582
|
+
return candidate !== ""
|
|
583
|
+
&& candidate !== ".."
|
|
584
|
+
&& !candidate.startsWith(`..${sep}`)
|
|
585
|
+
&& !isAbsolute(candidate);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function validatedProjectionNotes(notes: readonly Note[]): {
|
|
589
|
+
readonly notes: SemanticProjectionManifest["notes"];
|
|
590
|
+
readonly totalBytes: number;
|
|
591
|
+
} {
|
|
592
|
+
if (notes.length > MAX_SCANNED_NOTES) {
|
|
593
|
+
throw new VaultScanBudgetError(
|
|
594
|
+
"notes",
|
|
595
|
+
MAX_SCANNED_NOTES,
|
|
596
|
+
`Vault scan exceeds the ${MAX_SCANNED_NOTES} Markdown note limit.`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
const paths = new Set<string>();
|
|
600
|
+
const validated: SemanticProjectionManifest["notes"][number][] = [];
|
|
601
|
+
let totalBytes = 0;
|
|
602
|
+
for (const note of notes) {
|
|
603
|
+
const path = note.path;
|
|
604
|
+
const segments = path.split("/");
|
|
605
|
+
if (path === ""
|
|
606
|
+
|| isAbsolute(path)
|
|
607
|
+
|| path.includes("\\")
|
|
608
|
+
|| !path.endsWith(".md")
|
|
609
|
+
|| segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
610
|
+
throw new Error(`Semantic projection note path ${JSON.stringify(path)} is invalid.`);
|
|
611
|
+
}
|
|
612
|
+
if (paths.has(path)) {
|
|
613
|
+
throw new Error(`Semantic projection note path ${JSON.stringify(path)} is duplicated.`);
|
|
614
|
+
}
|
|
615
|
+
paths.add(path);
|
|
616
|
+
const bytes = Buffer.byteLength(note.content, "utf8");
|
|
617
|
+
if (bytes > MAX_NOTE_UTF8_BYTES) {
|
|
618
|
+
throw new VaultScanBudgetError(
|
|
619
|
+
"note-bytes",
|
|
620
|
+
MAX_NOTE_UTF8_BYTES,
|
|
621
|
+
`Vault note ${JSON.stringify(path)} exceeds the ${MAX_NOTE_UTF8_BYTES}-byte UTF-8 limit.`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
totalBytes += bytes;
|
|
625
|
+
if (totalBytes > MAX_VAULT_UTF8_BYTES) {
|
|
626
|
+
throw new VaultScanBudgetError(
|
|
627
|
+
"total-bytes",
|
|
628
|
+
MAX_VAULT_UTF8_BYTES,
|
|
629
|
+
`Vault scan exceeds the ${MAX_VAULT_UTF8_BYTES}-byte cumulative UTF-8 limit.`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
validated.push({
|
|
633
|
+
path,
|
|
634
|
+
sha256: createHash("sha256").update(note.content).digest("hex"),
|
|
635
|
+
bytes,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return { notes: validated, totalBytes };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
642
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
643
|
+
let handle: FileHandle | undefined;
|
|
644
|
+
try {
|
|
645
|
+
handle = await open(
|
|
646
|
+
temporary,
|
|
647
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
648
|
+
0o600,
|
|
649
|
+
);
|
|
650
|
+
await handle.writeFile(content, "utf8");
|
|
651
|
+
await handle.sync();
|
|
652
|
+
await handle.close();
|
|
653
|
+
handle = undefined;
|
|
654
|
+
await rename(temporary, path);
|
|
655
|
+
} finally {
|
|
656
|
+
await handle?.close().catch(() => undefined);
|
|
657
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function snapshotCacheOwner(database: string): SnapshotCacheOwner {
|
|
662
|
+
return {
|
|
663
|
+
version: SNAPSHOT_OWNER_VERSION,
|
|
664
|
+
kind: SNAPSHOT_OWNER_KIND,
|
|
665
|
+
databaseIdentity: resolve(database),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function assertOwnedSnapshotCache(
|
|
670
|
+
database: string,
|
|
671
|
+
snapshotDirectory: string,
|
|
672
|
+
): Promise<"absent" | "owned"> {
|
|
673
|
+
if (await safeDirectory(snapshotDirectory, "Semantic snapshot cache") === "absent") {
|
|
674
|
+
return "absent";
|
|
675
|
+
}
|
|
676
|
+
const expected = snapshotCacheOwner(database);
|
|
677
|
+
let actual: unknown;
|
|
678
|
+
try {
|
|
679
|
+
actual = JSON.parse(await boundedMetadataText(
|
|
680
|
+
join(snapshotDirectory, SNAPSHOT_OWNER_NAME),
|
|
681
|
+
MAX_OWNER_BYTES,
|
|
682
|
+
"Semantic snapshot cache owner",
|
|
683
|
+
)) as unknown;
|
|
684
|
+
} catch {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`Semantic snapshot cache ${JSON.stringify(snapshotDirectory)} is unowned or has an incompatible ownership marker; remove this disposable directory explicitly before retrying.`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
if (!isRecord(actual)
|
|
690
|
+
|| actual.version !== expected.version
|
|
691
|
+
|| actual.kind !== expected.kind
|
|
692
|
+
|| actual.databaseIdentity !== expected.databaseIdentity) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`Semantic snapshot cache ${JSON.stringify(snapshotDirectory)} is unowned or has an incompatible ownership marker; remove this disposable directory explicitly before retrying.`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return "owned";
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function ensureOwnedSnapshotCache(
|
|
701
|
+
database: string,
|
|
702
|
+
snapshotDirectory: string,
|
|
703
|
+
): Promise<void> {
|
|
704
|
+
if (await assertOwnedSnapshotCache(database, snapshotDirectory) === "owned") return;
|
|
705
|
+
const temporary = `${snapshotDirectory}.initialize-${process.pid}-${randomUUID()}`;
|
|
706
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
707
|
+
try {
|
|
708
|
+
await writeFile(
|
|
709
|
+
join(temporary, SNAPSHOT_OWNER_NAME),
|
|
710
|
+
`${JSON.stringify(snapshotCacheOwner(database))}\n`,
|
|
711
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
712
|
+
);
|
|
713
|
+
try {
|
|
714
|
+
await rename(temporary, snapshotDirectory);
|
|
715
|
+
return;
|
|
716
|
+
} catch (error: unknown) {
|
|
717
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(errorCode(error) ?? "")) throw error;
|
|
718
|
+
}
|
|
719
|
+
} finally {
|
|
720
|
+
await rm(temporary, { recursive: true, force: true });
|
|
721
|
+
}
|
|
722
|
+
await assertOwnedSnapshotCache(database, snapshotDirectory);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function existingGenerationMatches(
|
|
726
|
+
generationPath: string,
|
|
727
|
+
manifest: SemanticProjectionManifest,
|
|
728
|
+
manifestText: string,
|
|
729
|
+
): Promise<boolean> {
|
|
730
|
+
try {
|
|
731
|
+
if (await safeDirectory(generationPath, "Semantic projection generation") === "absent") {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
if (await boundedMetadataText(
|
|
735
|
+
join(generationPath, MANIFEST_NAME),
|
|
736
|
+
MAX_MANIFEST_BYTES,
|
|
737
|
+
"Semantic projection manifest",
|
|
738
|
+
) !== manifestText) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
for (const entry of manifest.notes) {
|
|
742
|
+
const path = resolve(generationPath, ...entry.path.split("/"));
|
|
743
|
+
if (!confinedPath(generationPath, path)) return false;
|
|
744
|
+
const content = await boundedMetadataText(
|
|
745
|
+
path,
|
|
746
|
+
MAX_NOTE_UTF8_BYTES,
|
|
747
|
+
`Semantic projection note ${JSON.stringify(entry.path)}`,
|
|
748
|
+
);
|
|
749
|
+
if (Buffer.byteLength(content, "utf8") !== entry.bytes
|
|
750
|
+
|| createHash("sha256").update(content).digest("hex") !== entry.sha256) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const expectedNotes = new Set(manifest.notes.map(({ path }) => join(...path.split("/"))));
|
|
756
|
+
const expectedDirectories = new Set<string>();
|
|
757
|
+
for (const path of expectedNotes) {
|
|
758
|
+
let parent = dirname(path);
|
|
759
|
+
while (parent !== ".") {
|
|
760
|
+
expectedDirectories.add(parent);
|
|
761
|
+
parent = dirname(parent);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const pending = [generationPath];
|
|
765
|
+
let entries = 0;
|
|
766
|
+
const maximumEntries = manifest.notes.length
|
|
767
|
+
+ expectedDirectories.size
|
|
768
|
+
+ MAX_CACHE_DIRECTORY_ENTRIES
|
|
769
|
+
+ 1;
|
|
770
|
+
while (pending.length > 0) {
|
|
771
|
+
const directoryPath = pending.pop();
|
|
772
|
+
if (directoryPath === undefined) break;
|
|
773
|
+
const directory = await opendir(directoryPath);
|
|
774
|
+
try {
|
|
775
|
+
for await (const entry of directory) {
|
|
776
|
+
entries += 1;
|
|
777
|
+
if (entries > maximumEntries || entry.isSymbolicLink()) return false;
|
|
778
|
+
const absolute = join(directoryPath, entry.name);
|
|
779
|
+
const path = relative(generationPath, absolute);
|
|
780
|
+
if (entry.isDirectory()) {
|
|
781
|
+
if (!expectedDirectories.has(path)) return false;
|
|
782
|
+
pending.push(absolute);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (!entry.isFile()) return false;
|
|
786
|
+
if (path === MANIFEST_NAME || expectedNotes.has(path)) continue;
|
|
787
|
+
if (dirname(path) === "."
|
|
788
|
+
&& entry.name.startsWith(READER_PREFIX)
|
|
789
|
+
&& entry.name.endsWith(".json")) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
} finally {
|
|
795
|
+
try {
|
|
796
|
+
await directory.close();
|
|
797
|
+
} catch {
|
|
798
|
+
// Async iteration closes the directory after exhaustion.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return true;
|
|
803
|
+
} catch (error: unknown) {
|
|
804
|
+
if (error instanceof RangeError || (error instanceof Error && error.message.includes("symbolic link"))) {
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function materializeGeneration(
|
|
812
|
+
snapshotDirectory: string,
|
|
813
|
+
generationPath: string,
|
|
814
|
+
manifest: SemanticProjectionManifest,
|
|
815
|
+
manifestText: string,
|
|
816
|
+
notesByPath: ReadonlyMap<string, Note>,
|
|
817
|
+
): Promise<void> {
|
|
818
|
+
if (await existingGenerationMatches(generationPath, manifest, manifestText)) return;
|
|
819
|
+
if (await safeDirectory(generationPath, "Semantic projection generation") === "directory") {
|
|
820
|
+
if (await activeGenerationReaders(generationPath) > 0) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
"Semantic projection cache verification failed while this generation still has active readers; close those sessions and retry the repair.",
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
await rm(generationPath, { recursive: true, force: true });
|
|
826
|
+
}
|
|
827
|
+
const temporary = join(
|
|
828
|
+
snapshotDirectory,
|
|
829
|
+
`.temporary-${process.pid}-${randomUUID()}`,
|
|
830
|
+
);
|
|
831
|
+
await mkdir(temporary, { recursive: true, mode: 0o700 });
|
|
832
|
+
try {
|
|
833
|
+
for (const entry of manifest.notes) {
|
|
834
|
+
const note = notesByPath.get(entry.path);
|
|
835
|
+
if (note === undefined) {
|
|
836
|
+
throw new Error(`Semantic projection lost note ${JSON.stringify(entry.path)}.`);
|
|
837
|
+
}
|
|
838
|
+
const bytes = Buffer.byteLength(note.content, "utf8");
|
|
839
|
+
const sha256 = createHash("sha256").update(note.content).digest("hex");
|
|
840
|
+
if (bytes !== entry.bytes || sha256 !== entry.sha256) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`Semantic projection note ${JSON.stringify(entry.path)} changed after validation.`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
const destination = resolve(temporary, ...entry.path.split("/"));
|
|
846
|
+
if (!confinedPath(temporary, destination)) {
|
|
847
|
+
throw new Error(`Semantic projection path ${JSON.stringify(entry.path)} escapes its generation.`);
|
|
848
|
+
}
|
|
849
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
850
|
+
await writeFile(destination, note.content, {
|
|
851
|
+
encoding: "utf8",
|
|
852
|
+
flag: "wx",
|
|
853
|
+
mode: 0o600,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
await atomicWrite(join(temporary, MANIFEST_NAME), manifestText);
|
|
857
|
+
await rename(temporary, generationPath);
|
|
858
|
+
} catch (error: unknown) {
|
|
859
|
+
await rm(temporary, { recursive: true, force: true });
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function readerOwnerName(owner: LeaseOwner): string {
|
|
865
|
+
return `${READER_PREFIX}${owner.pid}-${owner.token}.json`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function activeGenerationReaders(generation: string): Promise<number> {
|
|
869
|
+
let directory: Awaited<ReturnType<typeof opendir>>;
|
|
870
|
+
try {
|
|
871
|
+
directory = await opendir(generation);
|
|
872
|
+
} catch (error: unknown) {
|
|
873
|
+
if (errorCode(error) === "ENOENT") return 0;
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
let active = 0;
|
|
877
|
+
let readerEntries = 0;
|
|
878
|
+
try {
|
|
879
|
+
for await (const entry of directory) {
|
|
880
|
+
if (!entry.isFile() || !entry.name.startsWith(READER_PREFIX) || !entry.name.endsWith(".json")) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
readerEntries += 1;
|
|
884
|
+
if (readerEntries > MAX_CACHE_DIRECTORY_ENTRIES) {
|
|
885
|
+
throw new RangeError("Semantic projection generation has too many reader entries.");
|
|
886
|
+
}
|
|
887
|
+
const path = join(generation, entry.name);
|
|
888
|
+
let owner: LeaseOwner | null;
|
|
889
|
+
try {
|
|
890
|
+
owner = parsedOwner(JSON.parse(
|
|
891
|
+
await boundedMetadataText(path, MAX_OWNER_BYTES, "Semantic projection reader"),
|
|
892
|
+
) as unknown);
|
|
893
|
+
} catch (error: unknown) {
|
|
894
|
+
if (error instanceof RangeError) throw error;
|
|
895
|
+
owner = null;
|
|
896
|
+
}
|
|
897
|
+
if (owner !== null && processIsAlive(owner.pid)) {
|
|
898
|
+
active += 1;
|
|
899
|
+
} else {
|
|
900
|
+
await rm(path, { force: true });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
} finally {
|
|
904
|
+
try {
|
|
905
|
+
await directory.close();
|
|
906
|
+
} catch {
|
|
907
|
+
// Async iteration closes the directory after exhaustion.
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return active;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function activeReaderGenerations(database: string): Promise<ReadonlySet<string>> {
|
|
914
|
+
const canonicalDatabase = await canonicalSemanticDatabasePath(database);
|
|
915
|
+
const snapshotDirectory = `${canonicalDatabase}.snapshot`;
|
|
916
|
+
if (await assertOwnedSnapshotCache(canonicalDatabase, snapshotDirectory) === "absent") {
|
|
917
|
+
return new Set();
|
|
918
|
+
}
|
|
919
|
+
const active = new Set<string>();
|
|
920
|
+
const directory = await opendir(snapshotDirectory);
|
|
921
|
+
let entries = 0;
|
|
922
|
+
try {
|
|
923
|
+
for await (const entry of directory) {
|
|
924
|
+
entries += 1;
|
|
925
|
+
if (entries > MAX_CACHE_DIRECTORY_ENTRIES) {
|
|
926
|
+
throw new RangeError("Semantic snapshot cache has too many metadata entries.");
|
|
927
|
+
}
|
|
928
|
+
if (!entry.isDirectory() || !entry.name.startsWith(GENERATION_PREFIX)) continue;
|
|
929
|
+
if (await activeGenerationReaders(join(snapshotDirectory, entry.name)) > 0) {
|
|
930
|
+
active.add(entry.name);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
} finally {
|
|
934
|
+
try {
|
|
935
|
+
await directory.close();
|
|
936
|
+
} catch {
|
|
937
|
+
// Async iteration closes the directory after exhaustion.
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return active;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Serialize one QMD mutation while allowing readers of the same immutable
|
|
945
|
+
* projection generation. A generation switch waits until older readers close.
|
|
946
|
+
*/
|
|
947
|
+
export async function withSemanticGenerationWriterLease<Value>(
|
|
948
|
+
database: string,
|
|
949
|
+
generation: string,
|
|
950
|
+
operation: () => Promise<Value>,
|
|
951
|
+
options: SemanticGenerationWriterLeaseOptions = {},
|
|
952
|
+
): Promise<Value> {
|
|
953
|
+
if (!generation.startsWith(GENERATION_PREFIX)) {
|
|
954
|
+
throw new Error("Semantic projection generation is invalid.");
|
|
955
|
+
}
|
|
956
|
+
if (options.excludeReaders !== undefined && typeof options.excludeReaders !== "boolean") {
|
|
957
|
+
throw new TypeError("Semantic writer excludeReaders must be a boolean.");
|
|
958
|
+
}
|
|
959
|
+
const waitMs = checkedLeaseBound(
|
|
960
|
+
options.waitMs,
|
|
961
|
+
DEFAULT_LEASE_WAIT_MS,
|
|
962
|
+
MAX_LEASE_WAIT_MS,
|
|
963
|
+
"Semantic writer lease wait",
|
|
964
|
+
);
|
|
965
|
+
const pollMs = checkedLeaseBound(
|
|
966
|
+
options.pollMs,
|
|
967
|
+
DEFAULT_LEASE_POLL_MS,
|
|
968
|
+
MAX_LEASE_POLL_MS,
|
|
969
|
+
"Semantic writer lease poll interval",
|
|
970
|
+
);
|
|
971
|
+
const readerDescription = options.excludeReaders === true
|
|
972
|
+
? "semantic projection readers"
|
|
973
|
+
: "readers of an older semantic projection";
|
|
974
|
+
const startedAt = Date.now();
|
|
975
|
+
for (;;) {
|
|
976
|
+
const elapsed = Date.now() - startedAt;
|
|
977
|
+
if (elapsed >= waitMs) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
`Timed out after ${waitMs}ms waiting for ${readerDescription} to close.`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
const lease = await acquireSemanticWriterLease(database, {
|
|
983
|
+
waitMs: Math.max(1, waitMs - elapsed),
|
|
984
|
+
pollMs,
|
|
985
|
+
});
|
|
986
|
+
let compatible = false;
|
|
987
|
+
try {
|
|
988
|
+
const active = await activeReaderGenerations(database);
|
|
989
|
+
compatible = options.excludeReaders === true
|
|
990
|
+
? active.size === 0
|
|
991
|
+
: [...active].every((candidate) => candidate === generation);
|
|
992
|
+
if (compatible) return await operation();
|
|
993
|
+
} finally {
|
|
994
|
+
await lease.release();
|
|
995
|
+
}
|
|
996
|
+
const waited = Date.now() - startedAt;
|
|
997
|
+
if (waited >= waitMs) {
|
|
998
|
+
throw new Error(
|
|
999
|
+
`Timed out after ${waitMs}ms waiting for ${readerDescription} to close.`,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
await Bun.sleep(Math.min(pollMs, waitMs - waited));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function cleanUnusedGenerations(
|
|
1007
|
+
snapshotDirectory: string,
|
|
1008
|
+
currentGeneration: string,
|
|
1009
|
+
): Promise<void> {
|
|
1010
|
+
const directory = await opendir(snapshotDirectory);
|
|
1011
|
+
let entries = 0;
|
|
1012
|
+
try {
|
|
1013
|
+
for await (const entry of directory) {
|
|
1014
|
+
entries += 1;
|
|
1015
|
+
if (entries > MAX_CACHE_DIRECTORY_ENTRIES) {
|
|
1016
|
+
throw new RangeError("Semantic snapshot cache has too many metadata entries.");
|
|
1017
|
+
}
|
|
1018
|
+
if (!entry.isDirectory()) continue;
|
|
1019
|
+
const path = join(snapshotDirectory, entry.name);
|
|
1020
|
+
if (entry.name.startsWith(".temporary-")) {
|
|
1021
|
+
await rm(path, { recursive: true, force: true });
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (!entry.name.startsWith(GENERATION_PREFIX) || entry.name === currentGeneration) continue;
|
|
1025
|
+
if (await activeGenerationReaders(path) === 0) {
|
|
1026
|
+
await rm(path, { recursive: true, force: true });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
} finally {
|
|
1030
|
+
try {
|
|
1031
|
+
await directory.close();
|
|
1032
|
+
} catch {
|
|
1033
|
+
// Async iteration closes the directory after exhaustion.
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Validate one bounded projection and atomically settle its empty database identity. */
|
|
1039
|
+
export async function describeSemanticProjection(
|
|
1040
|
+
database: string,
|
|
1041
|
+
root: string,
|
|
1042
|
+
notes: readonly Note[],
|
|
1043
|
+
indexIdentity: SemanticIndexIdentity,
|
|
1044
|
+
): Promise<SemanticProjectionDescription> {
|
|
1045
|
+
const [canonicalRoot, prospectiveDatabase] = await Promise.all([
|
|
1046
|
+
realpath(root),
|
|
1047
|
+
canonicalSemanticDatabasePath(database),
|
|
1048
|
+
]);
|
|
1049
|
+
assertSemanticCacheOutsideVault(canonicalRoot, prospectiveDatabase);
|
|
1050
|
+
const validated = validatedProjectionNotes(notes);
|
|
1051
|
+
const validatedIdentity = validatedIndexIdentity(indexIdentity);
|
|
1052
|
+
const canonicalDatabase = await settledSemanticDatabasePath(prospectiveDatabase);
|
|
1053
|
+
assertSemanticCacheOutsideVault(canonicalRoot, canonicalDatabase);
|
|
1054
|
+
const identity = createHash("sha256").update(JSON.stringify({
|
|
1055
|
+
version: PROJECTION_VERSION,
|
|
1056
|
+
indexIdentity: validatedIdentity,
|
|
1057
|
+
root: canonicalRoot,
|
|
1058
|
+
notes: validated.notes,
|
|
1059
|
+
totalBytes: validated.totalBytes,
|
|
1060
|
+
})).digest("hex");
|
|
1061
|
+
const generation = `${GENERATION_PREFIX}${identity.slice(0, 32)}`;
|
|
1062
|
+
const snapshotDirectory = `${canonicalDatabase}.snapshot`;
|
|
1063
|
+
const generationPath = join(snapshotDirectory, generation);
|
|
1064
|
+
const manifest: SemanticProjectionManifest = {
|
|
1065
|
+
version: PROJECTION_VERSION,
|
|
1066
|
+
indexIdentity: validatedIdentity,
|
|
1067
|
+
root: canonicalRoot,
|
|
1068
|
+
generation,
|
|
1069
|
+
notes: validated.notes,
|
|
1070
|
+
totalBytes: validated.totalBytes,
|
|
1071
|
+
};
|
|
1072
|
+
const manifestText = `${JSON.stringify(manifest)}\n`;
|
|
1073
|
+
if (Buffer.byteLength(manifestText) > MAX_MANIFEST_BYTES) {
|
|
1074
|
+
throw new RangeError(
|
|
1075
|
+
`Semantic projection manifest exceeds the ${MAX_MANIFEST_BYTES}-byte metadata limit.`,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
database: canonicalDatabase,
|
|
1080
|
+
snapshotDirectory,
|
|
1081
|
+
generationPath,
|
|
1082
|
+
manifest,
|
|
1083
|
+
manifestText,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Materialize one described snapshot and retain its reader generation.
|
|
1089
|
+
* Callers must hold that generation's semantic writer lease.
|
|
1090
|
+
*/
|
|
1091
|
+
export async function prepareSemanticProjection(
|
|
1092
|
+
description: SemanticProjectionDescription,
|
|
1093
|
+
notes: readonly Note[],
|
|
1094
|
+
): Promise<SemanticProjection> {
|
|
1095
|
+
const { database, snapshotDirectory, generationPath, manifest, manifestText } = description;
|
|
1096
|
+
const validated = validatedProjectionNotes(notes);
|
|
1097
|
+
if (validated.totalBytes !== manifest.totalBytes
|
|
1098
|
+
|| JSON.stringify(validated.notes) !== JSON.stringify(manifest.notes)) {
|
|
1099
|
+
throw new Error("Semantic projection notes changed after validation.");
|
|
1100
|
+
}
|
|
1101
|
+
const notesByPath = new Map(notes.map((note) => [note.path, note]));
|
|
1102
|
+
await ensureOwnedSnapshotCache(database, snapshotDirectory);
|
|
1103
|
+
await materializeGeneration(
|
|
1104
|
+
snapshotDirectory,
|
|
1105
|
+
generationPath,
|
|
1106
|
+
manifest,
|
|
1107
|
+
manifestText,
|
|
1108
|
+
notesByPath,
|
|
1109
|
+
);
|
|
1110
|
+
const reader: LeaseOwner = {
|
|
1111
|
+
version: LEASE_VERSION,
|
|
1112
|
+
pid: process.pid,
|
|
1113
|
+
token: randomUUID(),
|
|
1114
|
+
acquiredAt: new Date().toISOString(),
|
|
1115
|
+
};
|
|
1116
|
+
const readerPath = join(generationPath, readerOwnerName(reader));
|
|
1117
|
+
await writeFile(readerPath, `${JSON.stringify(reader)}\n`, {
|
|
1118
|
+
encoding: "utf8",
|
|
1119
|
+
flag: "wx",
|
|
1120
|
+
mode: 0o600,
|
|
1121
|
+
});
|
|
1122
|
+
try {
|
|
1123
|
+
await atomicWrite(join(snapshotDirectory, MANIFEST_NAME), manifestText);
|
|
1124
|
+
await cleanUnusedGenerations(snapshotDirectory, manifest.generation);
|
|
1125
|
+
} catch (error: unknown) {
|
|
1126
|
+
await rm(readerPath, { force: true });
|
|
1127
|
+
throw error;
|
|
1128
|
+
}
|
|
1129
|
+
let released = false;
|
|
1130
|
+
return {
|
|
1131
|
+
root: generationPath,
|
|
1132
|
+
manifest,
|
|
1133
|
+
release: async () => {
|
|
1134
|
+
if (released) return;
|
|
1135
|
+
released = true;
|
|
1136
|
+
await rm(readerPath, { force: true });
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}
|