@evomap/evolver-core 2.0.0-beta.6 → 2.0.0-beta.8

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.
@@ -0,0 +1,1044 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { basename, dirname, join, resolve, sep } from 'node:path';
4
+ import { acquireLock, LockTimeoutError, releaseLock } from '../util/fileLock.js';
5
+ import { containsWorkflowSensitiveText } from './dsl.js';
6
+ export const WORKFLOW_RUN_SCHEMA_VERSION = 4;
7
+ export const WORKFLOW_DEFINITION_SCHEMA_VERSION = 1;
8
+ const WORKFLOW_CONTROL_SCHEMA_VERSION = 1;
9
+ const WORKFLOW_TRANSACTION_SCHEMA_VERSION = 1;
10
+ const MAX_OPERATOR_REASON_LENGTH = 500;
11
+ export const MAX_WORKFLOW_HISTORY_EVENTS = 4_096;
12
+ export const MAX_WORKFLOW_HISTORY_BYTES = 8 * 1024 * 1024;
13
+ const WORKFLOW_SAFE_ERROR_CLASSES = new Set([
14
+ 'transient', 'permanent', 'safety', 'unknown', 'sensitive_output', 'interrupted_non_idempotent',
15
+ 'invalid_control_flow', 'invalid_output', 'approval_rejected', 'resource_limit',
16
+ ]);
17
+ function isWorkflowSafeErrorClass(value) {
18
+ return typeof value === 'string' && WORKFLOW_SAFE_ERROR_CLASSES.has(value);
19
+ }
20
+ export class WorkflowStateError extends Error {
21
+ code;
22
+ constructor(message, code) {
23
+ super(message);
24
+ this.code = code;
25
+ this.name = 'WorkflowStateError';
26
+ }
27
+ }
28
+ function isStableIdValue(value) {
29
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
30
+ }
31
+ function isErrno(error, code) {
32
+ return typeof error === 'object' && error !== null && error.code === code;
33
+ }
34
+ export function assertStableId(value, label) {
35
+ if (!isStableIdValue(value)) {
36
+ throw new WorkflowStateError(`${label} is not a valid stable identifier`, 'INVALID_ID');
37
+ }
38
+ }
39
+ function assertNoSymlink(path) {
40
+ try {
41
+ if (lstatSync(path).isSymbolicLink())
42
+ throw new WorkflowStateError(`symlink is not allowed: ${path}`, 'UNSAFE_PATH');
43
+ }
44
+ catch (error) {
45
+ if (!isErrno(error, 'ENOENT'))
46
+ throw error;
47
+ }
48
+ }
49
+ function assertWithin(root, path) {
50
+ const resolvedRoot = resolve(root);
51
+ const resolvedPath = resolve(path);
52
+ if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) {
53
+ throw new WorkflowStateError('workflow state path escapes its root', 'UNSAFE_PATH');
54
+ }
55
+ }
56
+ function ensureOwnerOnlyDirectory(path) {
57
+ assertNoSymlink(path);
58
+ mkdirSync(path, { recursive: true, mode: 0o700 });
59
+ assertNoSymlink(path);
60
+ if (process.platform === 'win32')
61
+ return;
62
+ chmodSync(path, 0o700);
63
+ const stat = statSync(path);
64
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
65
+ throw new WorkflowStateError(`workflow state directory is not owned by the current user: ${path}`, 'INSECURE_PERMISSIONS');
66
+ }
67
+ if ((stat.mode & 0o077) !== 0) {
68
+ throw new WorkflowStateError(`workflow state directory is not owner-only: ${path}`, 'INSECURE_PERMISSIONS');
69
+ }
70
+ }
71
+ function jsonViolation(value, path, ancestors) {
72
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
73
+ return null;
74
+ if (typeof value === 'number')
75
+ return Number.isFinite(value) ? null : path;
76
+ if (typeof value !== 'object')
77
+ return path;
78
+ if (ancestors.has(value))
79
+ return path;
80
+ ancestors.add(value);
81
+ try {
82
+ if (Array.isArray(value)) {
83
+ if (Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length > 0)
84
+ return path;
85
+ const keys = Reflect.ownKeys(value).filter((key) => key !== 'length');
86
+ for (let index = 0; index < value.length; index += 1) {
87
+ if (!Object.prototype.hasOwnProperty.call(value, index))
88
+ return `${path}[${index}]`;
89
+ }
90
+ if (keys.some((key) => typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key)))
91
+ return path;
92
+ for (let index = 0; index < value.length; index += 1) {
93
+ const violation = jsonViolation(value[index], `${path}[${index}]`, ancestors);
94
+ if (violation)
95
+ return violation;
96
+ }
97
+ return null;
98
+ }
99
+ const prototype = Object.getPrototypeOf(value);
100
+ if (prototype !== Object.prototype && prototype !== null)
101
+ return path;
102
+ if (Object.prototype.hasOwnProperty.call(value, 'toJSON') || Object.getOwnPropertySymbols(value).length > 0)
103
+ return path;
104
+ for (const key of Object.getOwnPropertyNames(value)) {
105
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
106
+ if (!descriptor?.enumerable || descriptor.get || descriptor.set)
107
+ return `${path}.${key}`;
108
+ const violation = jsonViolation(value[key], `${path}.${key}`, ancestors);
109
+ if (violation)
110
+ return violation;
111
+ }
112
+ return null;
113
+ }
114
+ finally {
115
+ ancestors.delete(value);
116
+ }
117
+ }
118
+ export function findWorkflowJsonViolation(value) {
119
+ return jsonViolation(value, '$', new Set());
120
+ }
121
+ function stableJson(value) {
122
+ if (Array.isArray(value))
123
+ return `[${value.map(stableJson).join(',')}]`;
124
+ if (typeof value === 'object' && value !== null) {
125
+ return `{${Object.entries(value)
126
+ .sort(([a], [b]) => a.localeCompare(b))
127
+ .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(',')}}`;
128
+ }
129
+ return JSON.stringify(value) ?? 'null';
130
+ }
131
+ export function deriveWorkflowDefinitionDigest(spec) {
132
+ const { workflowId: _workflowId, input: _input, ...definition } = spec;
133
+ return createHash('sha256').update(stableJson(definition)).digest('hex');
134
+ }
135
+ function assertJsonDomain(value, label) {
136
+ const violation = findWorkflowJsonViolation(value);
137
+ if (violation)
138
+ throw new WorkflowStateError(`${label} contains a non-JSON value at ${violation}`, 'CORRUPT_STATE');
139
+ }
140
+ function sameFileSnapshot(left, right) {
141
+ return left.dev === right.dev
142
+ && left.ino === right.ino
143
+ && left.mode === right.mode
144
+ && left.size === right.size
145
+ && left.mtimeNs === right.mtimeNs
146
+ && left.ctimeNs === right.ctimeNs;
147
+ }
148
+ function assertOwnerOnlyRegularFile(stat, path) {
149
+ if (stat.isSymbolicLink() || !stat.isFile()) {
150
+ throw new WorkflowStateError(`workflow state is not a regular file: ${path}`, 'UNSAFE_PATH');
151
+ }
152
+ if (process.platform !== 'win32' && (stat.mode & 63n) !== 0n) {
153
+ throw new WorkflowStateError(`workflow state is not owner-only: ${path}`, 'INSECURE_PERMISSIONS');
154
+ }
155
+ }
156
+ function assertWithinReadLimit(stat, maxBytes, label) {
157
+ if (maxBytes !== undefined && stat.size > BigInt(maxBytes)) {
158
+ throw new WorkflowStateError(`${label} byte limit exceeded`, 'RESOURCE_LIMIT');
159
+ }
160
+ }
161
+ function readBoundedFile(fd, maxBytes, expectedBytes, label) {
162
+ const buffer = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedBytes + 1));
163
+ let offset = 0;
164
+ while (offset < buffer.length) {
165
+ const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null);
166
+ if (bytesRead === 0)
167
+ break;
168
+ offset += bytesRead;
169
+ }
170
+ if (offset > maxBytes)
171
+ throw new WorkflowStateError(`${label} byte limit exceeded`, 'RESOURCE_LIMIT');
172
+ return buffer.subarray(0, offset).toString('utf8');
173
+ }
174
+ function readOwnerOnlyFile(path, optional = false, maxBytes, resourceLabel = 'workflow state') {
175
+ let pathBefore;
176
+ try {
177
+ pathBefore = lstatSync(path, { bigint: true });
178
+ }
179
+ catch (error) {
180
+ if (optional && isErrno(error, 'ENOENT'))
181
+ return undefined;
182
+ throw error;
183
+ }
184
+ assertOwnerOnlyRegularFile(pathBefore, path);
185
+ assertWithinReadLimit(pathBefore, maxBytes, resourceLabel);
186
+ const noFollow = constants['O_NOFOLLOW'] ?? 0;
187
+ let fd;
188
+ try {
189
+ fd = openSync(path, constants.O_RDONLY | noFollow);
190
+ }
191
+ catch (error) {
192
+ if (isErrno(error, 'ENOENT')) {
193
+ throw new WorkflowStateError(`workflow state changed while opening: ${path}`, 'UNSAFE_PATH');
194
+ }
195
+ if (isErrno(error, 'ELOOP') || isErrno(error, 'EMLINK')) {
196
+ throw new WorkflowStateError(`symlink is not allowed: ${path}`, 'UNSAFE_PATH');
197
+ }
198
+ throw error;
199
+ }
200
+ try {
201
+ const openedBefore = fstatSync(fd, { bigint: true });
202
+ assertOwnerOnlyRegularFile(openedBefore, path);
203
+ assertWithinReadLimit(openedBefore, maxBytes, resourceLabel);
204
+ if (!sameFileSnapshot(pathBefore, openedBefore)) {
205
+ throw new WorkflowStateError(`workflow state changed while opening: ${path}`, 'UNSAFE_PATH');
206
+ }
207
+ const raw = maxBytes === undefined
208
+ ? readFileSync(fd, 'utf8')
209
+ : readBoundedFile(fd, maxBytes, Number(openedBefore.size), resourceLabel);
210
+ const openedAfter = fstatSync(fd, { bigint: true });
211
+ let pathAfter;
212
+ try {
213
+ pathAfter = lstatSync(path, { bigint: true });
214
+ }
215
+ catch (error) {
216
+ if (isErrno(error, 'ENOENT')) {
217
+ throw new WorkflowStateError(`workflow state changed while reading: ${path}`, 'UNSAFE_PATH');
218
+ }
219
+ throw error;
220
+ }
221
+ assertOwnerOnlyRegularFile(pathAfter, path);
222
+ if (!sameFileSnapshot(openedBefore, openedAfter) || !sameFileSnapshot(openedAfter, pathAfter)) {
223
+ throw new WorkflowStateError(`workflow state changed while reading: ${path}`, 'UNSAFE_PATH');
224
+ }
225
+ return raw;
226
+ }
227
+ finally {
228
+ closeSync(fd);
229
+ }
230
+ }
231
+ function parseJsonPayload(raw, path) {
232
+ try {
233
+ return JSON.parse(raw);
234
+ }
235
+ catch {
236
+ throw new WorkflowStateError(`workflow state is corrupt JSON: ${path}`, 'CORRUPT_STATE');
237
+ }
238
+ }
239
+ function readJsonFile(path) {
240
+ return parseJsonPayload(readOwnerOnlyFile(path), path);
241
+ }
242
+ function readOptionalJsonFile(path) {
243
+ const raw = readOwnerOnlyFile(path, true);
244
+ return raw === undefined ? undefined : parseJsonPayload(raw, path);
245
+ }
246
+ function writeJsonAtomic(root, path, value) {
247
+ assertJsonDomain(value, 'workflow state');
248
+ assertNoSymlink(path);
249
+ const temp = join(root, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
250
+ assertWithin(root, temp);
251
+ const payload = `${JSON.stringify(value, null, 2)}\n`;
252
+ let fd;
253
+ let pendingError;
254
+ try {
255
+ fd = openSync(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
256
+ writeFileSync(fd, payload, 'utf8');
257
+ fsyncSync(fd);
258
+ closeSync(fd);
259
+ fd = undefined;
260
+ renameSync(temp, path);
261
+ if (process.platform !== 'win32') {
262
+ chmodSync(path, 0o600);
263
+ const dirFd = openSync(dirname(path), constants.O_RDONLY);
264
+ try {
265
+ fsyncSync(dirFd);
266
+ }
267
+ finally {
268
+ closeSync(dirFd);
269
+ }
270
+ }
271
+ }
272
+ catch (error) {
273
+ pendingError = error;
274
+ }
275
+ finally {
276
+ if (fd !== undefined)
277
+ closeSync(fd);
278
+ try {
279
+ unlinkSync(temp);
280
+ }
281
+ catch (error) {
282
+ if (!isErrno(error, 'ENOENT') && pendingError === undefined)
283
+ pendingError = error;
284
+ }
285
+ }
286
+ if (pendingError !== undefined)
287
+ throw pendingError;
288
+ }
289
+ function migrateLegacy(value) {
290
+ const workflowId = value.workflowId ?? `legacy-${value.runId}`;
291
+ const spec = { ...value.spec, workflowId };
292
+ return {
293
+ ...value,
294
+ schemaVersion: WORKFLOW_RUN_SCHEMA_VERSION,
295
+ workflowSchemaVersion: WORKFLOW_DEFINITION_SCHEMA_VERSION,
296
+ workflowId,
297
+ definitionDigest: value.definitionDigest ?? deriveWorkflowDefinitionDigest(spec),
298
+ spec,
299
+ };
300
+ }
301
+ function parseState(raw, path) {
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(raw);
305
+ }
306
+ catch {
307
+ throw new WorkflowStateError(`workflow state is corrupt JSON: ${path}`, 'CORRUPT_STATE');
308
+ }
309
+ if (typeof parsed !== 'object' || parsed === null) {
310
+ throw new WorkflowStateError(`workflow state is not an object: ${path}`, 'CORRUPT_STATE');
311
+ }
312
+ const version = parsed.schemaVersion;
313
+ if (version !== 1 && version !== 2 && version !== 3 && version !== WORKFLOW_RUN_SCHEMA_VERSION) {
314
+ throw new WorkflowStateError(`unsupported workflow state schema: ${String(version)}`, 'UNSUPPORTED_SCHEMA');
315
+ }
316
+ const state = version === WORKFLOW_RUN_SCHEMA_VERSION
317
+ ? parsed
318
+ : migrateLegacy(parsed);
319
+ assertStableId(state.runId, 'runId');
320
+ assertStableId(state.workflowId, 'workflowId');
321
+ if (state.runId !== basename(path, '.json')) {
322
+ throw new WorkflowStateError(`workflow state runId does not match its file name: ${path}`, 'CORRUPT_STATE');
323
+ }
324
+ if (state.workflowSchemaVersion !== WORKFLOW_DEFINITION_SCHEMA_VERSION
325
+ || typeof state.definitionDigest !== 'string' || !/^[a-f0-9]{64}$/.test(state.definitionDigest)
326
+ || typeof state.workflowName !== 'string'
327
+ || !['queued', 'pending', 'running', 'retry_wait', 'pause_requested', 'paused', 'cancel_requested', 'cancelled', 'waiting_approval', 'succeeded', 'failed', 'unsafe_to_resume'].includes(state.status)
328
+ || (state.lastErrorClass !== undefined && !isWorkflowSafeErrorClass(state.lastErrorClass))
329
+ || !state.spec || typeof state.steps !== 'object' || state.steps === null || !state.context
330
+ || typeof state.context.input !== 'object' || state.context.input === null || Array.isArray(state.context.input)
331
+ || typeof state.context.steps !== 'object' || state.context.steps === null || Array.isArray(state.context.steps)) {
332
+ throw new WorkflowStateError(`workflow state is missing required fields: ${path}`, 'CORRUPT_STATE');
333
+ }
334
+ for (const [executionId, step] of Object.entries(state.steps)) {
335
+ if (!step || step.executionId !== executionId || typeof step.stepId !== 'string'
336
+ || !['script', 'agent', 'approval'].includes(step.kind)
337
+ || !['idempotent', 'non_idempotent'].includes(step.idempotency)
338
+ || !['pending', 'running', 'retry_wait', 'waiting_approval', 'succeeded', 'failed'].includes(step.status)
339
+ || (step.lastErrorClass !== undefined && !isWorkflowSafeErrorClass(step.lastErrorClass))
340
+ || !Number.isInteger(step.attempts) || step.attempts < 0
341
+ || !Number.isInteger(step.maxAttempts) || step.maxAttempts < 1 || step.attempts > step.maxAttempts) {
342
+ throw new WorkflowStateError(`workflow step state is invalid: ${executionId}`, 'CORRUPT_STATE');
343
+ }
344
+ }
345
+ if (state.currentStep !== undefined && !state.steps[state.currentStep]) {
346
+ throw new WorkflowStateError('workflow currentStep does not identify a persisted step', 'CORRUPT_STATE');
347
+ }
348
+ assertJsonDomain(state, 'workflow state');
349
+ return state;
350
+ }
351
+ function emptyControl() {
352
+ return { schemaVersion: WORKFLOW_CONTROL_SCHEMA_VERSION, approvals: {} };
353
+ }
354
+ function parseControl(value, path) {
355
+ if (typeof value !== 'object' || value === null) {
356
+ throw new WorkflowStateError(`workflow control is not an object: ${path}`, 'CORRUPT_STATE');
357
+ }
358
+ const control = value;
359
+ if (control.schemaVersion !== WORKFLOW_CONTROL_SCHEMA_VERSION
360
+ || typeof control.approvals !== 'object' || control.approvals === null || Array.isArray(control.approvals)) {
361
+ throw new WorkflowStateError(`workflow control is invalid: ${path}`, 'CORRUPT_STATE');
362
+ }
363
+ assertJsonDomain(control, 'workflow control');
364
+ return control;
365
+ }
366
+ function validateOperatorOptions(options) {
367
+ assertStableId(options.actor, 'actor');
368
+ if (options.reason === undefined)
369
+ return;
370
+ const hasControlCharacter = [...options.reason].some((character) => {
371
+ const code = character.charCodeAt(0);
372
+ return code <= 0x1f || code === 0x7f;
373
+ });
374
+ if (options.reason.length > MAX_OPERATOR_REASON_LENGTH || hasControlCharacter || containsWorkflowSensitiveText(options.reason)) {
375
+ throw new WorkflowStateError('operator reason is not safe durable metadata', 'INVALID_TRANSITION');
376
+ }
377
+ }
378
+ function isTerminal(status) {
379
+ return ['cancelled', 'succeeded', 'failed', 'unsafe_to_resume'].includes(status);
380
+ }
381
+ function resolveHistoryBound(value, maximum, label) {
382
+ const configured = value ?? maximum;
383
+ if (!Number.isSafeInteger(configured) || configured < 1 || configured > maximum) {
384
+ throw new WorkflowStateError(`${label} must be an integer between 1 and ${maximum}`, 'RESOURCE_LIMIT');
385
+ }
386
+ return configured;
387
+ }
388
+ function jsonDigest(value) {
389
+ return createHash('sha256').update(transactionStableJson(value)).digest('hex');
390
+ }
391
+ function transactionStableJson(value) {
392
+ if (Array.isArray(value))
393
+ return `[${value.map(transactionStableJson).join(',')}]`;
394
+ if (typeof value === 'object' && value !== null) {
395
+ return `{${Object.entries(value)
396
+ .sort(([a], [b]) => (a < b ? -1 : (a > b ? 1 : 0)))
397
+ .map(([key, child]) => `${JSON.stringify(key)}:${transactionStableJson(child)}`).join(',')}}`;
398
+ }
399
+ return JSON.stringify(value) ?? 'null';
400
+ }
401
+ function deterministicHistoryEventId(input, sequence) {
402
+ return `evt_${jsonDigest({ ...input, sequence })}`;
403
+ }
404
+ function isDigest(value) {
405
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
406
+ }
407
+ function isHistoryEventId(value) {
408
+ return typeof value === 'string'
409
+ && /^evt_(?:[a-f0-9]{64}|[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/.test(value);
410
+ }
411
+ const HISTORY_EVENT_TYPES = new Set([
412
+ 'run_created', 'run_queued', 'run_started', 'run_succeeded', 'run_failed',
413
+ 'step_started', 'step_retry_scheduled', 'step_succeeded', 'step_failed',
414
+ 'pause_requested', 'paused', 'resume_requested', 'cancel_requested', 'cancelled',
415
+ 'approval_waiting', 'approval_approved', 'approval_rejected', 'recovery_started', 'unsafe_to_resume',
416
+ ]);
417
+ const RUN_STATUSES = new Set([
418
+ 'queued', 'pending', 'running', 'retry_wait', 'pause_requested', 'paused', 'cancel_requested', 'cancelled',
419
+ 'waiting_approval', 'succeeded', 'failed', 'unsafe_to_resume',
420
+ ]);
421
+ function validateHistory(value, runId, expectedWorkflowId, maxHistoryEvents, maxHistoryBytes, path) {
422
+ if (!Array.isArray(value))
423
+ throw new WorkflowStateError(`workflow history is invalid: ${path}`, 'CORRUPT_STATE');
424
+ if (value.length > maxHistoryEvents) {
425
+ throw new WorkflowStateError('workflow history event limit exceeded', 'RESOURCE_LIMIT');
426
+ }
427
+ assertJsonDomain(value, 'workflow history');
428
+ const serializedBytes = Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
429
+ if (serializedBytes > maxHistoryBytes) {
430
+ throw new WorkflowStateError('workflow history byte limit exceeded', 'RESOURCE_LIMIT');
431
+ }
432
+ const eventIds = new Set();
433
+ let workflowId = expectedWorkflowId;
434
+ for (let index = 0; index < value.length; index += 1) {
435
+ const candidate = value[index];
436
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
437
+ throw new WorkflowStateError(`workflow history event is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
438
+ }
439
+ const event = candidate;
440
+ if (event.sequence !== index + 1 || !Number.isSafeInteger(event.sequence)
441
+ || !isHistoryEventId(event.eventId) || eventIds.has(event.eventId)
442
+ || event.runId !== runId || !HISTORY_EVENT_TYPES.has(event.type) || !RUN_STATUSES.has(event.status)
443
+ || (event.errorClass !== undefined && !isWorkflowSafeErrorClass(event.errorClass))
444
+ || typeof event.at !== 'string' || Number.isNaN(Date.parse(event.at))) {
445
+ throw new WorkflowStateError(`workflow history event is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
446
+ }
447
+ if (!isStableIdValue(event.runId) || !isStableIdValue(event.workflowId)) {
448
+ throw new WorkflowStateError(`workflow history identity is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
449
+ }
450
+ workflowId ??= event.workflowId;
451
+ if (event.workflowId !== workflowId) {
452
+ throw new WorkflowStateError('workflow history identity is inconsistent', 'CORRUPT_STATE');
453
+ }
454
+ if (event.executionId !== undefined
455
+ && (typeof event.executionId !== 'string' || !/^[A-Za-z0-9._:/[\]-]{1,4096}$/.test(event.executionId))) {
456
+ throw new WorkflowStateError(`workflow history executionId is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
457
+ }
458
+ for (const id of [event.stepId, event.gateId, event.actor]) {
459
+ if (id !== undefined && !isStableIdValue(id)) {
460
+ throw new WorkflowStateError(`workflow history metadata is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
461
+ }
462
+ }
463
+ if (event.at !== new Date(event.at).toISOString()
464
+ || (event.attempt !== undefined && (!Number.isSafeInteger(event.attempt) || event.attempt < 1))
465
+ || (event.reason !== undefined && typeof event.reason !== 'string')) {
466
+ throw new WorkflowStateError(`workflow history event metadata is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
467
+ }
468
+ if (/^evt_[a-f0-9]{64}$/.test(event.eventId)) {
469
+ const { sequence, eventId: _eventId, ...input } = event;
470
+ if (event.eventId !== deterministicHistoryEventId(input, sequence)) {
471
+ throw new WorkflowStateError(`workflow history event digest is invalid at sequence ${index + 1}`, 'CORRUPT_STATE');
472
+ }
473
+ }
474
+ eventIds.add(event.eventId);
475
+ }
476
+ return value;
477
+ }
478
+ function unlinkDurable(path) {
479
+ assertNoSymlink(path);
480
+ try {
481
+ unlinkSync(path);
482
+ }
483
+ catch (error) {
484
+ if (!isErrno(error, 'ENOENT'))
485
+ throw error;
486
+ return;
487
+ }
488
+ if (process.platform !== 'win32') {
489
+ const dirFd = openSync(dirname(path), constants.O_RDONLY);
490
+ try {
491
+ fsyncSync(dirFd);
492
+ }
493
+ finally {
494
+ closeSync(dirFd);
495
+ }
496
+ }
497
+ }
498
+ export class WorkflowStateStore {
499
+ options;
500
+ root;
501
+ maxHistoryEvents;
502
+ maxHistoryBytes;
503
+ constructor(root, options = {}) {
504
+ this.options = options;
505
+ this.root = resolve(root);
506
+ this.maxHistoryEvents = resolveHistoryBound(options.maxHistoryEvents, MAX_WORKFLOW_HISTORY_EVENTS, 'maxHistoryEvents');
507
+ this.maxHistoryBytes = resolveHistoryBound(options.maxHistoryBytes, MAX_WORKFLOW_HISTORY_BYTES, 'maxHistoryBytes');
508
+ }
509
+ ensureConcurrencyLimit(limit) {
510
+ ensureOwnerOnlyDirectory(this.root);
511
+ const configPath = join(this.root, '.concurrency.json');
512
+ const lockPath = join(this.root, '.concurrency-config.lock');
513
+ assertWithin(this.root, configPath);
514
+ assertWithin(this.root, lockPath);
515
+ assertNoSymlink(configPath);
516
+ assertNoSymlink(lockPath);
517
+ acquireLock(lockPath, this.options.lock);
518
+ try {
519
+ if (!existsSync(configPath)) {
520
+ writeJsonAtomic(this.root, configPath, { schemaVersion: 1, maxConcurrentRuns: limit });
521
+ return limit;
522
+ }
523
+ const value = readJsonFile(configPath);
524
+ if (typeof value !== 'object' || value === null
525
+ || value.schemaVersion !== 1
526
+ || !Number.isSafeInteger(value.maxConcurrentRuns)) {
527
+ throw new WorkflowStateError('workflow concurrency configuration is invalid', 'CORRUPT_STATE');
528
+ }
529
+ return value.maxConcurrentRuns;
530
+ }
531
+ finally {
532
+ releaseLock(lockPath);
533
+ }
534
+ }
535
+ tryAcquireConcurrencySlot(limit) {
536
+ ensureOwnerOnlyDirectory(this.root);
537
+ for (let slot = 0; slot < limit; slot += 1) {
538
+ const path = this.concurrencySlotPath(slot);
539
+ assertNoSymlink(path);
540
+ try {
541
+ acquireLock(path, { maxTries: 1, waitMs: 0 });
542
+ assertNoSymlink(path);
543
+ return slot;
544
+ }
545
+ catch (error) {
546
+ if (!(error instanceof LockTimeoutError))
547
+ throw error;
548
+ }
549
+ }
550
+ return null;
551
+ }
552
+ releaseConcurrencySlot(slot) {
553
+ releaseLock(this.concurrencySlotPath(slot));
554
+ }
555
+ concurrencySlotPath(slot) {
556
+ if (!Number.isSafeInteger(slot) || slot < 0) {
557
+ throw new WorkflowStateError('workflow concurrency slot is invalid', 'CORRUPT_STATE');
558
+ }
559
+ const path = join(this.root, `.concurrency-slot-${slot}.lock`);
560
+ assertWithin(this.root, path);
561
+ return path;
562
+ }
563
+ statePath(runId) {
564
+ assertStableId(runId, 'runId');
565
+ const path = join(this.root, `${runId}.json`);
566
+ assertWithin(this.root, path);
567
+ return path;
568
+ }
569
+ controlPath(runId) {
570
+ assertStableId(runId, 'runId');
571
+ return join(this.root, `${runId}.control.json`);
572
+ }
573
+ historyPath(runId) {
574
+ assertStableId(runId, 'runId');
575
+ return join(this.root, `${runId}.history.json`);
576
+ }
577
+ commitLockPath(runId) {
578
+ assertStableId(runId, 'runId');
579
+ const path = join(this.root, `${runId}.commit.lock`);
580
+ assertWithin(this.root, path);
581
+ return path;
582
+ }
583
+ legacyControlLockPath(runId) {
584
+ assertStableId(runId, 'runId');
585
+ const path = join(this.root, `${runId}.control.lock`);
586
+ assertWithin(this.root, path);
587
+ return path;
588
+ }
589
+ legacyHistoryLockPath(runId) {
590
+ assertStableId(runId, 'runId');
591
+ const path = join(this.root, `${runId}.history.lock`);
592
+ assertWithin(this.root, path);
593
+ return path;
594
+ }
595
+ transactionPath(runId) {
596
+ assertStableId(runId, 'runId');
597
+ const path = join(this.root, `${runId}.transaction.wal`);
598
+ assertWithin(this.root, path);
599
+ return path;
600
+ }
601
+ lockPath(runId) {
602
+ assertStableId(runId, 'runId');
603
+ return join(this.root, `${runId}.lock`);
604
+ }
605
+ withRunLock(runId, fn) {
606
+ this.acquireRunLock(runId);
607
+ try {
608
+ return fn();
609
+ }
610
+ finally {
611
+ this.releaseRunLock(runId);
612
+ }
613
+ }
614
+ acquireRunLock(runId) {
615
+ ensureOwnerOnlyDirectory(this.root);
616
+ const lockPath = this.lockPath(runId);
617
+ assertWithin(this.root, lockPath);
618
+ assertNoSymlink(lockPath);
619
+ acquireLock(lockPath, this.options.lock);
620
+ assertNoSymlink(lockPath);
621
+ }
622
+ releaseRunLock(runId) {
623
+ releaseLock(this.lockPath(runId));
624
+ }
625
+ readStored(runId) {
626
+ ensureOwnerOnlyDirectory(this.root);
627
+ return this.withCommitLock(runId, () => {
628
+ this.replayTransactionUnlocked(runId);
629
+ return this.readStoredUnlocked(runId);
630
+ });
631
+ }
632
+ readStoredUnlocked(runId) {
633
+ const path = this.statePath(runId);
634
+ const raw = readOwnerOnlyFile(path, true);
635
+ if (raw === undefined)
636
+ throw new WorkflowStateError(`workflow run not found: ${runId}`, 'STATE_NOT_FOUND');
637
+ return parseState(raw, path);
638
+ }
639
+ readOptionalStoredUnlocked(runId) {
640
+ const path = this.statePath(runId);
641
+ const raw = readOwnerOnlyFile(path, true);
642
+ return raw === undefined ? undefined : parseState(raw, path);
643
+ }
644
+ read(runId) {
645
+ ensureOwnerOnlyDirectory(this.root);
646
+ return this.withCommitLock(runId, () => {
647
+ this.replayTransactionUnlocked(runId);
648
+ const state = this.readStoredUnlocked(runId);
649
+ if (isTerminal(state.status))
650
+ return state;
651
+ const control = this.readControlUnlocked(runId);
652
+ if (control.cancel)
653
+ return { ...state, status: 'cancel_requested' };
654
+ if (control.pause && state.status !== 'paused' && state.status !== 'waiting_approval') {
655
+ return { ...state, status: 'pause_requested' };
656
+ }
657
+ return state;
658
+ });
659
+ }
660
+ listRunIds() {
661
+ ensureOwnerOnlyDirectory(this.root);
662
+ const names = readdirSync(this.root);
663
+ const pending = names
664
+ .filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.transaction\.wal$/.test(name))
665
+ .map((name) => name.slice(0, -'.transaction.wal'.length));
666
+ const stored = names
667
+ .filter((name) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.json$/.test(name))
668
+ .filter((name) => !name.endsWith('.control.json') && !name.endsWith('.history.json'))
669
+ .map((name) => name.slice(0, -'.json'.length));
670
+ return [...new Set([...stored, ...pending])].sort();
671
+ }
672
+ list() {
673
+ return this.listRunIds().map((runId) => this.read(runId));
674
+ }
675
+ write(state) {
676
+ this.commit(state);
677
+ }
678
+ commit(state, history = [], controlPatch = {}) {
679
+ const changesControl = Object.prototype.hasOwnProperty.call(controlPatch, 'pause')
680
+ || Object.prototype.hasOwnProperty.call(controlPatch, 'cancel');
681
+ return this.transactSelected(state.runId, changesControl, history.length > 0, (snapshot) => ({
682
+ state,
683
+ history,
684
+ ...(changesControl
685
+ ? { control: this.patchControl(snapshot.control, controlPatch) }
686
+ : {}),
687
+ }));
688
+ }
689
+ /** Commit a completion only when no durable cancellation won the commit-lock race. */
690
+ commitUnlessCancelled(state, history = []) {
691
+ let cancellation;
692
+ this.transactSelected(state.runId, true, history.length > 0, (snapshot) => {
693
+ cancellation = snapshot.control.cancel;
694
+ return cancellation ? {} : { state, history };
695
+ });
696
+ return cancellation;
697
+ }
698
+ /** Last-resort state/control projection when bounded or corrupt history prevents a safety transition. */
699
+ commitSafetyState(state, controlPatch = {}) {
700
+ this.transactSelected(state.runId, true, false, (snapshot) => ({
701
+ state,
702
+ control: this.patchControl(snapshot.control, controlPatch),
703
+ }));
704
+ }
705
+ readControl(runId) {
706
+ ensureOwnerOnlyDirectory(this.root);
707
+ return this.withCommitLock(runId, () => {
708
+ this.replayTransactionUnlocked(runId);
709
+ this.readStoredUnlocked(runId);
710
+ return this.readControlUnlocked(runId);
711
+ });
712
+ }
713
+ readControlUnlocked(runId) {
714
+ const path = this.controlPath(runId);
715
+ const value = readOptionalJsonFile(path);
716
+ return value === undefined ? emptyControl() : parseControl(value, path);
717
+ }
718
+ requestPause(runId, options, requestedAt = new Date().toISOString()) {
719
+ validateOperatorOptions(options);
720
+ const request = { ...options, requestedAt };
721
+ this.requestControlOnly(runId, { pause: request }, 'pause_requested');
722
+ }
723
+ requestPauseWithHistory(runId, options, requestedAt = new Date().toISOString()) {
724
+ validateOperatorOptions(options);
725
+ const request = { ...options, requestedAt };
726
+ this.requestControl(runId, { pause: request }, 'pause_requested', options, requestedAt);
727
+ }
728
+ clearPauseRequest(runId) {
729
+ this.transactSelected(runId, true, false, ({ state, control }) => {
730
+ if (!state)
731
+ throw new WorkflowStateError(`workflow run not found: ${runId}`, 'STATE_NOT_FOUND');
732
+ return { control: this.patchControl(control, { pause: null }) };
733
+ });
734
+ }
735
+ requestCancel(runId, options, requestedAt = new Date().toISOString()) {
736
+ validateOperatorOptions(options);
737
+ const request = { ...options, requestedAt };
738
+ this.requestControlOnly(runId, { cancel: request }, 'cancel_requested');
739
+ }
740
+ requestCancelWithHistory(runId, options, requestedAt = new Date().toISOString()) {
741
+ validateOperatorOptions(options);
742
+ const request = { ...options, requestedAt };
743
+ this.requestControl(runId, { cancel: request }, 'cancel_requested', options, requestedAt);
744
+ }
745
+ clearCancelRequest(runId) {
746
+ this.transactSelected(runId, true, false, ({ state, control }) => {
747
+ if (!state)
748
+ throw new WorkflowStateError(`workflow run not found: ${runId}`, 'STATE_NOT_FOUND');
749
+ return { control: this.patchControl(control, { cancel: null }) };
750
+ });
751
+ }
752
+ approve(runId, gateId, options, requestedAt = new Date().toISOString()) {
753
+ this.decideApproval(runId, gateId, 'approved', options, requestedAt);
754
+ }
755
+ reject(runId, gateId, options, requestedAt = new Date().toISOString()) {
756
+ this.decideApproval(runId, gateId, 'rejected', options, requestedAt);
757
+ }
758
+ decideApproval(runId, gateId, decision, options, requestedAt) {
759
+ assertStableId(gateId, 'gateId');
760
+ validateOperatorOptions(options);
761
+ this.transactSelected(runId, true, false, ({ state, control }) => {
762
+ if (!state)
763
+ throw new WorkflowStateError(`workflow run not found: ${runId}`, 'STATE_NOT_FOUND');
764
+ if (state.status !== 'waiting_approval' || state.approval?.gateId !== gateId) {
765
+ throw new WorkflowStateError('workflow run is not waiting at the requested approval gate', 'INVALID_TRANSITION');
766
+ }
767
+ const next = { ...control, approvals: { ...control.approvals } };
768
+ next.approvals[state.approval.executionId] = {
769
+ gateId,
770
+ executionId: state.approval.executionId,
771
+ decision,
772
+ ...options,
773
+ requestedAt,
774
+ };
775
+ return { control: next };
776
+ });
777
+ }
778
+ appendHistory(input) {
779
+ assertStableId(input.runId, 'runId');
780
+ assertStableId(input.workflowId, 'workflowId');
781
+ const [event] = this.transactSelected(input.runId, false, true, () => ({ history: [input] }));
782
+ if (!event)
783
+ throw new WorkflowStateError('workflow history transaction produced no event', 'CORRUPT_STATE');
784
+ return event;
785
+ }
786
+ readHistory(runId) {
787
+ ensureOwnerOnlyDirectory(this.root);
788
+ return this.withCommitLock(runId, () => {
789
+ this.replayTransactionUnlocked(runId);
790
+ const state = this.readStoredUnlocked(runId);
791
+ return this.readHistoryUnlocked(runId, state.workflowId);
792
+ });
793
+ }
794
+ history(runId) {
795
+ return this.readHistory(runId);
796
+ }
797
+ readHistoryUnlocked(runId, workflowId) {
798
+ const path = this.historyPath(runId);
799
+ const raw = readOwnerOnlyFile(path, true, this.maxHistoryBytes, 'workflow history');
800
+ if (raw === undefined)
801
+ return [];
802
+ if (Buffer.byteLength(raw, 'utf8') > this.maxHistoryBytes) {
803
+ throw new WorkflowStateError('workflow history byte limit exceeded', 'RESOURCE_LIMIT');
804
+ }
805
+ return validateHistory(parseJsonPayload(raw, path), runId, workflowId, this.maxHistoryEvents, this.maxHistoryBytes, path);
806
+ }
807
+ transact(runId, prepare) {
808
+ return this.transactSelected(runId, true, true, prepare);
809
+ }
810
+ transactSelected(runId, includeControl, includeHistory, prepare) {
811
+ ensureOwnerOnlyDirectory(this.root);
812
+ assertStableId(runId, 'runId');
813
+ return this.withCommitLock(runId, () => {
814
+ this.replayTransactionUnlocked(runId);
815
+ const state = this.readOptionalStoredUnlocked(runId);
816
+ const control = includeControl ? this.readControlUnlocked(runId) : emptyControl();
817
+ const history = includeHistory ? this.readHistoryUnlocked(runId, state?.workflowId) : [];
818
+ return this.persistTransactionUnlocked(runId, { state, control, history }, prepare({ state, control, history }));
819
+ });
820
+ }
821
+ requestControl(runId, patch, type, options, requestedAt) {
822
+ const prepare = ({ state, control }) => {
823
+ if (!state)
824
+ throw new WorkflowStateError(`workflow run not found: ${runId}`, 'STATE_NOT_FOUND');
825
+ if (isTerminal(state.status)) {
826
+ throw new WorkflowStateError(type === 'pause_requested' ? 'terminal workflow runs cannot be paused' : 'terminal workflow runs cannot be cancelled', 'INVALID_TRANSITION');
827
+ }
828
+ return {
829
+ control: this.patchControl(control, patch),
830
+ history: [{
831
+ runId, workflowId: state.workflowId, type, status: type, at: requestedAt, ...options,
832
+ }],
833
+ };
834
+ };
835
+ try {
836
+ this.transact(runId, prepare);
837
+ }
838
+ catch (error) {
839
+ if (!(error instanceof WorkflowStateError)
840
+ || !['RESOURCE_LIMIT', 'CORRUPT_STATE', 'UNSAFE_PATH', 'INSECURE_PERMISSIONS'].includes(error.code))
841
+ throw error;
842
+ this.transactSelected(runId, true, false, ({ state, control }) => {
843
+ const update = prepare({ state, control, history: [] });
844
+ return { control: update.control };
845
+ });
846
+ }
847
+ }
848
+ requestControlOnly(runId, patch, type) {
849
+ this.transactSelected(runId, true, false, ({ state, control }) => {
850
+ if (!state)
851
+ throw new WorkflowStateError(`workflow run not found: ${runId}`, 'STATE_NOT_FOUND');
852
+ if (isTerminal(state.status)) {
853
+ throw new WorkflowStateError(type === 'pause_requested' ? 'terminal workflow runs cannot be paused' : 'terminal workflow runs cannot be cancelled', 'INVALID_TRANSITION');
854
+ }
855
+ return { control: this.patchControl(control, patch) };
856
+ });
857
+ }
858
+ persistTransactionUnlocked(runId, snapshot, update) {
859
+ if (update.state) {
860
+ if (update.state.runId !== runId)
861
+ throw new WorkflowStateError('workflow transaction run identity changed', 'CORRUPT_STATE');
862
+ parseState(`${JSON.stringify(update.state)}\n`, this.statePath(runId));
863
+ }
864
+ if (update.control)
865
+ parseControl(update.control, this.controlPath(runId));
866
+ const workflowId = update.state?.workflowId
867
+ ?? snapshot.state?.workflowId
868
+ ?? snapshot.history[0]?.workflowId
869
+ ?? update.history?.[0]?.workflowId;
870
+ if (!workflowId)
871
+ throw new WorkflowStateError('workflow transaction has no workflow identity', 'CORRUPT_STATE');
872
+ assertStableId(workflowId, 'workflowId');
873
+ if (snapshot.state && snapshot.state.workflowId !== workflowId) {
874
+ throw new WorkflowStateError('workflow transaction state identity changed', 'CORRUPT_STATE');
875
+ }
876
+ const appended = (update.history ?? []).map((input, index) => {
877
+ if (input.runId !== runId || input.workflowId !== workflowId) {
878
+ throw new WorkflowStateError('workflow transaction history identity changed', 'CORRUPT_STATE');
879
+ }
880
+ const sequence = snapshot.history.length + index + 1;
881
+ return { ...input, sequence, eventId: deterministicHistoryEventId(input, sequence) };
882
+ });
883
+ const nextHistory = [...snapshot.history, ...appended];
884
+ if (appended.length > 0) {
885
+ validateHistory(nextHistory, runId, workflowId, this.maxHistoryEvents, this.maxHistoryBytes, this.historyPath(runId));
886
+ }
887
+ const unsigned = {
888
+ schemaVersion: WORKFLOW_TRANSACTION_SCHEMA_VERSION,
889
+ runId,
890
+ workflowId,
891
+ stateBeforeDigest: jsonDigest(snapshot.state ?? null),
892
+ ...(update.state && jsonDigest(update.state) !== jsonDigest(snapshot.state ?? null)
893
+ ? { state: { beforeDigest: jsonDigest(snapshot.state ?? null), value: update.state } }
894
+ : {}),
895
+ ...(update.control && jsonDigest(update.control) !== jsonDigest(snapshot.control)
896
+ ? { control: { beforeDigest: jsonDigest(snapshot.control), value: update.control } }
897
+ : {}),
898
+ ...(appended.length > 0
899
+ ? { history: { beforeDigest: jsonDigest(snapshot.history), value: nextHistory } }
900
+ : {}),
901
+ };
902
+ if (!unsigned.state && !unsigned.control && !unsigned.history)
903
+ return appended;
904
+ const record = {
905
+ ...unsigned,
906
+ transactionId: `txn_${jsonDigest(unsigned)}`,
907
+ };
908
+ const path = this.transactionPath(runId);
909
+ if (existsSync(path))
910
+ throw new WorkflowStateError('workflow transaction WAL was not replayed', 'CORRUPT_STATE');
911
+ writeJsonAtomic(this.root, path, record);
912
+ this.notifyTransactionPhase(record, 'commit', 'wal_persisted');
913
+ this.applyTransactionUnlocked(record, 'commit');
914
+ return appended;
915
+ }
916
+ replayTransactionUnlocked(runId) {
917
+ const record = this.readTransactionUnlocked(runId, true);
918
+ if (record)
919
+ this.applyTransactionUnlocked(record, 'replay');
920
+ }
921
+ readTransactionUnlocked(runId, optional = false) {
922
+ const path = this.transactionPath(runId);
923
+ const value = optional ? readOptionalJsonFile(path) : readJsonFile(path);
924
+ if (value === undefined)
925
+ return undefined;
926
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
927
+ throw new WorkflowStateError('workflow transaction WAL is invalid', 'CORRUPT_STATE');
928
+ }
929
+ const version = value.schemaVersion;
930
+ if (version !== WORKFLOW_TRANSACTION_SCHEMA_VERSION) {
931
+ throw new WorkflowStateError(`unsupported workflow transaction schema: ${String(version)}`, 'UNSUPPORTED_SCHEMA');
932
+ }
933
+ const record = value;
934
+ if (record.runId !== runId || typeof record.transactionId !== 'string'
935
+ || !/^txn_[a-f0-9]{64}$/.test(record.transactionId)
936
+ || typeof record.workflowId !== 'string' || !isDigest(record.stateBeforeDigest)
937
+ || (!record.state && !record.control && !record.history)) {
938
+ throw new WorkflowStateError('workflow transaction WAL is invalid', 'CORRUPT_STATE');
939
+ }
940
+ assertStableId(record.runId, 'runId');
941
+ assertStableId(record.workflowId, 'workflowId');
942
+ for (const projection of [record.state, record.control, record.history]) {
943
+ if (projection && (!isDigest(projection.beforeDigest) || !Object.prototype.hasOwnProperty.call(projection, 'value'))) {
944
+ throw new WorkflowStateError('workflow transaction projection is invalid', 'CORRUPT_STATE');
945
+ }
946
+ }
947
+ if (record.state) {
948
+ const state = parseState(`${JSON.stringify(record.state.value)}\n`, this.statePath(runId));
949
+ if (state.workflowId !== record.workflowId) {
950
+ throw new WorkflowStateError('workflow transaction state identity is invalid', 'CORRUPT_STATE');
951
+ }
952
+ }
953
+ if (record.control)
954
+ parseControl(record.control.value, this.controlPath(runId));
955
+ if (record.history) {
956
+ validateHistory(record.history.value, runId, record.workflowId, this.maxHistoryEvents, this.maxHistoryBytes, this.historyPath(runId));
957
+ }
958
+ const { transactionId: _transactionId, ...unsigned } = record;
959
+ if (record.transactionId !== `txn_${jsonDigest(unsigned)}`) {
960
+ throw new WorkflowStateError('workflow transaction WAL digest is invalid', 'CORRUPT_STATE');
961
+ }
962
+ return record;
963
+ }
964
+ applyTransactionUnlocked(record, mode) {
965
+ const stateBeforeProjection = this.readOptionalStoredUnlocked(record.runId);
966
+ if (!record.state && jsonDigest(stateBeforeProjection ?? null) !== record.stateBeforeDigest) {
967
+ throw new WorkflowStateError('workflow transaction state precondition diverged', 'CORRUPT_STATE');
968
+ }
969
+ if (record.state) {
970
+ this.assertProjectionBase(stateBeforeProjection ?? null, record.state, 'state');
971
+ if (jsonDigest(stateBeforeProjection ?? null) !== jsonDigest(record.state.value)) {
972
+ writeJsonAtomic(this.root, this.statePath(record.runId), record.state.value);
973
+ }
974
+ this.notifyTransactionPhase(record, mode, 'state_projected');
975
+ }
976
+ if (record.control) {
977
+ const current = this.readControlUnlocked(record.runId);
978
+ this.assertProjectionBase(current, record.control, 'control');
979
+ if (jsonDigest(current) !== jsonDigest(record.control.value)) {
980
+ writeJsonAtomic(this.root, this.controlPath(record.runId), record.control.value);
981
+ }
982
+ this.notifyTransactionPhase(record, mode, 'control_projected');
983
+ }
984
+ if (record.history) {
985
+ const current = this.readHistoryUnlocked(record.runId, record.workflowId);
986
+ this.assertProjectionBase(current, record.history, 'history');
987
+ if (jsonDigest(current) !== jsonDigest(record.history.value)) {
988
+ writeJsonAtomic(this.root, this.historyPath(record.runId), record.history.value);
989
+ }
990
+ this.notifyTransactionPhase(record, mode, 'history_projected');
991
+ }
992
+ this.notifyTransactionPhase(record, mode, 'before_wal_clear');
993
+ unlinkDurable(this.transactionPath(record.runId));
994
+ }
995
+ assertProjectionBase(current, projection, label) {
996
+ const currentDigest = jsonDigest(current);
997
+ if (currentDigest !== projection.beforeDigest && currentDigest !== jsonDigest(projection.value)) {
998
+ throw new WorkflowStateError(`workflow transaction ${label} projection diverged`, 'CORRUPT_STATE');
999
+ }
1000
+ }
1001
+ notifyTransactionPhase(record, mode, phase) {
1002
+ this.options.onTransactionPhase?.({ runId: record.runId, transactionId: record.transactionId, mode, phase });
1003
+ }
1004
+ patchControl(control, patch) {
1005
+ const next = { ...control, approvals: { ...control.approvals } };
1006
+ if (Object.prototype.hasOwnProperty.call(patch, 'pause')) {
1007
+ if (patch.pause === null || patch.pause === undefined)
1008
+ delete next.pause;
1009
+ else
1010
+ next.pause = patch.pause;
1011
+ }
1012
+ if (Object.prototype.hasOwnProperty.call(patch, 'cancel')) {
1013
+ if (patch.cancel === null || patch.cancel === undefined)
1014
+ delete next.cancel;
1015
+ else
1016
+ next.cancel = patch.cancel;
1017
+ }
1018
+ return next;
1019
+ }
1020
+ withCommitLock(runId, fn) {
1021
+ assertStableId(runId, 'runId');
1022
+ // Legacy binaries lock control and history independently. Keep this order stable so mixed-version
1023
+ // processes serialize both projections before the WAL-wide commit lock is acquired.
1024
+ const lockPaths = [
1025
+ this.legacyControlLockPath(runId),
1026
+ this.legacyHistoryLockPath(runId),
1027
+ this.commitLockPath(runId),
1028
+ ];
1029
+ const acquired = [];
1030
+ try {
1031
+ for (const lockPath of lockPaths) {
1032
+ assertNoSymlink(lockPath);
1033
+ acquireLock(lockPath, this.options.lock);
1034
+ acquired.push(lockPath);
1035
+ assertNoSymlink(lockPath);
1036
+ }
1037
+ return fn();
1038
+ }
1039
+ finally {
1040
+ for (const lockPath of acquired.reverse())
1041
+ releaseLock(lockPath);
1042
+ }
1043
+ }
1044
+ }