llmnav 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +294 -0
  4. package/ROADMAP.md +71 -0
  5. package/bin/llmnav.js +16 -0
  6. package/docs/agent-integration.md +114 -0
  7. package/docs/api.md +290 -0
  8. package/docs/architecture.md +286 -0
  9. package/docs/benchmarking.md +164 -0
  10. package/docs/ci.md +196 -0
  11. package/docs/cli.md +233 -0
  12. package/docs/configuration.md +117 -0
  13. package/docs/editor-integration.md +29 -0
  14. package/docs/faq.md +59 -0
  15. package/docs/graph.md +92 -0
  16. package/docs/language-examples.md +130 -0
  17. package/docs/migration.md +130 -0
  18. package/docs/performance-v0.2.md +42 -0
  19. package/docs/provider-neutral-integration.md +66 -0
  20. package/docs/publishing.md +86 -0
  21. package/docs/quickstart.md +139 -0
  22. package/docs/research.md +31 -0
  23. package/docs/spec.md +424 -0
  24. package/examples/provider-neutral-host.d.mts +17 -0
  25. package/examples/provider-neutral-host.mjs +40 -0
  26. package/package.json +79 -0
  27. package/schema/config.schema.json +296 -0
  28. package/src/agent-protocol.js +117 -0
  29. package/src/agent-tools.js +61 -0
  30. package/src/agents.js +127 -0
  31. package/src/boundaries.js +50 -0
  32. package/src/changes.js +168 -0
  33. package/src/cli.js +459 -0
  34. package/src/config.js +305 -0
  35. package/src/contracts.js +70 -0
  36. package/src/declaration.js +334 -0
  37. package/src/doctor.js +124 -0
  38. package/src/editor.js +107 -0
  39. package/src/evaluation.js +67 -0
  40. package/src/files.js +81 -0
  41. package/src/formatter.js +23 -0
  42. package/src/generator.js +528 -0
  43. package/src/graph-input.js +157 -0
  44. package/src/graph.js +403 -0
  45. package/src/incremental.js +262 -0
  46. package/src/index.d.ts +673 -0
  47. package/src/index.js +115 -0
  48. package/src/initializer.js +137 -0
  49. package/src/inverted-index.js +350 -0
  50. package/src/parser.js +449 -0
  51. package/src/project.js +65 -0
  52. package/src/prompt-bundle.js +108 -0
  53. package/src/registry.js +107 -0
  54. package/src/sarif.js +70 -0
  55. package/src/search-shards.js +75 -0
  56. package/src/search.js +636 -0
  57. package/src/spec.d.ts +27 -0
  58. package/src/spec.js +237 -0
  59. package/src/tokenizer.js +37 -0
  60. package/src/transaction.js +557 -0
  61. package/src/util.js +256 -0
  62. package/src/validator.js +635 -0
  63. package/templates/file-card.txt +8 -0
  64. package/templates/lexicon.json +7 -0
  65. package/templates/line-card.txt +9 -0
  66. package/templates/module-card.txt +9 -0
  67. package/templates/queries.jsonl +1 -0
  68. package/templates/symbol-card.txt +10 -0
