@abelxiaoxing/cadence 1.0.13 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +29 -13
  2. package/agents/contract-reviewer.md +2 -2
  3. package/agents/design-explorer.md +1 -1
  4. package/agents/diagnosis-worker.md +6 -3
  5. package/agents/implementation-worker.md +14 -9
  6. package/config/.env.example +5 -17
  7. package/config/routes.example.json +42 -0
  8. package/package.json +3 -3
  9. package/prompts/abel-design.md +150 -228
  10. package/prompts/abel-diagnose.md +37 -18
  11. package/prompts/abel-implement.md +111 -178
  12. package/prompts/abel-init.md +36 -33
  13. package/skills/abel-workflow/SKILL.md +156 -168
  14. package/src/apply-transaction.ts +1427 -0
  15. package/src/artifact-store.ts +204 -0
  16. package/src/child-session.ts +136 -17
  17. package/src/contracts.ts +105 -696
  18. package/src/control-contracts.ts +259 -0
  19. package/src/delivery-compiler.ts +1088 -0
  20. package/src/file-snapshot.ts +5 -0
  21. package/src/implement-graph.ts +11 -4
  22. package/src/index.ts +1465 -133
  23. package/src/isolation-backend.ts +269 -0
  24. package/src/packet-runtime.ts +533 -0
  25. package/src/parent-provider.ts +23 -4
  26. package/src/route-policy.ts +483 -0
  27. package/src/run-state.ts +393 -0
  28. package/src/run-store.ts +918 -0
  29. package/src/safe-path.ts +11 -0
  30. package/src/scoped-tools.ts +36 -0
  31. package/src/state-root.ts +215 -0
  32. package/src/subagent-activity.ts +526 -27
  33. package/src/submit-tool.ts +274 -0
  34. package/src/task-ledger.ts +1380 -0
  35. package/src/verification-capability.ts +529 -42
  36. package/src/worker-broker.ts +703 -0
  37. package/src/workflow-engine.ts +7391 -0
  38. package/src/workspace-store.ts +544 -0
  39. package/src/candidate-preflight.ts +0 -1618
  40. package/src/drain.ts +0 -20
  41. package/src/patch.ts +0 -505
  42. package/src/result-store.ts +0 -312
  43. package/src/runtime.ts +0 -2436
  44. package/src/scheduler.ts +0 -384
  45. package/src/subagent-endpoint.ts +0 -158
  46. package/src/worker.ts +0 -204
