@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.
- package/README.md +29 -13
- package/agents/contract-reviewer.md +2 -2
- package/agents/design-explorer.md +1 -1
- package/agents/diagnosis-worker.md +6 -3
- package/agents/implementation-worker.md +14 -9
- package/config/.env.example +5 -17
- package/config/routes.example.json +42 -0
- package/package.json +3 -3
- package/prompts/abel-design.md +150 -228
- package/prompts/abel-diagnose.md +37 -18
- package/prompts/abel-implement.md +111 -178
- package/prompts/abel-init.md +36 -33
- package/skills/abel-workflow/SKILL.md +156 -168
- package/src/apply-transaction.ts +1427 -0
- package/src/artifact-store.ts +204 -0
- package/src/child-session.ts +136 -17
- package/src/contracts.ts +105 -696
- package/src/control-contracts.ts +259 -0
- package/src/delivery-compiler.ts +1088 -0
- package/src/file-snapshot.ts +5 -0
- package/src/implement-graph.ts +11 -4
- package/src/index.ts +1465 -133
- package/src/isolation-backend.ts +269 -0
- package/src/packet-runtime.ts +533 -0
- package/src/parent-provider.ts +23 -4
- package/src/route-policy.ts +483 -0
- package/src/run-state.ts +393 -0
- package/src/run-store.ts +918 -0
- package/src/safe-path.ts +11 -0
- package/src/scoped-tools.ts +36 -0
- package/src/state-root.ts +215 -0
- package/src/subagent-activity.ts +526 -27
- package/src/submit-tool.ts +274 -0
- package/src/task-ledger.ts +1380 -0
- package/src/verification-capability.ts +529 -42
- package/src/worker-broker.ts +703 -0
- package/src/workflow-engine.ts +7391 -0
- package/src/workspace-store.ts +544 -0
- package/src/candidate-preflight.ts +0 -1618
- package/src/drain.ts +0 -20
- package/src/patch.ts +0 -505
- package/src/result-store.ts +0 -312
- package/src/runtime.ts +0 -2436
- package/src/scheduler.ts +0 -384
- package/src/subagent-endpoint.ts +0 -158
- 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
|
+
}
|