@@ -0,0 +1,557 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.index.transaction
3
+ role=Commit a complete generated cache with rollback and crash recovery across POSIX and Windows rename behavior.
4
+ owns=cache staging|directory swap|generation recovery
5
+ excludes=artifact semantics|source indexing
6
+ search=transactional generation|atomic cache swap|Windows rename recovery
7
+ rel=workflow>llmnav.index.generate
8
+ stability=architecture
9
+ */
10
+
11
+ import { randomBytes } from "node:crypto";
12
+ import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ import {
16
+ assertNoSymlinkTraversal,
17
+ atomicWrite,
18
+ compareText,
19
+ readJson,
20
+ readJsonSafe,
21
+ projectRelativePath,
22
+ relativePosix,
23
+ sha256,
24
+ stableStringify,
25
+ toPosix,
26
+ } from "./util.js";
27
+
28
+ export const TRANSACTION_SCHEMA_VERSION = 1;
29
+ export const TRANSACTION_ABORT_EXIT_CODE = 86;
30
+ const RETRYABLE_RENAME_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]);
31
+ const DEFAULT_RETRY_DELAYS = Object.freeze([0, 8, 16, 32, 64, 128, 256, 512]);
32
+ const JOURNAL_PHASES = new Set(["prepared", "old-moved", "new-installed", "committed"]);
33
+ const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
34
+ const DEFAULT_LOCK_POLL_MS = 50;
35
+ const CONTROL_ARTIFACT_PATHS = new Set([".llmnav/ids.jsonl", ".llmnav/order.lock"]);
36
+
37
+ export async function withGenerationLock(root, callback, options = {}) {
38
+ const lock = await acquireGenerationLock(root, options);
39
+ try {
40
+ return await callback(lock);
41
+ } finally {
42
+ await releaseGenerationLock(lock);
43
+ }
44
+ }
45
+
46
+ export async function acquireGenerationLock(root, options = {}) {
47
+ const controlDirectory = path.join(root, ".llmnav");
48
+ const lockPath = path.join(controlDirectory, "generation.lock");
49
+ await assertNoSymlinkTraversal(root, controlDirectory, ".llmnav");
50
+ await mkdir(controlDirectory, { recursive: true });
51
+ const ownerId = options.ownerId ?? createTransactionId();
52
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
53
+ const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
54
+ const delays = options.delays ?? [0, ...Array.from({ length: Math.ceil(timeoutMs / pollMs) }, () => pollMs)];
55
+ const openImpl = options.openImpl ?? open;
56
+ const sleepImpl = options.sleepImpl ?? sleep;
57
+
58
+ for (const delay of delays) {
59
+ if (delay > 0) await sleepImpl(delay);
60
+ try {
61
+ const handle = await openImpl(lockPath, "wx");
62
+ try {
63
+ await handle.writeFile(stableStringify({ schemaVersion: 1, ownerId, pid: process.pid }));
64
+ await handle.sync();
65
+ } finally {
66
+ await handle.close();
67
+ }
68
+ return { root, lockPath, ownerId };
69
+ } catch (error) {
70
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
71
+ const existing = await readJsonSafe(lockPath, null);
72
+ if (existing && Number.isInteger(existing.pid) && !isProcessAlive(existing.pid)) {
73
+ await removeOwnedLock(lockPath, existing.ownerId);
74
+ }
75
+ }
76
+ }
77
+ throw new Error("Timed out waiting for the LLMNav generation lock.");
78
+ }
79
+
80
+ export async function releaseGenerationLock(lock) {
81
+ await removeOwnedLock(lock.lockPath, lock.ownerId);
82
+ }
83
+
84
+ export async function commitGeneratedCache(root, cacheDirectory, artifacts, options = {}) {
85
+ if (!options.lockOwnerId) {
86
+ return withGenerationLock(
87
+ root,
88
+ (lock) => commitGeneratedCache(root, cacheDirectory, artifacts, { ...options, lockOwnerId: lock.ownerId }),
89
+ options.lockOptions,
90
+ );
91
+ }
92
+ const cacheRelative = projectRelativePath(cacheDirectory, "cacheDirectory");
93
+ const cachePath = path.join(root, cacheRelative);
94
+ const controlDirectory = path.join(root, ".llmnav");
95
+ const transactionsDirectory = path.join(controlDirectory, ".transactions");
96
+ const journalPath = path.join(controlDirectory, "generation-transaction.json");
97
+ await assertNoSymlinkTraversal(root, controlDirectory, ".llmnav");
98
+ await assertNoSymlinkTraversal(root, cachePath, cacheRelative);
99
+ const recovery = await recoverGenerationTransaction(root, {
100
+ cacheDirectory: cacheRelative,
101
+ renameOptions: options.renameOptions,
102
+ lockOwnerId: options.lockOwnerId,
103
+ });
104
+
105
+ const transactionId = options.transactionId ?? createTransactionId();
106
+ const transactionPath = path.join(transactionsDirectory, transactionId);
107
+ const stagePath = path.join(transactionPath, "stage");
108
+ const backupPath = path.join(transactionPath, "backup");
109
+ const hadExistingCacheAtStart = await pathExists(cachePath);
110
+ let controlRecords = [];
111
+ await mkdir(stagePath, { recursive: true });
112
+
113
+ const cachePrefix = `${cacheRelative.replace(/\/+$/u, "")}/`;
114
+ const cacheArtifacts = [...artifacts.entries()]
115
+ .filter(([relativePath]) => toPosix(relativePath).startsWith(cachePrefix))
116
+ .sort(([left], [right]) => compareText(toPosix(left), toPosix(right)));
117
+ if (cacheArtifacts.length === 0) throw new Error(`No generated artifacts target ${cacheRelative}.`);
118
+
119
+ try {
120
+ for (const [relativePath, content] of cacheArtifacts) {
121
+ const normalized = projectRelativePath(relativePath, `Generated artifact ${relativePath}`);
122
+ const stageRelative = normalized.slice(cachePrefix.length);
123
+ if (!normalized.startsWith(cachePrefix) || !stageRelative) {
124
+ throw new Error(`Generated artifact ${normalized} escapes the staged cache.`);
125
+ }
126
+ const destination = path.join(stagePath, ...stageRelative.split("/"));
127
+ const relativeDestination = path.relative(stagePath, path.resolve(destination));
128
+ if (relativeDestination === ".." || relativeDestination.startsWith(`..${path.sep}`) || path.isAbsolute(relativeDestination)) {
129
+ throw new Error(`Generated artifact ${normalized} escapes the staged cache.`);
130
+ }
131
+ await mkdir(path.dirname(destination), { recursive: true });
132
+ await writeFile(destination, content, "utf8");
133
+ await invokeFailpoint(`after-write:${stageRelative}`, options);
134
+ }
135
+ await verifyStagedArtifacts(stagePath, cacheArtifacts, cachePrefix);
136
+ controlRecords = await stageControlArtifacts(root, transactionPath, options.controlArtifacts);
137
+ await invokeFailpoint("after-stage", options);
138
+
139
+ const hadExistingCache = hadExistingCacheAtStart;
140
+ let journal = {
141
+ schemaVersion: TRANSACTION_SCHEMA_VERSION,
142
+ cacheDirectory: cacheRelative,
143
+ transactionDirectory: relativePosix(root, transactionPath),
144
+ stageDirectory: relativePosix(root, stagePath),
145
+ backupDirectory: relativePosix(root, backupPath),
146
+ hadExistingCache,
147
+ ownerId: options.lockOwnerId,
148
+ controlArtifacts: controlRecords,
149
+ phase: "prepared",
150
+ };
151
+ await atomicWrite(journalPath, stableStringify(journal));
152
+ await invokeFailpoint("after-journal", options);
153
+
154
+ if (hadExistingCache) {
155
+ await renameWithRetry(cachePath, backupPath, options.renameOptions);
156
+ }
157
+ await moveControlArtifactsToBackup(root, controlRecords, options.renameOptions);
158
+ journal = { ...journal, phase: "old-moved" };
159
+ await atomicWrite(journalPath, stableStringify(journal));
160
+ await invokeFailpoint("after-cache-moved", options);
161
+
162
+ await renameWithRetry(stagePath, cachePath, options.renameOptions);
163
+ await installControlArtifacts(root, controlRecords, options.renameOptions);
164
+ journal = { ...journal, phase: "new-installed" };
165
+ await atomicWrite(journalPath, stableStringify(journal));
166
+ await verifyCommittedCache(cachePath, cacheRelative);
167
+ await verifyControlArtifacts(root, controlRecords);
168
+ await invokeFailpoint("after-new-installed", options);
169
+
170
+ journal = { ...journal, phase: "committed" };
171
+ await atomicWrite(journalPath, stableStringify(journal));
172
+ if (hadExistingCache) await removeWithRetry(backupPath, options.renameOptions);
173
+ await removeWithRetry(transactionPath, options.renameOptions);
174
+ await removeWithRetry(journalPath, options.renameOptions);
175
+ await cleanupEmptyTransactionsDirectory(transactionsDirectory);
176
+
177
+ return {
178
+ committed: true,
179
+ skipped: false,
180
+ recovered: recovery.recovered,
181
+ recoveryAction: recovery.action,
182
+ transactionId,
183
+ replacedExisting: hadExistingCache,
184
+ };
185
+ } catch (error) {
186
+ const journal = await readJson(journalPath, null);
187
+ const ownedJournal = journal?.ownerId === options.lockOwnerId ? journal : null;
188
+ if (ownedJournal?.phase !== "committed") {
189
+ try {
190
+ await rollbackTransaction(root, ownedJournal ?? {
191
+ schemaVersion: TRANSACTION_SCHEMA_VERSION,
192
+ cacheDirectory: cacheRelative,
193
+ transactionDirectory: relativePosix(root, transactionPath),
194
+ stageDirectory: relativePosix(root, stagePath),
195
+ backupDirectory: relativePosix(root, backupPath),
196
+ hadExistingCache: hadExistingCacheAtStart,
197
+ ownerId: options.lockOwnerId,
198
+ controlArtifacts: controlRecords,
199
+ phase: "prepared",
200
+ }, cacheRelative, options.renameOptions);
201
+ } catch (rollbackError) {
202
+ const message = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
203
+ throw new AggregateError([error, rollbackError], `Generation failed and rollback also failed: ${message}`);
204
+ }
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ export async function recoverGenerationTransaction(root, options = {}) {
211
+ if (!options.lockOwnerId) {
212
+ return withGenerationLock(
213
+ root,
214
+ (lock) => recoverGenerationTransaction(root, { ...options, lockOwnerId: lock.ownerId }),
215
+ options.lockOptions,
216
+ );
217
+ }
218
+ const controlDirectory = path.join(root, ".llmnav");
219
+ await assertNoSymlinkTraversal(root, controlDirectory, ".llmnav");
220
+ const journalPath = path.join(controlDirectory, "generation-transaction.json");
221
+ const journal = await readJson(journalPath, null);
222
+ if (!journal) return { recovered: false, action: "none" };
223
+ validateJournal(root, journal, options.cacheDirectory);
224
+
225
+ const cachePath = path.join(root, journal.cacheDirectory);
226
+ const stagePath = path.join(root, journal.stageDirectory);
227
+ const backupPath = path.join(root, journal.backupDirectory);
228
+ const transactionPath = path.join(root, journal.transactionDirectory);
229
+ await assertNoSymlinkTraversal(root, cachePath, journal.cacheDirectory);
230
+ await assertNoSymlinkTraversal(root, transactionPath, journal.transactionDirectory);
231
+ await assertNoSymlinkTraversal(root, stagePath, journal.stageDirectory);
232
+ await assertNoSymlinkTraversal(root, backupPath, journal.backupDirectory);
233
+ let action = "cleanup";
234
+
235
+ if (journal.phase === "committed") {
236
+ if (await pathExists(cachePath)) {
237
+ action = "finalized-committed-cache";
238
+ } else if (await pathExists(stagePath)) {
239
+ await renameWithRetry(stagePath, cachePath, options.renameOptions);
240
+ action = "installed-committed-stage";
241
+ } else if (await pathExists(backupPath)) {
242
+ await renameWithRetry(backupPath, cachePath, options.renameOptions);
243
+ action = "restored-backup-after-missing-commit";
244
+ }
245
+ await finalizeCommittedControlArtifacts(root, journal.controlArtifacts ?? [], options.renameOptions);
246
+ } else if (await pathExists(backupPath)) {
247
+ if (await pathExists(cachePath)) await removeWithRetry(cachePath, options.renameOptions);
248
+ await renameWithRetry(backupPath, cachePath, options.renameOptions);
249
+ action = "restored-previous-cache";
250
+ } else if (journal.hadExistingCache && await pathExists(cachePath)) {
251
+ action = "kept-existing-cache";
252
+ } else if (!journal.hadExistingCache && await pathExists(cachePath)) {
253
+ await verifyCommittedCache(cachePath, journal.cacheDirectory);
254
+ action = "kept-first-generated-cache";
255
+ } else if (!journal.hadExistingCache && await pathExists(stagePath)) {
256
+ await verifyCommittedCache(stagePath, journal.cacheDirectory);
257
+ await renameWithRetry(stagePath, cachePath, options.renameOptions);
258
+ action = "completed-first-generation";
259
+ } else {
260
+ action = "removed-incomplete-transaction";
261
+ }
262
+
263
+ if (journal.phase !== "committed") {
264
+ await restoreControlArtifacts(root, journal.controlArtifacts ?? [], options.renameOptions);
265
+ }
266
+
267
+ await removeWithRetry(transactionPath, options.renameOptions);
268
+ await removeWithRetry(journalPath, options.renameOptions);
269
+ await cleanupEmptyTransactionsDirectory(path.dirname(transactionPath));
270
+ return { recovered: true, action };
271
+ }
272
+
273
+ export async function renameWithRetry(source, destination, options = {}) {
274
+ const renameImpl = options.renameImpl ?? rename;
275
+ const delays = options.delays ?? DEFAULT_RETRY_DELAYS;
276
+ let lastError;
277
+ for (const delay of delays) {
278
+ if (delay > 0) await (options.sleepImpl ?? sleep)(delay);
279
+ try {
280
+ await renameImpl(source, destination);
281
+ return;
282
+ } catch (error) {
283
+ lastError = error;
284
+ if (!isRetryableRenameError(error)) throw error;
285
+ }
286
+ }
287
+ throw lastError;
288
+ }
289
+
290
+ export async function removeWithRetry(target, options = {}) {
291
+ const rmImpl = options.rmImpl ?? rm;
292
+ const delays = options.delays ?? DEFAULT_RETRY_DELAYS;
293
+ let lastError;
294
+ for (const delay of delays) {
295
+ if (delay > 0) await (options.sleepImpl ?? sleep)(delay);
296
+ try {
297
+ await rmImpl(target, { recursive: true, force: true });
298
+ return;
299
+ } catch (error) {
300
+ lastError = error;
301
+ if (!isRetryableRenameError(error)) throw error;
302
+ }
303
+ }
304
+ throw lastError;
305
+ }
306
+
307
+ async function rollbackTransaction(root, journal, expectedCacheDirectory, renameOptions) {
308
+ validateJournal(root, journal, expectedCacheDirectory);
309
+ const cachePath = path.join(root, journal.cacheDirectory);
310
+ const stagePath = path.join(root, journal.stageDirectory);
311
+ const backupPath = path.join(root, journal.backupDirectory);
312
+ const transactionPath = path.join(root, journal.transactionDirectory);
313
+ const journalPath = path.join(root, ".llmnav", "generation-transaction.json");
314
+
315
+ if (await pathExists(backupPath)) {
316
+ if (await pathExists(cachePath)) await removeWithRetry(cachePath, renameOptions);
317
+ await renameWithRetry(backupPath, cachePath, renameOptions);
318
+ } else if (!journal.hadExistingCache && await pathExists(cachePath)) {
319
+ await removeWithRetry(cachePath, renameOptions);
320
+ }
321
+ await restoreControlArtifacts(root, journal.controlArtifacts ?? [], renameOptions);
322
+ if (await pathExists(stagePath)) await removeWithRetry(stagePath, renameOptions);
323
+ await removeWithRetry(transactionPath, renameOptions);
324
+ await removeWithRetry(journalPath, renameOptions);
325
+ await cleanupEmptyTransactionsDirectory(path.dirname(transactionPath));
326
+ }
327
+
328
+ async function verifyStagedArtifacts(stagePath, artifacts, cachePrefix) {
329
+ for (const [relativePath, expected] of artifacts) {
330
+ const normalized = projectRelativePath(relativePath, `Generated artifact ${relativePath}`);
331
+ if (!normalized.startsWith(cachePrefix)) throw new Error(`Generated artifact ${normalized} escapes the staged cache.`);
332
+ const stageRelative = normalized.slice(cachePrefix.length);
333
+ const actual = await readFile(path.join(stagePath, ...stageRelative.split("/")), "utf8");
334
+ if (actual !== expected) throw new Error(`Staged artifact ${relativePath} does not match generated bytes.`);
335
+ }
336
+ await verifyCommittedCache(stagePath, cachePrefix.slice(0, -1));
337
+ }
338
+
339
+ async function verifyCommittedCache(cachePath, cacheDirectory) {
340
+ const manifest = JSON.parse(await readFile(path.join(cachePath, "manifest.json"), "utf8"));
341
+ for (const [relativePath, expectedHash] of Object.entries(manifest.files ?? {}).sort(([left], [right]) => compareText(left, right))) {
342
+ const normalized = projectRelativePath(relativePath, `Manifest path ${relativePath}`);
343
+ const prefix = `${toPosix(cacheDirectory).replace(/\/+$/u, "")}/`;
344
+ if (!normalized.startsWith(prefix)) throw new Error(`Manifest path ${relativePath} is outside ${cacheDirectory}.`);
345
+ const cacheRelative = normalized.slice(prefix.length);
346
+ const content = await readFile(path.join(cachePath, ...cacheRelative.split("/")), "utf8");
347
+ if (sha256(content) !== expectedHash) throw new Error(`Generated artifact ${relativePath} fails manifest verification.`);
348
+ }
349
+ const index = JSON.parse(await readFile(path.join(cachePath, "index.json"), "utf8"));
350
+ if (index?.schemaVersion !== 1 || !Array.isArray(index.cards)) {
351
+ throw new Error("Generated index.json is not a compatible schemaVersion 1 index.");
352
+ }
353
+ }
354
+
355
+ function validateJournal(root, journal, expectedCacheDirectory) {
356
+ if (!journal || journal.schemaVersion !== TRANSACTION_SCHEMA_VERSION) {
357
+ throw new Error("Unsupported or malformed LLMNav generation transaction journal.");
358
+ }
359
+ for (const key of ["cacheDirectory", "transactionDirectory", "stageDirectory", "backupDirectory"]) {
360
+ if (typeof journal[key] !== "string" || !journal[key] || path.isAbsolute(journal[key])) {
361
+ throw new Error(`Generation transaction journal has invalid ${key}.`);
362
+ }
363
+ const resolved = path.resolve(root, journal[key]);
364
+ const relative = path.relative(path.resolve(root), resolved);
365
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
366
+ throw new Error(`Generation transaction journal ${key} escapes the repository root.`);
367
+ }
368
+ }
369
+ if (!expectedCacheDirectory) {
370
+ throw new Error("Generation transaction recovery requires the configured cache directory.");
371
+ }
372
+ if (toPosix(journal.cacheDirectory) !== toPosix(expectedCacheDirectory)) {
373
+ throw new Error("Generation transaction cache directory does not match project configuration.");
374
+ }
375
+ const transactionDirectory = toPosix(journal.transactionDirectory);
376
+ if (!/^\.llmnav\/\.transactions\/[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(transactionDirectory)) {
377
+ throw new Error("Generation transaction directory must be one direct child of .llmnav/.transactions.");
378
+ }
379
+ if (toPosix(journal.stageDirectory) !== `${transactionDirectory}/stage`) {
380
+ throw new Error("Generation transaction stage directory does not belong to its transaction.");
381
+ }
382
+ if (toPosix(journal.backupDirectory) !== `${transactionDirectory}/backup`) {
383
+ throw new Error("Generation transaction backup directory does not belong to its transaction.");
384
+ }
385
+ if (typeof journal.hadExistingCache !== "boolean") {
386
+ throw new Error("Generation transaction journal has invalid hadExistingCache.");
387
+ }
388
+ if (journal.ownerId !== undefined && (typeof journal.ownerId !== "string" || !journal.ownerId)) {
389
+ throw new Error("Generation transaction journal has invalid ownerId.");
390
+ }
391
+ validateControlArtifacts(journal, transactionDirectory);
392
+ if (!JOURNAL_PHASES.has(journal.phase)) {
393
+ throw new Error("Generation transaction journal has invalid phase.");
394
+ }
395
+ }
396
+
397
+ async function stageControlArtifacts(root, transactionPath, artifacts = new Map()) {
398
+ const records = [];
399
+ for (const [relativePath, content] of [...artifacts.entries()].sort(([left], [right]) => compareText(left, right))) {
400
+ const normalized = projectRelativePath(relativePath, `Control artifact ${relativePath}`);
401
+ if (!CONTROL_ARTIFACT_PATHS.has(normalized)) {
402
+ throw new Error(`Control artifact ${normalized} is not transaction-managed.`);
403
+ }
404
+ const name = path.posix.basename(normalized);
405
+ const stagePath = path.join(transactionPath, "control-stage", name);
406
+ const backupPath = path.join(transactionPath, "control-backup", name);
407
+ await mkdir(path.dirname(stagePath), { recursive: true });
408
+ await writeFile(stagePath, content, "utf8");
409
+ records.push({
410
+ path: normalized,
411
+ stagePath: relativePosix(root, stagePath),
412
+ backupPath: relativePosix(root, backupPath),
413
+ hadExisting: await pathExists(path.join(root, normalized)),
414
+ hash: sha256(content),
415
+ });
416
+ }
417
+ return records;
418
+ }
419
+
420
+ async function moveControlArtifactsToBackup(root, records, renameOptions) {
421
+ for (const record of records) {
422
+ if (!record.hadExisting) continue;
423
+ const backupPath = path.join(root, record.backupPath);
424
+ await mkdir(path.dirname(backupPath), { recursive: true });
425
+ await renameWithRetry(path.join(root, record.path), backupPath, renameOptions);
426
+ }
427
+ }
428
+
429
+ async function installControlArtifacts(root, records, renameOptions) {
430
+ for (const record of records) {
431
+ await renameWithRetry(path.join(root, record.stagePath), path.join(root, record.path), renameOptions);
432
+ }
433
+ }
434
+
435
+ async function verifyControlArtifacts(root, records) {
436
+ for (const record of records) {
437
+ const content = await readFile(path.join(root, record.path), "utf8");
438
+ if (sha256(content) !== record.hash) throw new Error(`Control artifact ${record.path} fails transaction verification.`);
439
+ }
440
+ }
441
+
442
+ async function restoreControlArtifacts(root, records, renameOptions) {
443
+ for (const record of records) {
444
+ const targetPath = path.join(root, record.path);
445
+ const backupPath = path.join(root, record.backupPath);
446
+ if (await pathExists(backupPath)) {
447
+ if (await pathExists(targetPath)) await removeWithRetry(targetPath, renameOptions);
448
+ await renameWithRetry(backupPath, targetPath, renameOptions);
449
+ } else if (!record.hadExisting && await pathExists(targetPath)) {
450
+ await removeWithRetry(targetPath, renameOptions);
451
+ }
452
+ }
453
+ }
454
+
455
+ async function finalizeCommittedControlArtifacts(root, records, renameOptions) {
456
+ for (const record of records) {
457
+ const targetPath = path.join(root, record.path);
458
+ const stagePath = path.join(root, record.stagePath);
459
+ const backupPath = path.join(root, record.backupPath);
460
+ if (!await pathExists(targetPath) && await pathExists(stagePath)) {
461
+ await renameWithRetry(stagePath, targetPath, renameOptions);
462
+ } else if (!await pathExists(targetPath) && await pathExists(backupPath)) {
463
+ await renameWithRetry(backupPath, targetPath, renameOptions);
464
+ }
465
+ }
466
+ await verifyControlArtifacts(root, records);
467
+ }
468
+
469
+ function validateControlArtifacts(journal, transactionDirectory) {
470
+ if (journal.controlArtifacts === undefined) return;
471
+ if (!Array.isArray(journal.controlArtifacts)) {
472
+ throw new Error("Generation transaction journal has invalid controlArtifacts.");
473
+ }
474
+ const seen = new Set();
475
+ for (const record of journal.controlArtifacts) {
476
+ if (!record || typeof record !== "object" || !CONTROL_ARTIFACT_PATHS.has(toPosix(record.path))) {
477
+ throw new Error("Generation transaction journal has an invalid control artifact path.");
478
+ }
479
+ if (seen.has(record.path)) throw new Error("Generation transaction journal repeats a control artifact path.");
480
+ seen.add(record.path);
481
+ const name = path.posix.basename(toPosix(record.path));
482
+ if (toPosix(record.stagePath) !== `${transactionDirectory}/control-stage/${name}` ||
483
+ toPosix(record.backupPath) !== `${transactionDirectory}/control-backup/${name}`) {
484
+ throw new Error("Generation transaction control artifact does not belong to its transaction.");
485
+ }
486
+ if (typeof record.hadExisting !== "boolean" || !/^[a-f0-9]{64}$/u.test(record.hash)) {
487
+ throw new Error("Generation transaction journal has invalid control artifact metadata.");
488
+ }
489
+ }
490
+ }
491
+
492
+ async function removeOwnedLock(lockPath, ownerId) {
493
+ const quarantinePath = `${lockPath}.release-${ownerId}-${randomBytes(4).toString("hex")}`;
494
+ try {
495
+ await rename(lockPath, quarantinePath);
496
+ } catch (error) {
497
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
498
+ throw error;
499
+ }
500
+ const current = await readJsonSafe(quarantinePath, null);
501
+ if (current?.ownerId === ownerId) {
502
+ await rm(quarantinePath, { force: true });
503
+ return true;
504
+ }
505
+ try {
506
+ await rename(quarantinePath, lockPath);
507
+ } catch (error) {
508
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
509
+ await rm(quarantinePath, { force: true });
510
+ }
511
+ return false;
512
+ }
513
+
514
+ function isProcessAlive(pid) {
515
+ try {
516
+ process.kill(pid, 0);
517
+ return true;
518
+ } catch (error) {
519
+ return Boolean(error && typeof error === "object" && error.code === "EPERM");
520
+ }
521
+ }
522
+
523
+ async function invokeFailpoint(name, options) {
524
+ if (typeof options.onPhase === "function") await options.onPhase(name);
525
+ const failpoint = options.failpoint ?? process.env.LLMNAV_TEST_FAILPOINT;
526
+ if (failpoint === `throw:${name}`) throw new Error(`Injected generation failure at ${name}.`);
527
+ if (failpoint === `abort:${name}`) process.exit(TRANSACTION_ABORT_EXIT_CODE);
528
+ }
529
+
530
+ function isRetryableRenameError(error) {
531
+ return Boolean(error && typeof error === "object" && RETRYABLE_RENAME_CODES.has(error.code));
532
+ }
533
+
534
+ async function pathExists(candidate) {
535
+ try {
536
+ await lstat(candidate);
537
+ return true;
538
+ } catch (error) {
539
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
540
+ throw error;
541
+ }
542
+ }
543
+
544
+ async function cleanupEmptyTransactionsDirectory(directory) {
545
+ try {
546
+ const details = await stat(directory);
547
+ if (!details.isDirectory()) return;
548
+ const entries = await readdir(directory);
549
+ if (entries.length === 0) await rm(directory, { recursive: true, force: true });
550
+ } catch (error) {
551
+ if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error;
552
+ }
553
+ }
554
+
555
+ function createTransactionId() {
556
+ return `${process.pid}-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
557
+ }