@@ -0,0 +1,1427 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ constants,
6
+ fsyncSync,
7
+ linkSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ openSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ renameSync,
15
+ rmdirSync,
16
+ rmSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import path from "node:path";
22
+ import { DatabaseSync } from "node:sqlite";
23
+
24
+ import type { ArtifactStore } from "./artifact-store.ts";
25
+ import { isValidRelativePath } from "./contracts.ts";
26
+ import { observeSafePath } from "./safe-path.ts";
27
+ import type {
28
+ WorkspaceEntry,
29
+ WorkspaceRevision,
30
+ WorkspaceStore,
31
+ } from "./workspace-store.ts";
32
+
33
+ const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
34
+ const SHA256 = /^[a-f0-9]{64}$/u;
35
+ const JOURNAL_VERSION = 2 as const;
36
+
37
+ type TransactionState =
38
+ | "prepared"
39
+ | "applying"
40
+ | "recovering"
41
+ | "paused"
42
+ | "discarded"
43
+ | "completed";
44
+ type PendingIntent = "cancel" | "discard";
45
+ type StepState = "pending" | "applying" | "applied" | "rolled-back";
46
+
47
+ interface TransactionStep {
48
+ path: string;
49
+ before: WorkspaceEntry;
50
+ after: WorkspaceEntry;
51
+ state: StepState;
52
+ }
53
+
54
+ interface TransactionEvent {
55
+ sequence: number;
56
+ kind: string;
57
+ path?: string;
58
+ }
59
+
60
+ interface TransactionJournal {
61
+ version: typeof JOURNAL_VERSION;
62
+ transactionId: string;
63
+ consumerRoot: string;
64
+ baselineRevisionId: string;
65
+ finalRevisionId: string;
66
+ verificationId: string;
67
+ boundPaths: string[];
68
+ state: TransactionState;
69
+ code?: string;
70
+ pendingIntent?: PendingIntent;
71
+ rollbackRetained: boolean;
72
+ externalPaths: string[];
73
+ retainedHashes: string[];
74
+ releasedHashes: string[];
75
+ createdDirectories: string[];
76
+ steps: TransactionStep[];
77
+ events: TransactionEvent[];
78
+ }
79
+
80
+ interface JournalRow {
81
+ journal_json: string;
82
+ journal_hash: string;
83
+ }
84
+
85
+ export interface CumulativeVerificationExecutionInput {
86
+ root: string;
87
+ revisionId: string;
88
+ verificationId: string;
89
+ signal?: AbortSignal;
90
+ }
91
+
92
+ export type CumulativeVerificationExecutionResult =
93
+ | {
94
+ ok: true;
95
+ exitCode: 0;
96
+ classification: string;
97
+ }
98
+ | {
99
+ ok: false;
100
+ kind:
101
+ | "artifact"
102
+ | "environment"
103
+ | "verification"
104
+ | "verification-adapter"
105
+ | "approval-boundary"
106
+ | "cancelled";
107
+ code: string;
108
+ };
109
+
110
+ export interface VerifyCumulativeRevisionInput {
111
+ workspaceStore: WorkspaceStore;
112
+ revisionId: string;
113
+ verificationId: string;
114
+ execute: (
115
+ input: CumulativeVerificationExecutionInput,
116
+ ) =>
117
+ | CumulativeVerificationExecutionResult
118
+ | Promise<CumulativeVerificationExecutionResult>;
119
+ signal?: AbortSignal;
120
+ }
121
+
122
+ interface CumulativeVerificationFact {
123
+ readonly revisionId: string;
124
+ readonly verificationId: string;
125
+ }
126
+
127
+ const verificationFacts = new WeakMap<
128
+ object,
129
+ CumulativeVerificationFact & { workspaceStore: WorkspaceStore }
130
+ >();
131
+
132
+ export type CumulativeVerificationResult =
133
+ | { ok: true; fact: object; revisionId: string; verificationId: string }
134
+ | {
135
+ ok: false;
136
+ kind:
137
+ | "artifact"
138
+ | "environment"
139
+ | "verification"
140
+ | "verification-adapter"
141
+ | "approval-boundary"
142
+ | "cancelled";
143
+ code: string;
144
+ verificationId: string;
145
+ };
146
+
147
+ export interface ApplyTransactionHooks {
148
+ beforeFileMutation?: (input: {
149
+ transactionId: string;
150
+ path: string;
151
+ index: number;
152
+ }) => void | Promise<void>;
153
+ afterFileMutationBeforeSave?: (input: {
154
+ transactionId: string;
155
+ path: string;
156
+ index: number;
157
+ }) => void | Promise<void>;
158
+ afterFileMutation?: (input: {
159
+ transactionId: string;
160
+ path: string;
161
+ index: number;
162
+ }) => void | Promise<void>;
163
+ postApply?: (input: {
164
+ transactionId: string;
165
+ root: string;
166
+ signal: AbortSignal;
167
+ }) =>
168
+ | { ok: true }
169
+ | { ok: false; code: string }
170
+ | Promise<{ ok: true } | { ok: false; code: string }>;
171
+ }
172
+
173
+ export interface ApplyTransactionOptions {
174
+ root: string;
175
+ artifacts: ArtifactStore;
176
+ workspaces: WorkspaceStore;
177
+ hooks?: ApplyTransactionHooks;
178
+ databasePath?: string;
179
+ }
180
+
181
+ export interface PrepareApplyInput {
182
+ transactionId: string;
183
+ consumerRoot: string;
184
+ baselineRevisionId: string;
185
+ finalRevisionId: string;
186
+ boundPaths: string[];
187
+ verificationFact: unknown;
188
+ }
189
+
190
+ export type ApplyTransactionOutcome =
191
+ | {
192
+ ok: true;
193
+ transactionId: string;
194
+ state: "prepared" | "completed";
195
+ replayed?: true;
196
+ }
197
+ | {
198
+ ok: false;
199
+ transactionId: string;
200
+ state: "paused" | "discarded";
201
+ code: string;
202
+ replayed?: true;
203
+ };
204
+
205
+ function hashBytes(bytes: Uint8Array): string {
206
+ return createHash("sha256").update(bytes).digest("hex");
207
+ }
208
+
209
+ function journalHash(journal: TransactionJournal): string {
210
+ return createHash("sha256")
211
+ .update("cadence-apply-transaction-v2\0")
212
+ .update(JSON.stringify(journal))
213
+ .digest("hex");
214
+ }
215
+
216
+ function requireIdentifier(value: string): void {
217
+ if (!IDENTIFIER.test(value)) throw new Error("transaction-id-invalid");
218
+ }
219
+
220
+ function requireRelative(relative: string): void {
221
+ if (!isValidRelativePath(relative) || relative === ".") {
222
+ throw new Error("transaction-path-invalid");
223
+ }
224
+ }
225
+
226
+ function ensurePrivateDirectory(directory: string): void {
227
+ if (!path.isAbsolute(directory)) throw new Error("transaction-root-invalid");
228
+ let cursor = path.resolve(directory);
229
+ const missing: string[] = [];
230
+ for (;;) {
231
+ const stat = lstatSync(cursor, { throwIfNoEntry: false });
232
+ if (stat) {
233
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
234
+ throw new Error("transaction-root-invalid");
235
+ }
236
+ break;
237
+ }
238
+ missing.push(cursor);
239
+ const parent = path.dirname(cursor);
240
+ if (parent === cursor) throw new Error("transaction-root-invalid");
241
+ cursor = parent;
242
+ }
243
+ for (const target of missing.reverse()) {
244
+ mkdirSync(target, { mode: 0o700 });
245
+ chmodSync(target, 0o700);
246
+ }
247
+ chmodSync(directory, 0o700);
248
+ }
249
+
250
+ function syncDirectory(directory: string): void {
251
+ const descriptor = openSync(directory, constants.O_RDONLY);
252
+ try {
253
+ fsyncSync(descriptor);
254
+ } finally {
255
+ closeSync(descriptor);
256
+ }
257
+ }
258
+
259
+ function entryEqual(left: WorkspaceEntry, right: WorkspaceEntry): boolean {
260
+ if (left.kind !== right.kind) return false;
261
+ return (
262
+ left.kind === "absent" ||
263
+ (right.kind === "file" &&
264
+ left.hash === right.hash &&
265
+ left.bytes === right.bytes &&
266
+ left.mode === right.mode)
267
+ );
268
+ }
269
+
270
+ function normalizedEntry(entry: WorkspaceEntry | undefined): WorkspaceEntry {
271
+ return entry ? structuredClone(entry) : { kind: "absent" };
272
+ }
273
+
274
+ function isContainedBy(parent: string, candidate: string): boolean {
275
+ const relative = path.relative(parent, candidate);
276
+ return (
277
+ relative === "" ||
278
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
279
+ );
280
+ }
281
+
282
+ function canonicalConsumerRoot(input: string): string {
283
+ const absolute = path.resolve(input);
284
+ const stat = lstatSync(absolute, { throwIfNoEntry: false });
285
+ if (!stat?.isDirectory() || stat.isSymbolicLink()) {
286
+ throw new Error("consumer-root-invalid");
287
+ }
288
+ return realpathSync.native(absolute);
289
+ }
290
+
291
+ function currentEntry(
292
+ root: string,
293
+ relative: string,
294
+ ): WorkspaceEntry | "unsafe" {
295
+ const observation = observeSafePath(root, relative);
296
+ if (observation.kind === "absent") return { kind: "absent" };
297
+ if (observation.kind !== "file") return "unsafe";
298
+ const absolute = path.join(root, ...relative.split("/"));
299
+ const bytes = readFileSync(absolute);
300
+ const stat = lstatSync(absolute);
301
+ return {
302
+ kind: "file",
303
+ hash: hashBytes(bytes),
304
+ bytes: bytes.byteLength,
305
+ mode: stat.mode & 0o777,
306
+ };
307
+ }
308
+
309
+ function mainMatches(
310
+ root: string,
311
+ relative: string,
312
+ expected: WorkspaceEntry,
313
+ ): boolean {
314
+ const current = currentEntry(root, relative);
315
+ return current !== "unsafe" && entryEqual(current, expected);
316
+ }
317
+
318
+ function fileEntryAt(target: string): WorkspaceEntry | "unsafe" | "absent" {
319
+ const stat = lstatSync(target, { throwIfNoEntry: false });
320
+ if (!stat) return "absent";
321
+ if (!stat.isFile() || stat.isSymbolicLink()) return "unsafe";
322
+ const bytes = readFileSync(target);
323
+ const confirmed = lstatSync(target, { throwIfNoEntry: false });
324
+ if (
325
+ !confirmed?.isFile() ||
326
+ confirmed.isSymbolicLink() ||
327
+ confirmed.dev !== stat.dev ||
328
+ confirmed.ino !== stat.ino
329
+ ) {
330
+ return "unsafe";
331
+ }
332
+ return {
333
+ kind: "file",
334
+ hash: hashBytes(bytes),
335
+ bytes: bytes.byteLength,
336
+ mode: confirmed.mode & 0o777,
337
+ };
338
+ }
339
+
340
+ function restoreClaim(claim: string, target: string): boolean {
341
+ try {
342
+ linkSync(claim, target);
343
+ } catch (error) {
344
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
345
+ throw error;
346
+ }
347
+ unlinkSync(claim);
348
+ syncDirectory(path.dirname(target));
349
+ return true;
350
+ }
351
+
352
+ async function raceWithAbort<T>(
353
+ execution: Promise<T>,
354
+ signal: AbortSignal,
355
+ ): Promise<T> {
356
+ let removeAbort = () => {};
357
+ const aborted = new Promise<never>((_resolve, reject) => {
358
+ const onAbort = () =>
359
+ reject(
360
+ signal.reason instanceof Error
361
+ ? signal.reason
362
+ : new Error("operation-cancelled"),
363
+ );
364
+ removeAbort = () => signal.removeEventListener("abort", onAbort);
365
+ if (signal.aborted) onAbort();
366
+ else signal.addEventListener("abort", onAbort, { once: true });
367
+ });
368
+ try {
369
+ return await Promise.race([execution, aborted]);
370
+ } finally {
371
+ removeAbort();
372
+ }
373
+ }
374
+
375
+ function safeTargetPath(root: string, relative: string): string {
376
+ requireRelative(relative);
377
+ const segments = relative.split("/");
378
+ let parent = root;
379
+ for (const segment of segments.slice(0, -1)) {
380
+ parent = path.join(parent, segment);
381
+ const stat = lstatSync(parent, { throwIfNoEntry: false });
382
+ if (!stat) break;
383
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
384
+ throw new Error("transaction-target-unsafe");
385
+ }
386
+ }
387
+ return path.join(root, ...segments);
388
+ }
389
+
390
+ function writeExclusiveFile(
391
+ target: string,
392
+ bytes: Uint8Array,
393
+ mode: number,
394
+ ): boolean {
395
+ const directory = path.dirname(target);
396
+ const temporary = path.join(
397
+ directory,
398
+ `.${path.basename(target)}.${process.pid}.${randomUUID()}.apply-tmp`,
399
+ );
400
+ const descriptor = openSync(
401
+ temporary,
402
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
403
+ 0o600,
404
+ );
405
+ try {
406
+ writeFileSync(descriptor, bytes);
407
+ fsyncSync(descriptor);
408
+ } finally {
409
+ closeSync(descriptor);
410
+ }
411
+ try {
412
+ chmodSync(temporary, mode);
413
+ try {
414
+ linkSync(temporary, target);
415
+ } catch (error) {
416
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
417
+ throw error;
418
+ }
419
+ unlinkSync(temporary);
420
+ syncDirectory(directory);
421
+ return true;
422
+ } catch (error) {
423
+ rmSync(temporary, { force: true });
424
+ throw error;
425
+ } finally {
426
+ rmSync(temporary, { force: true });
427
+ }
428
+ }
429
+
430
+ function event(
431
+ journal: TransactionJournal,
432
+ kind: string,
433
+ relative?: string,
434
+ ): void {
435
+ journal.events.push({
436
+ sequence: journal.events.length + 1,
437
+ kind,
438
+ ...(relative ? { path: relative } : {}),
439
+ });
440
+ }
441
+
442
+ function requireRevisionId(value: string): void {
443
+ if (!SHA256.test(value)) throw new Error("workspace-revision-id-invalid");
444
+ }
445
+
446
+ function validateJournal(value: unknown): TransactionJournal {
447
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
448
+ throw new Error("transaction-journal-invalid");
449
+ }
450
+ const journal = value as TransactionJournal;
451
+ if (
452
+ journal.version !== JOURNAL_VERSION ||
453
+ !IDENTIFIER.test(journal.transactionId) ||
454
+ !path.isAbsolute(journal.consumerRoot) ||
455
+ !SHA256.test(journal.baselineRevisionId) ||
456
+ !SHA256.test(journal.finalRevisionId) ||
457
+ !IDENTIFIER.test(journal.verificationId) ||
458
+ !Array.isArray(journal.boundPaths) ||
459
+ ![
460
+ "prepared",
461
+ "applying",
462
+ "recovering",
463
+ "paused",
464
+ "discarded",
465
+ "completed",
466
+ ].includes(journal.state) ||
467
+ typeof journal.rollbackRetained !== "boolean" ||
468
+ !Array.isArray(journal.externalPaths) ||
469
+ !Array.isArray(journal.retainedHashes) ||
470
+ !Array.isArray(journal.releasedHashes) ||
471
+ !Array.isArray(journal.createdDirectories) ||
472
+ !Array.isArray(journal.steps) ||
473
+ !Array.isArray(journal.events)
474
+ ) {
475
+ throw new Error("transaction-journal-invalid");
476
+ }
477
+ if (
478
+ new Set(journal.createdDirectories).size !==
479
+ journal.createdDirectories.length ||
480
+ journal.createdDirectories.some((relative) => {
481
+ try {
482
+ requireRelative(relative);
483
+ return false;
484
+ } catch {
485
+ return true;
486
+ }
487
+ })
488
+ ) {
489
+ throw new Error("transaction-journal-invalid");
490
+ }
491
+ if (
492
+ journal.boundPaths.length === 0 ||
493
+ new Set(journal.boundPaths).size !== journal.boundPaths.length ||
494
+ journal.boundPaths.some((relative) => {
495
+ try {
496
+ requireRelative(relative);
497
+ return false;
498
+ } catch {
499
+ return true;
500
+ }
501
+ })
502
+ ) {
503
+ throw new Error("transaction-journal-invalid");
504
+ }
505
+ for (const step of journal.steps) {
506
+ requireRelative(step.path);
507
+ if (
508
+ !["pending", "applying", "applied", "rolled-back"].includes(step.state)
509
+ ) {
510
+ throw new Error("transaction-journal-invalid");
511
+ }
512
+ }
513
+ if (
514
+ journal.steps.length !== journal.boundPaths.length ||
515
+ journal.steps.some((step, index) => step.path !== journal.boundPaths[index])
516
+ ) {
517
+ throw new Error("transaction-journal-invalid");
518
+ }
519
+ return journal;
520
+ }
521
+
522
+ function isAncestor(
523
+ workspaces: WorkspaceStore,
524
+ ancestorId: string,
525
+ revision: WorkspaceRevision,
526
+ ): boolean {
527
+ let cursor: WorkspaceRevision | null = revision;
528
+ const visited = new Set<string>();
529
+ while (cursor) {
530
+ if (cursor.revisionId === ancestorId) return true;
531
+ if (visited.has(cursor.revisionId)) {
532
+ throw new Error("workspace-revision-cycle");
533
+ }
534
+ visited.add(cursor.revisionId);
535
+ cursor = cursor.parentRevisionId
536
+ ? workspaces.getRevision(cursor.parentRevisionId)
537
+ : null;
538
+ }
539
+ return false;
540
+ }
541
+
542
+ /** Materialize and execute verification without exposing a caller-forgeable fact. */
543
+ export async function verifyCumulativeRevision(
544
+ input: VerifyCumulativeRevisionInput,
545
+ ): Promise<CumulativeVerificationResult> {
546
+ requireRevisionId(input.revisionId);
547
+ requireIdentifier(input.verificationId);
548
+ const revision = input.workspaceStore.getRevision(input.revisionId);
549
+ const temporary = mkdtempSync(
550
+ path.join(tmpdir(), "cadence-cumulative-verification-"),
551
+ );
552
+ chmodSync(temporary, 0o700);
553
+ try {
554
+ input.workspaceStore.materialize(revision.revisionId, temporary);
555
+ if (input.signal?.aborted) {
556
+ return {
557
+ ok: false,
558
+ kind: "cancelled",
559
+ code: "cancelled",
560
+ verificationId: input.verificationId,
561
+ };
562
+ }
563
+ const result = await input.execute({
564
+ root: temporary,
565
+ revisionId: revision.revisionId,
566
+ verificationId: input.verificationId,
567
+ signal: input.signal,
568
+ });
569
+ if (!result.ok) {
570
+ return {
571
+ ok: false,
572
+ kind: result.kind,
573
+ code: result.code,
574
+ verificationId: input.verificationId,
575
+ };
576
+ }
577
+ if (result.exitCode !== 0 || result.classification.length === 0) {
578
+ throw new Error("verification-outcome-invalid");
579
+ }
580
+ const fact = Object.freeze({
581
+ revisionId: revision.revisionId,
582
+ verificationId: input.verificationId,
583
+ });
584
+ verificationFacts.set(fact, {
585
+ ...fact,
586
+ workspaceStore: input.workspaceStore,
587
+ });
588
+ return {
589
+ ok: true,
590
+ fact,
591
+ revisionId: revision.revisionId,
592
+ verificationId: input.verificationId,
593
+ };
594
+ } finally {
595
+ rmSync(temporary, { recursive: true, force: true });
596
+ }
597
+ }
598
+
599
+ /** Journaled, restart-safe final delivery from one immutable revision. */
600
+ export class ApplyTransaction {
601
+ readonly root: string;
602
+ readonly #artifacts: ArtifactStore;
603
+ readonly #workspaces: WorkspaceStore;
604
+ readonly #hooks: ApplyTransactionHooks;
605
+ readonly #database: DatabaseSync;
606
+ readonly #active = new Map<
607
+ string,
608
+ {
609
+ controller: AbortController;
610
+ promise: Promise<ApplyTransactionOutcome>;
611
+ }
612
+ >();
613
+ #closed = false;
614
+
615
+ constructor(options: ApplyTransactionOptions) {
616
+ if (!options || typeof options !== "object") {
617
+ throw new Error("transaction-options-invalid");
618
+ }
619
+ this.root = path.resolve(options.root);
620
+ ensurePrivateDirectory(this.root);
621
+ this.#artifacts = options.artifacts;
622
+ this.#workspaces = options.workspaces;
623
+ this.#hooks = options.hooks ?? {};
624
+ const databasePath = path.resolve(
625
+ options.databasePath ?? path.join(this.root, "apply.sqlite3"),
626
+ );
627
+ if (!isContainedBy(this.root, databasePath)) {
628
+ throw new Error("transaction-database-path-invalid");
629
+ }
630
+ const existing = lstatSync(databasePath, { throwIfNoEntry: false });
631
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) {
632
+ throw new Error("transaction-database-path-invalid");
633
+ }
634
+ this.#database = new DatabaseSync(databasePath);
635
+ this.#database.exec(
636
+ "PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;",
637
+ );
638
+ this.#database.exec(`
639
+ CREATE TABLE IF NOT EXISTS apply_transaction_journal (
640
+ transaction_id TEXT PRIMARY KEY,
641
+ journal_json TEXT NOT NULL,
642
+ journal_hash TEXT NOT NULL
643
+ ) STRICT;
644
+ `);
645
+ chmodSync(databasePath, 0o600);
646
+ }
647
+
648
+ #save(journal: TransactionJournal): void {
649
+ if (this.#closed) throw new Error("transaction-store-closed");
650
+ const json = JSON.stringify(journal);
651
+ const digest = journalHash(journal);
652
+ this.#database
653
+ .prepare(
654
+ `INSERT INTO apply_transaction_journal (
655
+ transaction_id, journal_json, journal_hash
656
+ ) VALUES (?, ?, ?)
657
+ ON CONFLICT(transaction_id) DO UPDATE SET
658
+ journal_json = excluded.journal_json,
659
+ journal_hash = excluded.journal_hash`,
660
+ )
661
+ .run(journal.transactionId, json, digest);
662
+ }
663
+
664
+ #load(transactionId: string): TransactionJournal {
665
+ if (this.#closed) throw new Error("transaction-store-closed");
666
+ requireIdentifier(transactionId);
667
+ const row = this.#database
668
+ .prepare(
669
+ `SELECT journal_json, journal_hash
670
+ FROM apply_transaction_journal WHERE transaction_id = ?`,
671
+ )
672
+ .get(transactionId) as JournalRow | undefined;
673
+ if (!row) throw new Error("transaction-unavailable");
674
+ let parsed: unknown;
675
+ try {
676
+ parsed = JSON.parse(row.journal_json);
677
+ } catch {
678
+ throw new Error("transaction-journal-invalid");
679
+ }
680
+ const journal = validateJournal(parsed);
681
+ if (journalHash(journal) !== row.journal_hash) {
682
+ throw new Error("transaction-journal-integrity-invalid");
683
+ }
684
+ return structuredClone(journal);
685
+ }
686
+
687
+ #project(journal: TransactionJournal, replayed = false) {
688
+ return {
689
+ transactionId: journal.transactionId,
690
+ state: journal.state,
691
+ ...(journal.code ? { code: journal.code } : {}),
692
+ ...(journal.pendingIntent
693
+ ? { pendingIntent: journal.pendingIntent }
694
+ : {}),
695
+ rollbackRetained: journal.rollbackRetained,
696
+ externalPaths: [...journal.externalPaths],
697
+ steps: journal.steps.map((step) => ({
698
+ path: step.path,
699
+ state: step.state,
700
+ })),
701
+ events: journal.events
702
+ .filter((entry) => entry.kind === "intent-prepared")
703
+ .map((entry) => ({ ...entry })),
704
+ ...(replayed ? { replayed: true as const } : {}),
705
+ };
706
+ }
707
+
708
+ #outcome(
709
+ journal: TransactionJournal,
710
+ replayed = false,
711
+ ): ApplyTransactionOutcome {
712
+ if (journal.state === "completed" || journal.state === "prepared") {
713
+ return {
714
+ ok: true,
715
+ transactionId: journal.transactionId,
716
+ state: journal.state,
717
+ ...(replayed ? { replayed: true as const } : {}),
718
+ };
719
+ }
720
+ if (journal.state !== "paused" && journal.state !== "discarded") {
721
+ throw new Error("transaction-outcome-nonterminal");
722
+ }
723
+ return {
724
+ ok: false,
725
+ transactionId: journal.transactionId,
726
+ state: journal.state,
727
+ code: journal.code ?? "recovery-required",
728
+ ...(replayed ? { replayed: true as const } : {}),
729
+ };
730
+ }
731
+
732
+ #settledOutcome(
733
+ journal: TransactionJournal,
734
+ ): ApplyTransactionOutcome | undefined {
735
+ return journal.state === "completed" ||
736
+ journal.state === "paused" ||
737
+ journal.state === "discarded"
738
+ ? this.#outcome(journal)
739
+ : undefined;
740
+ }
741
+
742
+ async prepare(input: PrepareApplyInput): Promise<ApplyTransactionOutcome> {
743
+ requireIdentifier(input.transactionId);
744
+ requireRevisionId(input.baselineRevisionId);
745
+ requireRevisionId(input.finalRevisionId);
746
+ if (!Array.isArray(input.boundPaths) || input.boundPaths.length === 0) {
747
+ throw new Error("transaction-bound-paths-invalid");
748
+ }
749
+ const boundPaths = [...new Set(input.boundPaths)].sort();
750
+ if (boundPaths.length !== input.boundPaths.length) {
751
+ throw new Error("transaction-bound-paths-invalid");
752
+ }
753
+ for (const relative of boundPaths) requireRelative(relative);
754
+ const fact =
755
+ input.verificationFact && typeof input.verificationFact === "object"
756
+ ? verificationFacts.get(input.verificationFact)
757
+ : undefined;
758
+ if (
759
+ !fact ||
760
+ fact.workspaceStore !== this.#workspaces ||
761
+ fact.revisionId !== input.finalRevisionId
762
+ ) {
763
+ throw new Error("verification-fact-invalid");
764
+ }
765
+ const consumerRoot = canonicalConsumerRoot(input.consumerRoot);
766
+ if (isContainedBy(consumerRoot, this.root)) {
767
+ throw new Error("transaction-root-inside-consumer");
768
+ }
769
+ const existing = this.#database
770
+ .prepare(
771
+ "SELECT journal_json, journal_hash FROM apply_transaction_journal WHERE transaction_id = ?",
772
+ )
773
+ .get(input.transactionId) as JournalRow | undefined;
774
+ if (existing) {
775
+ const journal = this.#load(input.transactionId);
776
+ if (
777
+ journal.consumerRoot !== consumerRoot ||
778
+ journal.baselineRevisionId !== input.baselineRevisionId ||
779
+ journal.finalRevisionId !== input.finalRevisionId ||
780
+ journal.verificationId !== fact.verificationId ||
781
+ JSON.stringify(journal.boundPaths) !== JSON.stringify(boundPaths)
782
+ ) {
783
+ throw new Error("transaction-id-conflict");
784
+ }
785
+ return this.#outcome(journal, true);
786
+ }
787
+
788
+ const baseline = this.#workspaces.getRevision(input.baselineRevisionId);
789
+ const finalRevision = this.#workspaces.getRevision(input.finalRevisionId);
790
+ if (!isAncestor(this.#workspaces, baseline.revisionId, finalRevision)) {
791
+ throw new Error("workspace-revision-base-invalid");
792
+ }
793
+ for (const relative of boundPaths) {
794
+ const expected = normalizedEntry(baseline.entries[relative]);
795
+ if (!mainMatches(consumerRoot, relative, expected)) {
796
+ return {
797
+ ok: false,
798
+ transactionId: input.transactionId,
799
+ state: "paused",
800
+ code: "main-workspace-stale",
801
+ };
802
+ }
803
+ }
804
+ const steps = boundPaths.map((relative) => ({
805
+ path: relative,
806
+ before: normalizedEntry(baseline.entries[relative]),
807
+ after: normalizedEntry(finalRevision.entries[relative]),
808
+ state: "pending" as const,
809
+ }));
810
+ const retainedHashes = [
811
+ ...new Set(
812
+ steps.flatMap((step) =>
813
+ [step.before, step.after]
814
+ .filter(
815
+ (entry): entry is Extract<WorkspaceEntry, { kind: "file" }> =>
816
+ entry.kind === "file",
817
+ )
818
+ .map((entry) => entry.hash),
819
+ ),
820
+ ),
821
+ ].sort();
822
+ for (const hash of retainedHashes) this.#artifacts.retain(hash);
823
+ const journal: TransactionJournal = {
824
+ version: JOURNAL_VERSION,
825
+ transactionId: input.transactionId,
826
+ consumerRoot,
827
+ baselineRevisionId: baseline.revisionId,
828
+ finalRevisionId: finalRevision.revisionId,
829
+ verificationId: fact.verificationId,
830
+ boundPaths,
831
+ state: "prepared",
832
+ rollbackRetained: true,
833
+ externalPaths: [],
834
+ retainedHashes,
835
+ releasedHashes: [],
836
+ createdDirectories: [],
837
+ steps,
838
+ events: [],
839
+ };
840
+ event(journal, "intent-prepared");
841
+ this.#save(journal);
842
+ return this.#outcome(journal);
843
+ }
844
+
845
+ #ensureTargetParents(journal: TransactionJournal, relative: string): string {
846
+ const segments = relative.split("/");
847
+ let parent = journal.consumerRoot;
848
+ const relativeSegments: string[] = [];
849
+ for (const segment of segments.slice(0, -1)) {
850
+ parent = path.join(parent, segment);
851
+ relativeSegments.push(segment);
852
+ const directory = relativeSegments.join("/");
853
+ const stat = lstatSync(parent, { throwIfNoEntry: false });
854
+ if (stat) {
855
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
856
+ throw new Error("transaction-target-unsafe");
857
+ }
858
+ continue;
859
+ }
860
+ if (!journal.createdDirectories.includes(directory)) {
861
+ journal.createdDirectories.push(directory);
862
+ event(journal, "directory-create-intent", directory);
863
+ this.#save(journal);
864
+ }
865
+ try {
866
+ mkdirSync(parent, { mode: 0o755 });
867
+ } catch (error) {
868
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
869
+ journal.createdDirectories = journal.createdDirectories.filter(
870
+ (candidate) => candidate !== directory,
871
+ );
872
+ this.#save(journal);
873
+ }
874
+ const created = lstatSync(parent, { throwIfNoEntry: false });
875
+ if (!created?.isDirectory() || created.isSymbolicLink()) {
876
+ throw new Error("transaction-target-unsafe");
877
+ }
878
+ syncDirectory(path.dirname(parent));
879
+ if (journal.createdDirectories.includes(directory)) {
880
+ event(journal, "directory-created", directory);
881
+ this.#save(journal);
882
+ }
883
+ }
884
+ return safeTargetPath(journal.consumerRoot, relative);
885
+ }
886
+
887
+ #claimPath(journal: TransactionJournal, step: TransactionStep): string {
888
+ const target = safeTargetPath(journal.consumerRoot, step.path);
889
+ return path.join(
890
+ path.dirname(target),
891
+ `.${path.basename(target)}.${createHash("sha256")
892
+ .update(`${journal.transactionId}\0${step.path}`)
893
+ .digest("hex")
894
+ .slice(0, 24)}.apply-claim`,
895
+ );
896
+ }
897
+
898
+ #settleClaim(journal: TransactionJournal, step: TransactionStep): boolean {
899
+ const target = safeTargetPath(journal.consumerRoot, step.path);
900
+ const claim = this.#claimPath(journal, step);
901
+ const claimed = fileEntryAt(claim);
902
+ if (claimed === "absent") return true;
903
+ if (claimed === "unsafe") return false;
904
+ const current = currentEntry(journal.consumerRoot, step.path);
905
+ if (current !== "unsafe" && current.kind === "absent") {
906
+ return restoreClaim(claim, target);
907
+ }
908
+ if (
909
+ current !== "unsafe" &&
910
+ (entryEqual(claimed, step.before) || entryEqual(claimed, step.after))
911
+ ) {
912
+ unlinkSync(claim);
913
+ syncDirectory(path.dirname(target));
914
+ return true;
915
+ }
916
+ return false;
917
+ }
918
+
919
+ #applyEntry(
920
+ journal: TransactionJournal,
921
+ step: TransactionStep,
922
+ expected: WorkspaceEntry,
923
+ entry: WorkspaceEntry,
924
+ ): boolean {
925
+ const target = this.#ensureTargetParents(journal, step.path);
926
+ const directory = path.dirname(target);
927
+ const claim = this.#claimPath(journal, step);
928
+ if (lstatSync(claim, { throwIfNoEntry: false })) {
929
+ throw new Error("transaction-claim-unsettled");
930
+ }
931
+
932
+ if (expected.kind === "file") {
933
+ try {
934
+ renameSync(target, claim);
935
+ } catch (error) {
936
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
937
+ throw error;
938
+ }
939
+ syncDirectory(directory);
940
+ const claimed = fileEntryAt(claim);
941
+ if (
942
+ claimed === "unsafe" ||
943
+ claimed === "absent" ||
944
+ !entryEqual(claimed, expected)
945
+ ) {
946
+ if (!restoreClaim(claim, target)) {
947
+ throw new Error("transaction-claim-conflict");
948
+ }
949
+ return false;
950
+ }
951
+ } else if (lstatSync(target, { throwIfNoEntry: false })) {
952
+ return false;
953
+ }
954
+
955
+ if (entry.kind === "absent") {
956
+ if (expected.kind === "file") {
957
+ unlinkSync(claim);
958
+ syncDirectory(directory);
959
+ }
960
+ return true;
961
+ }
962
+ const bytes = this.#artifacts.read(entry.hash);
963
+ if (bytes.byteLength !== entry.bytes) {
964
+ throw new Error("transaction-artifact-integrity-invalid");
965
+ }
966
+ const installed = writeExclusiveFile(target, bytes, entry.mode);
967
+ if (expected.kind === "file") {
968
+ unlinkSync(claim);
969
+ syncDirectory(directory);
970
+ }
971
+ return installed;
972
+ }
973
+
974
+ async #release(journal: TransactionJournal): Promise<void> {
975
+ if (!journal.rollbackRetained) return;
976
+ for (const hash of journal.retainedHashes) {
977
+ if (journal.releasedHashes.includes(hash)) continue;
978
+ this.#artifacts.release(hash);
979
+ journal.releasedHashes.push(hash);
980
+ this.#save(journal);
981
+ }
982
+ journal.rollbackRetained = false;
983
+ this.#save(journal);
984
+ }
985
+
986
+ #rollbackCreatedDirectories(
987
+ journal: TransactionJournal,
988
+ external: Set<string>,
989
+ ): void {
990
+ const deepestFirst = [...journal.createdDirectories].sort(
991
+ (left, right) =>
992
+ right.split("/").length - left.split("/").length ||
993
+ right.localeCompare(left),
994
+ );
995
+ for (const relative of deepestFirst) {
996
+ const observation = observeSafePath(journal.consumerRoot, relative);
997
+ if (observation.kind === "absent") continue;
998
+ if (observation.kind !== "directory") {
999
+ external.add(relative);
1000
+ continue;
1001
+ }
1002
+ const target = path.join(journal.consumerRoot, ...relative.split("/"));
1003
+ try {
1004
+ rmdirSync(target);
1005
+ } catch (error) {
1006
+ if (
1007
+ ["ENOTEMPTY", "EEXIST"].includes(
1008
+ (error as NodeJS.ErrnoException).code ?? "",
1009
+ )
1010
+ ) {
1011
+ external.add(relative);
1012
+ continue;
1013
+ }
1014
+ throw error;
1015
+ }
1016
+ syncDirectory(path.dirname(target));
1017
+ event(journal, "directory-rolled-back", relative);
1018
+ this.#save(journal);
1019
+ }
1020
+ }
1021
+
1022
+ async #rollback(
1023
+ journal: TransactionJournal,
1024
+ terminal: {
1025
+ state: "paused" | "discarded";
1026
+ code: string;
1027
+ externalPaths?: string[];
1028
+ },
1029
+ ): Promise<ApplyTransactionOutcome> {
1030
+ journal.state = "recovering";
1031
+ event(journal, "rollback-started");
1032
+ this.#save(journal);
1033
+ const external = new Set(terminal.externalPaths ?? []);
1034
+ for (const step of [...journal.steps].reverse()) {
1035
+ if (!this.#settleClaim(journal, step)) {
1036
+ external.add(step.path);
1037
+ continue;
1038
+ }
1039
+ const current = currentEntry(journal.consumerRoot, step.path);
1040
+ if (step.state === "pending") {
1041
+ if (current !== "unsafe" && entryEqual(current, step.before)) {
1042
+ step.state = "rolled-back";
1043
+ this.#save(journal);
1044
+ } else {
1045
+ external.add(step.path);
1046
+ }
1047
+ continue;
1048
+ }
1049
+ if (current !== "unsafe" && entryEqual(current, step.after)) {
1050
+ if (!entryEqual(step.before, step.after)) {
1051
+ if (!this.#applyEntry(journal, step, step.after, step.before)) {
1052
+ external.add(step.path);
1053
+ continue;
1054
+ }
1055
+ }
1056
+ step.state = "rolled-back";
1057
+ event(journal, "file-rolled-back", step.path);
1058
+ this.#save(journal);
1059
+ } else if (current !== "unsafe" && entryEqual(current, step.before)) {
1060
+ step.state = "rolled-back";
1061
+ this.#save(journal);
1062
+ } else {
1063
+ external.add(step.path);
1064
+ }
1065
+ }
1066
+ this.#rollbackCreatedDirectories(journal, external);
1067
+ journal.externalPaths = [...external].sort();
1068
+ journal.state = terminal.state;
1069
+ journal.code = terminal.code;
1070
+ event(journal, "rollback-settled");
1071
+ this.#save(journal);
1072
+ await this.#release(journal);
1073
+ return this.#outcome(journal);
1074
+ }
1075
+
1076
+ async #driveForward(
1077
+ transactionId: string,
1078
+ signal?: AbortSignal,
1079
+ ): Promise<ApplyTransactionOutcome> {
1080
+ let journal = this.#load(transactionId);
1081
+ if (journal.pendingIntent) {
1082
+ return this.#rollback(journal, {
1083
+ state: journal.pendingIntent === "discard" ? "discarded" : "paused",
1084
+ code:
1085
+ journal.pendingIntent === "discard"
1086
+ ? "run-discarded"
1087
+ : "operation-cancelled",
1088
+ });
1089
+ }
1090
+ journal.state = journal.state === "recovering" ? "recovering" : "applying";
1091
+ event(journal, "apply-started");
1092
+ this.#save(journal);
1093
+
1094
+ for (const [index, originalStep] of journal.steps.entries()) {
1095
+ journal = this.#load(transactionId);
1096
+ let step = journal.steps[index] as TransactionStep;
1097
+ if (journal.pendingIntent || signal?.aborted) {
1098
+ journal.pendingIntent ??= "cancel";
1099
+ journal.state = "recovering";
1100
+ event(journal, "control-pending");
1101
+ this.#save(journal);
1102
+ return this.#rollback(journal, {
1103
+ state: journal.pendingIntent === "discard" ? "discarded" : "paused",
1104
+ code:
1105
+ journal.pendingIntent === "discard"
1106
+ ? "run-discarded"
1107
+ : "operation-cancelled",
1108
+ });
1109
+ }
1110
+ if (!this.#settleClaim(journal, step)) {
1111
+ return this.#rollback(journal, {
1112
+ state: "paused",
1113
+ code: "recovery-required",
1114
+ externalPaths: [step.path],
1115
+ });
1116
+ }
1117
+ const current = currentEntry(journal.consumerRoot, step.path);
1118
+ if (step.state === "applied") {
1119
+ if (current === "unsafe" || !entryEqual(current, step.after)) {
1120
+ return this.#rollback(journal, {
1121
+ state: "paused",
1122
+ code: "recovery-required",
1123
+ externalPaths: [step.path],
1124
+ });
1125
+ }
1126
+ continue;
1127
+ }
1128
+ if (
1129
+ step.state === "applying" &&
1130
+ current !== "unsafe" &&
1131
+ entryEqual(current, step.after)
1132
+ ) {
1133
+ step.state = "applied";
1134
+ event(journal, "file-apply-recovered", step.path);
1135
+ this.#save(journal);
1136
+ continue;
1137
+ }
1138
+ if (current === "unsafe" || !entryEqual(current, step.before)) {
1139
+ return this.#rollback(journal, {
1140
+ state: "paused",
1141
+ code: "recovery-required",
1142
+ externalPaths: [step.path],
1143
+ });
1144
+ }
1145
+
1146
+ if (entryEqual(step.before, step.after)) {
1147
+ step.state = "applied";
1148
+ event(journal, "bound-path-checked", step.path);
1149
+ this.#save(journal);
1150
+ continue;
1151
+ }
1152
+
1153
+ step.state = "applying";
1154
+ event(journal, "file-step-started", step.path);
1155
+ this.#save(journal);
1156
+ await this.#hooks.beforeFileMutation?.({
1157
+ transactionId,
1158
+ path: originalStep.path,
1159
+ index,
1160
+ });
1161
+ journal = this.#load(transactionId);
1162
+ step = journal.steps[index] as TransactionStep;
1163
+ if (journal.pendingIntent || signal?.aborted) {
1164
+ journal.pendingIntent ??= "cancel";
1165
+ this.#save(journal);
1166
+ return this.#rollback(journal, {
1167
+ state: journal.pendingIntent === "discard" ? "discarded" : "paused",
1168
+ code:
1169
+ journal.pendingIntent === "discard"
1170
+ ? "run-discarded"
1171
+ : "operation-cancelled",
1172
+ });
1173
+ }
1174
+ if (!this.#applyEntry(journal, step, step.before, step.after)) {
1175
+ return this.#rollback(journal, {
1176
+ state: "paused",
1177
+ code: "recovery-required",
1178
+ externalPaths: [step.path],
1179
+ });
1180
+ }
1181
+ await this.#hooks.afterFileMutationBeforeSave?.({
1182
+ transactionId,
1183
+ path: originalStep.path,
1184
+ index,
1185
+ });
1186
+ journal = this.#load(transactionId);
1187
+ step = journal.steps[index] as TransactionStep;
1188
+ step.state = "applied";
1189
+ event(journal, "file-applied", step.path);
1190
+ this.#save(journal);
1191
+ try {
1192
+ await this.#hooks.afterFileMutation?.({
1193
+ transactionId,
1194
+ path: originalStep.path,
1195
+ index,
1196
+ });
1197
+ } catch (error) {
1198
+ journal = this.#load(transactionId);
1199
+ const settled = this.#settledOutcome(journal);
1200
+ if (settled) return settled;
1201
+ if (!journal.pendingIntent && !signal?.aborted) throw error;
1202
+ }
1203
+ journal = this.#load(transactionId);
1204
+ const settled = this.#settledOutcome(journal);
1205
+ if (settled) return settled;
1206
+ if (journal.pendingIntent || signal?.aborted) {
1207
+ journal.pendingIntent ??= "cancel";
1208
+ journal.state = "recovering";
1209
+ event(journal, "control-pending");
1210
+ this.#save(journal);
1211
+ return this.#rollback(journal, {
1212
+ state: journal.pendingIntent === "discard" ? "discarded" : "paused",
1213
+ code:
1214
+ journal.pendingIntent === "discard"
1215
+ ? "run-discarded"
1216
+ : "operation-cancelled",
1217
+ });
1218
+ }
1219
+ const appliedStep = journal.steps[index] as TransactionStep;
1220
+ if (
1221
+ !mainMatches(journal.consumerRoot, appliedStep.path, appliedStep.after)
1222
+ ) {
1223
+ return this.#rollback(journal, {
1224
+ state: "paused",
1225
+ code: "recovery-required",
1226
+ externalPaths: [appliedStep.path],
1227
+ });
1228
+ }
1229
+ }
1230
+
1231
+ journal = this.#load(transactionId);
1232
+ const external = journal.steps
1233
+ .filter(
1234
+ (step) => !mainMatches(journal.consumerRoot, step.path, step.after),
1235
+ )
1236
+ .map((step) => step.path);
1237
+ if (external.length > 0) {
1238
+ return this.#rollback(journal, {
1239
+ state: "paused",
1240
+ code: "recovery-required",
1241
+ externalPaths: external,
1242
+ });
1243
+ }
1244
+ let postApply: { ok: true } | { ok: false; code: string } | undefined;
1245
+ try {
1246
+ if (this.#hooks.postApply) {
1247
+ const isolatedRoot = mkdtempSync(
1248
+ path.join(this.root, "post-apply-verification-"),
1249
+ );
1250
+ try {
1251
+ this.#workspaces.materialize(journal.finalRevisionId, isolatedRoot);
1252
+ const execution = Promise.resolve(
1253
+ this.#hooks.postApply({
1254
+ transactionId,
1255
+ root: isolatedRoot,
1256
+ signal: signal ?? new AbortController().signal,
1257
+ }),
1258
+ );
1259
+ void execution.catch(() => undefined);
1260
+ postApply = signal
1261
+ ? await raceWithAbort(execution, signal)
1262
+ : await execution;
1263
+ } finally {
1264
+ rmSync(isolatedRoot, { recursive: true, force: true });
1265
+ }
1266
+ }
1267
+ } catch (error) {
1268
+ journal = this.#load(transactionId);
1269
+ const settled = this.#settledOutcome(journal);
1270
+ if (settled) return settled;
1271
+ if (!journal.pendingIntent && !signal?.aborted) throw error;
1272
+ }
1273
+ journal = this.#load(transactionId);
1274
+ const settled = this.#settledOutcome(journal);
1275
+ if (settled) return settled;
1276
+ if (journal.pendingIntent || signal?.aborted) {
1277
+ journal.pendingIntent ??= "cancel";
1278
+ journal.state = "recovering";
1279
+ event(journal, "control-pending");
1280
+ this.#save(journal);
1281
+ return this.#rollback(journal, {
1282
+ state: journal.pendingIntent === "discard" ? "discarded" : "paused",
1283
+ code:
1284
+ journal.pendingIntent === "discard"
1285
+ ? "run-discarded"
1286
+ : "operation-cancelled",
1287
+ });
1288
+ }
1289
+ const postApplyExternal = journal.steps
1290
+ .filter(
1291
+ (step) => !mainMatches(journal.consumerRoot, step.path, step.after),
1292
+ )
1293
+ .map((step) => step.path);
1294
+ if (postApplyExternal.length > 0) {
1295
+ return this.#rollback(journal, {
1296
+ state: "paused",
1297
+ code: "recovery-required",
1298
+ externalPaths: postApplyExternal,
1299
+ });
1300
+ }
1301
+ if (postApply && !postApply.ok) {
1302
+ return this.#rollback(journal, {
1303
+ state: "paused",
1304
+ code: postApply.code,
1305
+ });
1306
+ }
1307
+ journal.state = "completed";
1308
+ delete journal.code;
1309
+ event(journal, "transaction-completed");
1310
+ this.#save(journal);
1311
+ await this.#release(journal);
1312
+ return this.#outcome(journal);
1313
+ }
1314
+
1315
+ async apply(
1316
+ transactionId: string,
1317
+ signal?: AbortSignal,
1318
+ ): Promise<ApplyTransactionOutcome> {
1319
+ const journal = this.#load(transactionId);
1320
+ if (journal.state === "completed") {
1321
+ if (journal.rollbackRetained) await this.#release(journal);
1322
+ return this.#outcome(journal, true);
1323
+ }
1324
+ if (journal.state === "paused" || journal.state === "discarded") {
1325
+ return this.#outcome(journal, true);
1326
+ }
1327
+ try {
1328
+ return await this.#drive(transactionId, signal);
1329
+ } catch (error) {
1330
+ const interrupted = this.#load(transactionId);
1331
+ if (
1332
+ interrupted.state !== "completed" &&
1333
+ interrupted.state !== "paused" &&
1334
+ interrupted.state !== "discarded"
1335
+ ) {
1336
+ interrupted.state = "recovering";
1337
+ event(interrupted, "apply-interrupted");
1338
+ this.#save(interrupted);
1339
+ }
1340
+ throw error;
1341
+ }
1342
+ }
1343
+
1344
+ async recover(transactionId: string): Promise<ApplyTransactionOutcome> {
1345
+ const journal = this.#load(transactionId);
1346
+ if (
1347
+ journal.state === "completed" ||
1348
+ journal.state === "paused" ||
1349
+ journal.state === "discarded"
1350
+ ) {
1351
+ if (journal.rollbackRetained) await this.#release(journal);
1352
+ return this.#outcome(journal, true);
1353
+ }
1354
+ return this.#drive(transactionId);
1355
+ }
1356
+
1357
+ #drive(
1358
+ transactionId: string,
1359
+ parentSignal?: AbortSignal,
1360
+ ): Promise<ApplyTransactionOutcome> {
1361
+ const existing = this.#active.get(transactionId);
1362
+ if (existing) return existing.promise;
1363
+ const controller = new AbortController();
1364
+ const forwardAbort = () => {
1365
+ if (!controller.signal.aborted) {
1366
+ controller.abort(
1367
+ parentSignal?.reason ?? new Error("operation-cancelled"),
1368
+ );
1369
+ }
1370
+ };
1371
+ if (parentSignal?.aborted) forwardAbort();
1372
+ else parentSignal?.addEventListener("abort", forwardAbort, { once: true });
1373
+ let active!: {
1374
+ controller: AbortController;
1375
+ promise: Promise<ApplyTransactionOutcome>;
1376
+ };
1377
+ const promise = this.#driveForward(
1378
+ transactionId,
1379
+ controller.signal,
1380
+ ).finally(() => {
1381
+ parentSignal?.removeEventListener("abort", forwardAbort);
1382
+ if (this.#active.get(transactionId) === active) {
1383
+ this.#active.delete(transactionId);
1384
+ }
1385
+ });
1386
+ active = { controller, promise };
1387
+ this.#active.set(transactionId, active);
1388
+ return active.promise;
1389
+ }
1390
+
1391
+ requestControl(transactionId: string, intent: PendingIntent) {
1392
+ if (intent !== "cancel" && intent !== "discard") {
1393
+ throw new Error("transaction-control-invalid");
1394
+ }
1395
+ const journal = this.#load(transactionId);
1396
+ if (journal.state === "completed" || journal.state === "discarded") {
1397
+ return this.#project(journal, true);
1398
+ }
1399
+ journal.pendingIntent = intent;
1400
+ journal.state = "recovering";
1401
+ event(journal, "control-pending");
1402
+ this.#save(journal);
1403
+ const active = this.#active.get(transactionId);
1404
+ if (active && !active.controller.signal.aborted) {
1405
+ active.controller.abort(
1406
+ new Error(
1407
+ intent === "discard" ? "run-discarded" : "operation-cancelled",
1408
+ ),
1409
+ );
1410
+ }
1411
+ return this.#project(journal);
1412
+ }
1413
+
1414
+ status(transactionId: string) {
1415
+ return this.#project(this.#load(transactionId));
1416
+ }
1417
+
1418
+ close(): void {
1419
+ if (this.#closed) return;
1420
+ this.#closed = true;
1421
+ this.#database.close();
1422
+ }
1423
+
1424
+ [Symbol.dispose](): void {
1425
+ this.close();
1426
+ }
1427
+ }