@workflow/world-local 5.0.0-beta.3 → 5.0.0-beta.5

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.
@@ -1,13 +1,62 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowWorldError, } from '@workflow/errors';
3
+ import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError, } from '@workflow/errors';
4
4
  import { EventSchema, HookSchema, isLegacySpecVersion, requiresNewerWorld, SPEC_VERSION_CURRENT, StepSchema, validateUlidTimestamp, WaitSchema, WorkflowRunSchema, } from '@workflow/world';
5
+ import { z } from 'zod';
5
6
  import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js';
6
- import { assertSafeEntityId, deleteJSON, jsonReplacer, listJSONFiles, paginatedFileSystemQuery, readJSONWithFallback, resolveWithinBase, taggedPath, writeExclusive, writeJSON, } from '../fs.js';
7
+ import { assertSafeEntityId, deleteJSON, jsonReplacer, listJSONFiles, paginatedFileSystemQuery, readJSON, readJSONWithFallback, resolveWithinBase, taggedPath, writeExclusive, writeJSON, } from '../fs.js';
7
8
  import { stripEventDataRefs } from './filters.js';
8
9
  import { getObjectCreatedAt, hashToken, monotonicUlid } from './helpers.js';
9
10
  import { deleteAllHooksForRun } from './hooks-storage.js';
10
11
  import { handleLegacyEvent } from './legacy.js';
12
+ /**
13
+ * Per-step in-process async mutex. Serializes concurrent `events.create` calls
14
+ * that target the same step, so that the "check terminal state, then write step
15
+ * entity + event" sequence is atomic. Without this, two concurrent step_started
16
+ * calls can both pass the not-terminal check and both write step_started events
17
+ * — or a step_started can land in the log after step_completed has already
18
+ * written, producing unconsumed events on replay.
19
+ *
20
+ * Duplicate step_started events for a non-terminal step are still allowed
21
+ * (retries legitimately re-start a step), only writes to an already-terminal
22
+ * step are rejected.
23
+ */
24
+ const stepLocks = new Map();
25
+ const HookTokenClaimSchema = z.object({
26
+ runId: z.string(),
27
+ });
28
+ async function readHookTokenClaim(constraintPath) {
29
+ try {
30
+ return await readJSON(constraintPath, HookTokenClaimSchema);
31
+ }
32
+ catch (error) {
33
+ if (error instanceof SyntaxError || error instanceof z.ZodError) {
34
+ return null;
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+ function withStepLock(key, fn) {
40
+ const prev = stepLocks.get(key);
41
+ const taskBox = {};
42
+ const task = (async () => {
43
+ if (prev) {
44
+ // Wait for the previous task to settle; don't inherit its errors.
45
+ await prev.catch(() => undefined);
46
+ }
47
+ try {
48
+ return await fn();
49
+ }
50
+ finally {
51
+ if (stepLocks.get(key) === taskBox.task) {
52
+ stepLocks.delete(key);
53
+ }
54
+ }
55
+ })();
56
+ taskBox.task = task;
57
+ stepLocks.set(key, task);
58
+ return task;
59
+ }
11
60
  /**
12
61
  * Helper function to delete all waits associated with a workflow run.
13
62
  * Called when a run reaches a terminal state.
@@ -31,12 +80,11 @@ async function deleteAllWaitsForRun(basedir, runId) {
31
80
  export function createEventsStorage(basedir, tag) {
32
81
  return {
33
82
  async create(runId, data, params) {
34
- const eventId = `evnt_${monotonicUlid()}`;
35
- const now = new Date();
36
83
  // Validate request-supplied IDs before they're concatenated into
37
84
  // filesystem paths. This is the primary defense against path traversal
38
85
  // attacks where a client supplies runId / correlationId values like
39
86
  // "../../../package" to read or write files outside the storage root.
87
+ // Run before taking the per-step mutex so malformed inputs fail fast.
40
88
  if (runId != null && runId !== '') {
41
89
  assertSafeEntityId('runId', runId);
42
90
  }
@@ -45,653 +93,702 @@ export function createEventsStorage(basedir, tag) {
45
93
  data.correlationId.length > 0) {
46
94
  assertSafeEntityId('correlationId', data.correlationId);
47
95
  }
48
- // For run_created events, use client-provided runId or generate one server-side
49
- let effectiveRunId;
50
- if (data.eventType === 'run_created' && (!runId || runId === '')) {
51
- effectiveRunId = `wrun_${monotonicUlid()}`;
96
+ // Step lifecycle events are serialized per-step via an in-process mutex
97
+ // so that the "check state, then write" sequence in step_started /
98
+ // step_completed / step_failed / step_retrying is atomic. step_created
99
+ // is also serialized so duplicate-create races don't leave extra
100
+ // step_created events in the log.
101
+ const isStepEvent = data.eventType === 'step_created' ||
102
+ data.eventType === 'step_started' ||
103
+ data.eventType === 'step_completed' ||
104
+ data.eventType === 'step_failed' ||
105
+ data.eventType === 'step_retrying';
106
+ if (isStepEvent && runId && data.correlationId) {
107
+ const lockKey = tag
108
+ ? `${runId}-${data.correlationId}.${tag}`
109
+ : `${runId}-${data.correlationId}`;
110
+ return withStepLock(lockKey, () => createImpl());
52
111
  }
53
- else if (!runId) {
54
- throw new Error('runId is required for non-run_created events');
55
- }
56
- else {
57
- effectiveRunId = runId;
58
- }
59
- // Validate client-provided runId timestamp is within acceptable threshold
60
- if (data.eventType === 'run_created' && runId && runId !== '') {
61
- const validationError = validateUlidTimestamp(effectiveRunId, 'wrun_');
62
- if (validationError) {
63
- throw new WorkflowWorldError(validationError);
112
+ return createImpl();
113
+ async function createImpl() {
114
+ const eventId = `evnt_${monotonicUlid()}`;
115
+ const now = new Date();
116
+ // For run_created events, use client-provided runId or generate one server-side
117
+ let effectiveRunId;
118
+ if (data.eventType === 'run_created' && (!runId || runId === '')) {
119
+ effectiveRunId = `wrun_${monotonicUlid()}`;
64
120
  }
65
- }
66
- // specVersion is always sent by the runtime, but we provide a fallback for safety
67
- const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
68
- // Helper to check if run is in terminal state
69
- const isRunTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
70
- // Helper to check if step is in terminal state
71
- const isStepTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
72
- // Get current run state for validation (if not creating a new run)
73
- // Skip run validation for step_completed and step_retrying - they only operate
74
- // on running steps, and running steps are always allowed to modify regardless
75
- // of run state. This optimization saves filesystem reads per step event.
76
- let currentRun = null;
77
- const skipRunValidationEvents = ['step_completed', 'step_retrying'];
78
- if (data.eventType !== 'run_created' &&
79
- !skipRunValidationEvents.includes(data.eventType)) {
80
- currentRun = await readJSONWithFallback(basedir, 'runs', effectiveRunId, WorkflowRunSchema, tag);
81
- // Resilient start: run_started on non-existent run with eventData
82
- // creates the run first, so the queue can bootstrap a run that
83
- // failed to create during start().
84
- if (data.eventType === 'run_started' &&
85
- !currentRun &&
86
- 'eventData' in data &&
87
- data.eventData) {
88
- const runInputData = data.eventData;
89
- if (runInputData.deploymentId &&
90
- runInputData.workflowName &&
91
- runInputData.input !== undefined) {
92
- // Atomically try to create the run entity. writeExclusive
93
- // uses O_CREAT|O_EXCL so only the first writer wins,
94
- // preventing a TOCTOU race where a concurrent run_created
95
- // from start() could overwrite a run that was already
96
- // transitioned to 'running'.
97
- const createdRun = {
98
- runId: effectiveRunId,
99
- deploymentId: runInputData.deploymentId,
100
- status: 'pending',
101
- workflowName: runInputData.workflowName,
102
- specVersion: effectiveSpecVersion,
103
- executionContext: runInputData.executionContext,
104
- input: runInputData.input,
105
- output: undefined,
106
- error: undefined,
107
- startedAt: undefined,
108
- completedAt: undefined,
109
- createdAt: now,
110
- updatedAt: now,
111
- };
112
- const runPath = taggedPath(basedir, 'runs', effectiveRunId, tag);
113
- const created = await writeExclusive(runPath, JSON.stringify(createdRun, jsonReplacer));
114
- if (created) {
115
- // We created the run — also write the run_created event.
116
- const runCreatedEventId = `evnt_${monotonicUlid()}`;
117
- const runCreatedEvent = {
118
- eventType: 'run_created',
121
+ else if (!runId) {
122
+ throw new Error('runId is required for non-run_created events');
123
+ }
124
+ else {
125
+ effectiveRunId = runId;
126
+ }
127
+ // Validate client-provided runId timestamp is within acceptable threshold
128
+ if (data.eventType === 'run_created' && runId && runId !== '') {
129
+ const validationError = validateUlidTimestamp(effectiveRunId, 'wrun_');
130
+ if (validationError) {
131
+ throw new WorkflowWorldError(validationError);
132
+ }
133
+ }
134
+ // specVersion is always sent by the runtime, but we provide a fallback for safety
135
+ const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
136
+ // Helper to check if run is in terminal state
137
+ const isRunTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
138
+ // Helper to check if step is in terminal state
139
+ const isStepTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
140
+ // Get current run state for validation (if not creating a new run)
141
+ // Skip run validation for step_completed and step_retrying - they only operate
142
+ // on running steps, and running steps are always allowed to modify regardless
143
+ // of run state. This optimization saves filesystem reads per step event.
144
+ let currentRun = null;
145
+ const skipRunValidationEvents = ['step_completed', 'step_retrying'];
146
+ if (data.eventType !== 'run_created' &&
147
+ !skipRunValidationEvents.includes(data.eventType)) {
148
+ currentRun = await readJSONWithFallback(basedir, 'runs', effectiveRunId, WorkflowRunSchema, tag);
149
+ // Resilient start: run_started on non-existent run with eventData
150
+ // creates the run first, so the queue can bootstrap a run that
151
+ // failed to create during start().
152
+ if (data.eventType === 'run_started' &&
153
+ !currentRun &&
154
+ 'eventData' in data &&
155
+ data.eventData) {
156
+ const runInputData = data.eventData;
157
+ if (runInputData.deploymentId &&
158
+ runInputData.workflowName &&
159
+ runInputData.input !== undefined) {
160
+ // Atomically try to create the run entity. writeExclusive
161
+ // uses O_CREAT|O_EXCL so only the first writer wins,
162
+ // preventing a TOCTOU race where a concurrent run_created
163
+ // from start() could overwrite a run that was already
164
+ // transitioned to 'running'.
165
+ const createdRun = {
119
166
  runId: effectiveRunId,
120
- eventId: runCreatedEventId,
121
- createdAt: now,
167
+ deploymentId: runInputData.deploymentId,
168
+ status: 'pending',
169
+ workflowName: runInputData.workflowName,
122
170
  specVersion: effectiveSpecVersion,
123
- eventData: {
124
- deploymentId: runInputData.deploymentId,
125
- workflowName: runInputData.workflowName,
126
- input: runInputData.input,
127
- executionContext: runInputData.executionContext,
128
- },
171
+ executionContext: runInputData.executionContext,
172
+ input: runInputData.input,
173
+ output: undefined,
174
+ error: undefined,
175
+ startedAt: undefined,
176
+ completedAt: undefined,
177
+ createdAt: now,
178
+ updatedAt: now,
129
179
  };
130
- const createdCompositeKey = `${effectiveRunId}-${runCreatedEventId}`;
131
- await writeJSON(taggedPath(basedir, 'events', createdCompositeKey, tag), runCreatedEvent);
132
- currentRun = createdRun;
133
- }
134
- else {
135
- // Run already exists (concurrent run_created won the
136
- // race). Re-read it so downstream logic sees the real state.
137
- currentRun = await readJSONWithFallback(basedir, 'runs', effectiveRunId, WorkflowRunSchema, tag);
180
+ const runPath = taggedPath(basedir, 'runs', effectiveRunId, tag);
181
+ const created = await writeExclusive(runPath, JSON.stringify(createdRun, jsonReplacer));
182
+ if (created) {
183
+ // We created the run — also write the run_created event.
184
+ const runCreatedEventId = `evnt_${monotonicUlid()}`;
185
+ const runCreatedEvent = {
186
+ eventType: 'run_created',
187
+ runId: effectiveRunId,
188
+ eventId: runCreatedEventId,
189
+ createdAt: now,
190
+ specVersion: effectiveSpecVersion,
191
+ eventData: {
192
+ deploymentId: runInputData.deploymentId,
193
+ workflowName: runInputData.workflowName,
194
+ input: runInputData.input,
195
+ executionContext: runInputData.executionContext,
196
+ },
197
+ };
198
+ const createdCompositeKey = `${effectiveRunId}-${runCreatedEventId}`;
199
+ await writeJSON(taggedPath(basedir, 'events', createdCompositeKey, tag), runCreatedEvent);
200
+ currentRun = createdRun;
201
+ }
202
+ else {
203
+ // Run already exists (concurrent run_created won the
204
+ // race). Re-read it so downstream logic sees the real state.
205
+ currentRun = await readJSONWithFallback(basedir, 'runs', effectiveRunId, WorkflowRunSchema, tag);
206
+ }
138
207
  }
139
208
  }
140
209
  }
141
- }
142
- // ============================================================
143
- // VERSION COMPATIBILITY: Check run spec version
144
- // ============================================================
145
- // For events that have fetched the run, check version compatibility.
146
- // Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
147
- if (currentRun) {
148
- // Check if run requires a newer world version
149
- if (requiresNewerWorld(currentRun.specVersion)) {
150
- throw new RunNotSupportedError(currentRun.specVersion, SPEC_VERSION_CURRENT);
151
- }
152
- // Route to legacy handler for pre-event-sourcing runs
153
- if (isLegacySpecVersion(currentRun.specVersion)) {
154
- return handleLegacyEvent(basedir, effectiveRunId, data, currentRun, params);
155
- }
156
- }
157
- // ============================================================
158
- // VALIDATION: Terminal state and event ordering checks
159
- // ============================================================
160
- // Run terminal state validation
161
- if (currentRun && isRunTerminal(currentRun.status)) {
162
- const runTerminalEvents = [
163
- 'run_started',
164
- 'run_completed',
165
- 'run_failed',
166
- ];
167
- // Idempotent operation: run_cancelled on already cancelled run is allowed
168
- if (data.eventType === 'run_cancelled' &&
169
- currentRun.status === 'cancelled') {
170
- // Return existing state (idempotent)
171
- const event = {
172
- ...data,
173
- runId: effectiveRunId,
174
- eventId,
175
- createdAt: now,
176
- specVersion: effectiveSpecVersion,
177
- };
178
- const compositeKey = `${effectiveRunId}-${eventId}`;
179
- await writeJSON(taggedPath(basedir, 'events', compositeKey, tag), event);
180
- const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
181
- return {
182
- event: stripEventDataRefs(event, resolveData),
183
- run: currentRun,
184
- };
185
- }
186
- // For run_started on terminal runs, use RunExpiredError so the
187
- // runtime knows to exit without retrying.
188
- if (data.eventType === 'run_started') {
189
- throw new RunExpiredError(`Workflow run "${effectiveRunId}" is already in terminal state "${currentRun.status}"`);
210
+ // run_failed on a non-existent run is rejected to match the
211
+ // postgres and vercel worlds, which both surface this as a
212
+ // WorkflowRunNotFoundError rather than silently persisting an
213
+ // event for a run that was never created.
214
+ if (data.eventType === 'run_failed' && !currentRun) {
215
+ throw new WorkflowRunNotFoundError(effectiveRunId);
190
216
  }
191
- // Other run state transitions are not allowed on terminal runs
192
- if (runTerminalEvents.includes(data.eventType) ||
193
- data.eventType === 'run_cancelled') {
194
- throw new EntityConflictError(`Cannot transition run from terminal state "${currentRun.status}"`);
195
- }
196
- // Creating new entities on terminal runs is not allowed
197
- if (data.eventType === 'step_created' ||
198
- data.eventType === 'hook_created' ||
199
- data.eventType === 'wait_created') {
200
- throw new EntityConflictError(`Cannot create new entities on run in terminal state "${currentRun.status}"`);
201
- }
202
- }
203
- // Step-related event validation (ordering and terminal state)
204
- // Store existingStep so we can reuse it later (avoid double read)
205
- let validatedStep = null;
206
- const stepEvents = [
207
- 'step_started',
208
- 'step_completed',
209
- 'step_failed',
210
- 'step_retrying',
211
- ];
212
- if (stepEvents.includes(data.eventType) && data.correlationId) {
213
- const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
214
- validatedStep = await readJSONWithFallback(basedir, 'steps', stepCompositeKey, StepSchema, tag);
215
- // Event ordering: step must exist before these events
216
- if (!validatedStep) {
217
- throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
218
- }
219
- // Step terminal state validation
220
- if (isStepTerminal(validatedStep.status)) {
221
- throw new EntityConflictError(`Cannot modify step in terminal state "${validatedStep.status}"`);
217
+ // ============================================================
218
+ // VERSION COMPATIBILITY: Check run spec version
219
+ // ============================================================
220
+ // For events that have fetched the run, check version compatibility.
221
+ // Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
222
+ if (currentRun) {
223
+ // Check if run requires a newer world version
224
+ if (requiresNewerWorld(currentRun.specVersion)) {
225
+ throw new RunNotSupportedError(currentRun.specVersion, SPEC_VERSION_CURRENT);
226
+ }
227
+ // Route to legacy handler for pre-event-sourcing runs
228
+ if (isLegacySpecVersion(currentRun.specVersion)) {
229
+ return handleLegacyEvent(basedir, effectiveRunId, data, currentRun, params);
230
+ }
222
231
  }
223
- // On terminal runs: only allow completing/failing in-progress steps
232
+ // ============================================================
233
+ // VALIDATION: Terminal state and event ordering checks
234
+ // ============================================================
235
+ // Run terminal state validation
224
236
  if (currentRun && isRunTerminal(currentRun.status)) {
225
- if (validatedStep.status !== 'running') {
226
- throw new RunExpiredError(`Cannot modify non-running step on run in terminal state "${currentRun.status}"`);
237
+ const runTerminalEvents = [
238
+ 'run_started',
239
+ 'run_completed',
240
+ 'run_failed',
241
+ ];
242
+ // Idempotent operation: run_cancelled on already cancelled run is allowed
243
+ if (data.eventType === 'run_cancelled' &&
244
+ currentRun.status === 'cancelled') {
245
+ // Return existing state (idempotent)
246
+ const event = {
247
+ ...data,
248
+ runId: effectiveRunId,
249
+ eventId,
250
+ createdAt: now,
251
+ specVersion: effectiveSpecVersion,
252
+ };
253
+ const compositeKey = `${effectiveRunId}-${eventId}`;
254
+ await writeJSON(taggedPath(basedir, 'events', compositeKey, tag), event);
255
+ const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
256
+ return {
257
+ event: stripEventDataRefs(event, resolveData),
258
+ run: currentRun,
259
+ };
260
+ }
261
+ // For run_started on terminal runs, use RunExpiredError so the
262
+ // runtime knows to exit without retrying.
263
+ if (data.eventType === 'run_started') {
264
+ throw new RunExpiredError(`Workflow run "${effectiveRunId}" is already in terminal state "${currentRun.status}"`);
265
+ }
266
+ // Other run state transitions are not allowed on terminal runs
267
+ if (runTerminalEvents.includes(data.eventType) ||
268
+ data.eventType === 'run_cancelled') {
269
+ throw new EntityConflictError(`Cannot transition run from terminal state "${currentRun.status}"`);
270
+ }
271
+ // Creating new entities on terminal runs is not allowed
272
+ if (data.eventType === 'step_created' ||
273
+ data.eventType === 'hook_created' ||
274
+ data.eventType === 'wait_created') {
275
+ throw new EntityConflictError(`Cannot create new entities on run in terminal state "${currentRun.status}"`);
227
276
  }
228
277
  }
229
- }
230
- // Hook-related event validation (ordering)
231
- const hookEventsRequiringExistence = ['hook_disposed', 'hook_received'];
232
- if (hookEventsRequiringExistence.includes(data.eventType) &&
233
- data.correlationId) {
234
- const existingHook = await readJSONWithFallback(basedir, 'hooks', data.correlationId, HookSchema, tag);
235
- if (!existingHook) {
236
- throw new HookNotFoundError(data.correlationId);
278
+ // Step-related event validation (ordering and terminal state)
279
+ // Store existingStep so we can reuse it later (avoid double read)
280
+ let validatedStep = null;
281
+ const stepEvents = [
282
+ 'step_started',
283
+ 'step_completed',
284
+ 'step_failed',
285
+ 'step_retrying',
286
+ ];
287
+ if (stepEvents.includes(data.eventType) && data.correlationId) {
288
+ const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
289
+ validatedStep = await readJSONWithFallback(basedir, 'steps', stepCompositeKey, StepSchema, tag);
290
+ // Event ordering: step must exist before these events
291
+ if (!validatedStep) {
292
+ throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
293
+ }
294
+ // Step terminal state validation
295
+ if (isStepTerminal(validatedStep.status)) {
296
+ throw new EntityConflictError(`Cannot modify step in terminal state "${validatedStep.status}"`);
297
+ }
298
+ // On terminal runs: only allow completing/failing in-progress steps
299
+ if (currentRun && isRunTerminal(currentRun.status)) {
300
+ if (validatedStep.status !== 'running') {
301
+ throw new RunExpiredError(`Cannot modify non-running step on run in terminal state "${currentRun.status}"`);
302
+ }
303
+ }
237
304
  }
238
- }
239
- const event = {
240
- ...data,
241
- runId: effectiveRunId,
242
- eventId,
243
- createdAt: now,
244
- specVersion: effectiveSpecVersion,
245
- };
246
- // Strip eventData from run_started — it belongs on run_created only.
247
- if (data.eventType === 'run_started' && 'eventData' in event) {
248
- delete event.eventData;
249
- }
250
- // Track entity created/updated for EventResult
251
- let run;
252
- let step;
253
- let hook;
254
- let wait;
255
- // Create/update entity based on event type (event-sourced architecture)
256
- // Run lifecycle events
257
- if (data.eventType === 'run_created' && 'eventData' in data) {
258
- const runData = data.eventData;
259
- run = {
305
+ // Hook-related event validation (ordering)
306
+ const hookEventsRequiringExistence = ['hook_disposed', 'hook_received'];
307
+ if (hookEventsRequiringExistence.includes(data.eventType) &&
308
+ data.correlationId) {
309
+ const existingHook = await readJSONWithFallback(basedir, 'hooks', data.correlationId, HookSchema, tag);
310
+ if (!existingHook) {
311
+ throw new HookNotFoundError(data.correlationId);
312
+ }
313
+ }
314
+ const event = {
315
+ ...data,
260
316
  runId: effectiveRunId,
261
- deploymentId: runData.deploymentId,
262
- status: 'pending',
263
- workflowName: runData.workflowName,
264
- // Propagate specVersion from the event to the run entity
265
- specVersion: effectiveSpecVersion,
266
- executionContext: runData.executionContext,
267
- input: runData.input,
268
- output: undefined,
269
- error: undefined,
270
- startedAt: undefined,
271
- completedAt: undefined,
317
+ eventId,
272
318
  createdAt: now,
273
- updatedAt: now,
319
+ specVersion: effectiveSpecVersion,
274
320
  };
275
- // Use writeExclusive (O_CREAT|O_EXCL) to atomically create the
276
- // run entity file. This prevents a TOCTOU race with the resilient
277
- // start path (run_started on non-existent run) that could result
278
- // in duplicate run_created events in the event log.
279
- const runPath = taggedPath(basedir, 'runs', effectiveRunId, tag);
280
- const created = await writeExclusive(runPath, JSON.stringify(run, jsonReplacer, 2));
281
- if (!created) {
282
- throw new EntityConflictError(`Workflow run "${effectiveRunId}" already exists`);
321
+ // Strip eventData from run_started it belongs on run_created only.
322
+ if (data.eventType === 'run_started' && 'eventData' in event) {
323
+ delete event.eventData;
283
324
  }
284
- }
285
- else if (data.eventType === 'run_started') {
286
- // Reuse currentRun from validation (already read above)
287
- if (currentRun) {
288
- // If already running, return the run without inserting a
289
- // duplicate event. This makes run_started idempotent for
290
- // concurrent invocations. We omit preloaded events here
291
- // because this is a rare race-condition path — the runtime
292
- // falls back to getAllWorkflowRunEvents().
293
- if (currentRun.status === 'running') {
294
- return { run: currentRun };
295
- }
325
+ // Track entity created/updated for EventResult
326
+ let run;
327
+ let step;
328
+ let hook;
329
+ let wait;
330
+ // Create/update entity based on event type (event-sourced architecture)
331
+ // Run lifecycle events
332
+ if (data.eventType === 'run_created' && 'eventData' in data) {
333
+ const runData = data.eventData;
296
334
  run = {
297
- runId: currentRun.runId,
298
- deploymentId: currentRun.deploymentId,
299
- workflowName: currentRun.workflowName,
300
- specVersion: currentRun.specVersion,
301
- executionContext: currentRun.executionContext,
302
- input: currentRun.input,
303
- createdAt: currentRun.createdAt,
304
- expiredAt: currentRun.expiredAt,
305
- status: 'running',
335
+ runId: effectiveRunId,
336
+ deploymentId: runData.deploymentId,
337
+ status: 'pending',
338
+ workflowName: runData.workflowName,
339
+ // Propagate specVersion from the event to the run entity
340
+ specVersion: effectiveSpecVersion,
341
+ executionContext: runData.executionContext,
342
+ input: runData.input,
306
343
  output: undefined,
307
344
  error: undefined,
345
+ startedAt: undefined,
308
346
  completedAt: undefined,
309
- startedAt: currentRun.startedAt ?? now,
347
+ createdAt: now,
310
348
  updatedAt: now,
311
349
  };
312
- await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
350
+ // Use writeExclusive (O_CREAT|O_EXCL) to atomically create the
351
+ // run entity file. This prevents a TOCTOU race with the resilient
352
+ // start path (run_started on non-existent run) that could result
353
+ // in duplicate run_created events in the event log.
354
+ const runPath = taggedPath(basedir, 'runs', effectiveRunId, tag);
355
+ const created = await writeExclusive(runPath, JSON.stringify(run, jsonReplacer, 2));
356
+ if (!created) {
357
+ throw new EntityConflictError(`Workflow run "${effectiveRunId}" already exists`);
358
+ }
313
359
  }
314
- }
315
- else if (data.eventType === 'run_completed' && 'eventData' in data) {
316
- const completedData = data.eventData;
317
- // Reuse currentRun from validation (already read above)
318
- if (currentRun) {
319
- run = {
320
- runId: currentRun.runId,
321
- deploymentId: currentRun.deploymentId,
322
- workflowName: currentRun.workflowName,
323
- specVersion: currentRun.specVersion,
324
- executionContext: currentRun.executionContext,
325
- input: currentRun.input,
326
- createdAt: currentRun.createdAt,
327
- expiredAt: currentRun.expiredAt,
328
- startedAt: currentRun.startedAt,
329
- status: 'completed',
330
- output: completedData.output,
331
- error: undefined,
332
- completedAt: now,
333
- updatedAt: now,
334
- };
335
- await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
336
- await Promise.all([
337
- deleteAllHooksForRun(basedir, effectiveRunId),
338
- deleteAllWaitsForRun(basedir, effectiveRunId),
339
- ]);
360
+ else if (data.eventType === 'run_started') {
361
+ // Reuse currentRun from validation (already read above)
362
+ if (currentRun) {
363
+ // If already running, return the run without inserting a
364
+ // duplicate event. This makes run_started idempotent for
365
+ // concurrent invocations. We omit preloaded events here
366
+ // because this is a rare race-condition path — the runtime
367
+ // falls back to loadWorkflowRunEvents().
368
+ if (currentRun.status === 'running') {
369
+ return { run: currentRun };
370
+ }
371
+ run = {
372
+ runId: currentRun.runId,
373
+ deploymentId: currentRun.deploymentId,
374
+ workflowName: currentRun.workflowName,
375
+ specVersion: currentRun.specVersion,
376
+ executionContext: currentRun.executionContext,
377
+ input: currentRun.input,
378
+ createdAt: currentRun.createdAt,
379
+ expiredAt: currentRun.expiredAt,
380
+ status: 'running',
381
+ output: undefined,
382
+ error: undefined,
383
+ completedAt: undefined,
384
+ startedAt: currentRun.startedAt ?? now,
385
+ updatedAt: now,
386
+ };
387
+ await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
388
+ }
340
389
  }
341
- }
342
- else if (data.eventType === 'run_failed' && 'eventData' in data) {
343
- const failedData = data.eventData;
344
- // Reuse currentRun from validation (already read above)
345
- if (currentRun) {
346
- run = {
347
- runId: currentRun.runId,
348
- deploymentId: currentRun.deploymentId,
349
- workflowName: currentRun.workflowName,
350
- specVersion: currentRun.specVersion,
351
- executionContext: currentRun.executionContext,
352
- input: currentRun.input,
353
- createdAt: currentRun.createdAt,
354
- expiredAt: currentRun.expiredAt,
355
- startedAt: currentRun.startedAt,
356
- status: 'failed',
357
- output: undefined,
358
- error: {
359
- message: typeof failedData.error === 'string'
360
- ? failedData.error
361
- : (failedData.error?.message ?? 'Unknown error'),
362
- stack: failedData.error?.stack,
363
- code: failedData.errorCode,
364
- },
365
- completedAt: now,
366
- updatedAt: now,
367
- };
368
- await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
369
- await Promise.all([
370
- deleteAllHooksForRun(basedir, effectiveRunId),
371
- deleteAllWaitsForRun(basedir, effectiveRunId),
372
- ]);
390
+ else if (data.eventType === 'run_completed' && 'eventData' in data) {
391
+ const completedData = data.eventData;
392
+ // Reuse currentRun from validation (already read above)
393
+ if (currentRun) {
394
+ run = {
395
+ runId: currentRun.runId,
396
+ deploymentId: currentRun.deploymentId,
397
+ workflowName: currentRun.workflowName,
398
+ specVersion: currentRun.specVersion,
399
+ executionContext: currentRun.executionContext,
400
+ input: currentRun.input,
401
+ createdAt: currentRun.createdAt,
402
+ expiredAt: currentRun.expiredAt,
403
+ startedAt: currentRun.startedAt,
404
+ status: 'completed',
405
+ output: completedData.output,
406
+ error: undefined,
407
+ completedAt: now,
408
+ updatedAt: now,
409
+ };
410
+ await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
411
+ await Promise.all([
412
+ deleteAllHooksForRun(basedir, effectiveRunId),
413
+ deleteAllWaitsForRun(basedir, effectiveRunId),
414
+ ]);
415
+ }
373
416
  }
374
- }
375
- else if (data.eventType === 'run_cancelled') {
376
- // Reuse currentRun from validation (already read above)
377
- if (currentRun) {
378
- run = {
379
- runId: currentRun.runId,
380
- deploymentId: currentRun.deploymentId,
381
- workflowName: currentRun.workflowName,
382
- specVersion: currentRun.specVersion,
383
- executionContext: currentRun.executionContext,
384
- input: currentRun.input,
385
- createdAt: currentRun.createdAt,
386
- expiredAt: currentRun.expiredAt,
387
- startedAt: currentRun.startedAt,
388
- status: 'cancelled',
389
- output: undefined,
390
- error: undefined,
391
- completedAt: now,
392
- updatedAt: now,
393
- };
394
- await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
395
- await Promise.all([
396
- deleteAllHooksForRun(basedir, effectiveRunId),
397
- deleteAllWaitsForRun(basedir, effectiveRunId),
398
- ]);
417
+ else if (data.eventType === 'run_failed' && 'eventData' in data) {
418
+ const failedData = data.eventData;
419
+ // Reuse currentRun from validation (already read above)
420
+ if (currentRun) {
421
+ // The error field is SerializedData (Uint8Array) produced by
422
+ // dehydrateRunError. We store it verbatim — consumers hydrate it
423
+ // via hydrateRunError to reconstruct the original thrown value.
424
+ run = {
425
+ runId: currentRun.runId,
426
+ deploymentId: currentRun.deploymentId,
427
+ workflowName: currentRun.workflowName,
428
+ specVersion: currentRun.specVersion,
429
+ executionContext: currentRun.executionContext,
430
+ input: currentRun.input,
431
+ createdAt: currentRun.createdAt,
432
+ expiredAt: currentRun.expiredAt,
433
+ startedAt: currentRun.startedAt,
434
+ status: 'failed',
435
+ output: undefined,
436
+ error: failedData.error,
437
+ errorCode: failedData.errorCode,
438
+ completedAt: now,
439
+ updatedAt: now,
440
+ };
441
+ await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
442
+ await Promise.all([
443
+ deleteAllHooksForRun(basedir, effectiveRunId),
444
+ deleteAllWaitsForRun(basedir, effectiveRunId),
445
+ ]);
446
+ }
399
447
  }
400
- }
401
- else if (
402
- // Step lifecycle events
403
- data.eventType === 'step_created' &&
404
- 'eventData' in data) {
405
- // step_created: Creates step entity with status 'pending', attempt=0, createdAt set
406
- const stepData = data.eventData;
407
- step = {
408
- runId: effectiveRunId,
409
- stepId: data.correlationId,
410
- stepName: stepData.stepName,
411
- status: 'pending',
412
- input: stepData.input,
413
- output: undefined,
414
- error: undefined,
415
- attempt: 0,
416
- startedAt: undefined,
417
- completedAt: undefined,
418
- createdAt: now,
419
- updatedAt: now,
420
- // Propagate specVersion from the event to the step entity
421
- specVersion: effectiveSpecVersion,
422
- };
423
- const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
424
- await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step);
425
- }
426
- else if (data.eventType === 'step_started') {
427
- // step_started: Increments attempt, sets status to 'running'
428
- // Sets startedAt only on the first start (not updated on retries)
429
- // Reuse validatedStep from validation (already read above)
430
- if (validatedStep) {
431
- // Check if retryAfter timestamp hasn't been reached yet
432
- if (validatedStep.retryAfter &&
433
- validatedStep.retryAfter.getTime() > Date.now()) {
434
- throw new TooEarlyError(`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`, {
435
- retryAfter: Math.ceil((validatedStep.retryAfter.getTime() - Date.now()) / 1000),
436
- });
448
+ else if (data.eventType === 'run_cancelled') {
449
+ // Reuse currentRun from validation (already read above)
450
+ if (currentRun) {
451
+ run = {
452
+ runId: currentRun.runId,
453
+ deploymentId: currentRun.deploymentId,
454
+ workflowName: currentRun.workflowName,
455
+ specVersion: currentRun.specVersion,
456
+ executionContext: currentRun.executionContext,
457
+ input: currentRun.input,
458
+ createdAt: currentRun.createdAt,
459
+ expiredAt: currentRun.expiredAt,
460
+ startedAt: currentRun.startedAt,
461
+ status: 'cancelled',
462
+ output: undefined,
463
+ error: undefined,
464
+ completedAt: now,
465
+ updatedAt: now,
466
+ };
467
+ await writeJSON(taggedPath(basedir, 'runs', effectiveRunId, tag), run, { overwrite: true });
468
+ await Promise.all([
469
+ deleteAllHooksForRun(basedir, effectiveRunId),
470
+ deleteAllWaitsForRun(basedir, effectiveRunId),
471
+ ]);
437
472
  }
438
- // Best-effort guard: re-read the step entity to check if it
439
- // reached terminal state between the validation read and now.
440
- // This narrows the TOCTOU window but does not fully eliminate it
441
- // (the local world is single-process / dev-only; the postgres
442
- // world uses SQL-level atomic guards for production).
443
- const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
444
- const freshStep = await readJSONWithFallback(basedir, 'steps', stepCompositeKey, StepSchema, tag);
445
- if (freshStep && isStepTerminal(freshStep.status)) {
446
- throw new EntityConflictError(`Cannot modify step in terminal state "${freshStep.status}"`);
473
+ }
474
+ else if (
475
+ // Step lifecycle events
476
+ data.eventType === 'step_created' &&
477
+ 'eventData' in data) {
478
+ // step_created: Creates step entity with status 'pending', attempt=0, createdAt set.
479
+ // Two concurrent invocations with identical correlationIds (e.g. the
480
+ // snapshot runtime's deterministic correlationIds across replays)
481
+ // must be deduped otherwise both writes succeed and the event log
482
+ // ends up with duplicate step_created entries. The outer
483
+ // withStepLock mutex serializes within a single process; this
484
+ // O_CREAT|O_EXCL constraint file additionally protects against
485
+ // cross-process races (two pnpm workers, redelivered queue
486
+ // messages, etc.). The loser throws EntityConflictError so the
487
+ // runtime's existing catch path can swallow it and avoid
488
+ // double-queuing the step.
489
+ const stepCreatedLockName = tag
490
+ ? `${effectiveRunId}-${data.correlationId}.created.${tag}`
491
+ : `${effectiveRunId}-${data.correlationId}.created`;
492
+ const stepCreatedLockPath = resolveWithinBase(basedir, '.locks', 'steps', stepCreatedLockName);
493
+ const stepCreatedClaimed = await writeExclusive(stepCreatedLockPath, '');
494
+ if (!stepCreatedClaimed) {
495
+ throw new EntityConflictError(`Step "${data.correlationId}" already created`);
447
496
  }
497
+ const stepData = data.eventData;
448
498
  step = {
449
- ...validatedStep,
450
- status: 'running',
451
- // Only set startedAt on the first start
452
- startedAt: validatedStep.startedAt ?? now,
453
- // Increment attempt counter on every start
454
- attempt: validatedStep.attempt + 1,
455
- // Clear retryAfter now that the step has started
456
- retryAfter: undefined,
499
+ runId: effectiveRunId,
500
+ stepId: data.correlationId,
501
+ stepName: stepData.stepName,
502
+ status: 'pending',
503
+ input: stepData.input,
504
+ output: undefined,
505
+ error: undefined,
506
+ attempt: 0,
507
+ startedAt: undefined,
508
+ completedAt: undefined,
509
+ createdAt: now,
457
510
  updatedAt: now,
511
+ // Propagate specVersion from the event to the step entity
512
+ specVersion: effectiveSpecVersion,
458
513
  };
459
- await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
460
- }
461
- }
462
- else if (data.eventType === 'step_completed' && 'eventData' in data) {
463
- // step_completed: Terminal state with output
464
- // Uses writeExclusive on a lock file to atomically prevent concurrent
465
- // invocations from both completing the same step (TOCTOU race).
466
- const completedData = data.eventData;
467
- if (validatedStep) {
468
514
  const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
469
- const lockName = tag
470
- ? `${stepCompositeKey}.terminal.${tag}`
471
- : `${stepCompositeKey}.terminal`;
472
- const terminalLockPath = resolveWithinBase(basedir, '.locks', 'steps', lockName);
473
- const claimed = await writeExclusive(terminalLockPath, '');
474
- if (!claimed) {
475
- throw new EntityConflictError('Cannot modify step in terminal state');
515
+ await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step);
516
+ }
517
+ else if (data.eventType === 'step_started') {
518
+ // step_started: Increments attempt, sets status to 'running'
519
+ // Sets startedAt only on the first start (not updated on retries)
520
+ // Reuse validatedStep from validation (already read above)
521
+ if (validatedStep) {
522
+ // Check if retryAfter timestamp hasn't been reached yet
523
+ if (validatedStep.retryAfter &&
524
+ validatedStep.retryAfter.getTime() > Date.now()) {
525
+ throw new TooEarlyError(`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`, {
526
+ retryAfter: Math.ceil((validatedStep.retryAfter.getTime() - Date.now()) / 1000),
527
+ });
528
+ }
529
+ // Best-effort guard: re-read the step entity to check if it
530
+ // reached terminal state between the validation read and now.
531
+ // This narrows the TOCTOU window but does not fully eliminate it
532
+ // (the local world is single-process / dev-only; the postgres
533
+ // world uses SQL-level atomic guards for production).
534
+ const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
535
+ const freshStep = await readJSONWithFallback(basedir, 'steps', stepCompositeKey, StepSchema, tag);
536
+ if (freshStep && isStepTerminal(freshStep.status)) {
537
+ throw new EntityConflictError(`Cannot modify step in terminal state "${freshStep.status}"`);
538
+ }
539
+ step = {
540
+ ...validatedStep,
541
+ status: 'running',
542
+ // Only set startedAt on the first start
543
+ startedAt: validatedStep.startedAt ?? now,
544
+ // Increment attempt counter on every start
545
+ attempt: validatedStep.attempt + 1,
546
+ // Clear retryAfter now that the step has started
547
+ retryAfter: undefined,
548
+ updatedAt: now,
549
+ };
550
+ await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
476
551
  }
477
- step = {
478
- ...validatedStep,
479
- status: 'completed',
480
- output: completedData.result,
481
- completedAt: now,
482
- updatedAt: now,
483
- };
484
- await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
485
552
  }
486
- }
487
- else if (data.eventType === 'step_failed' && 'eventData' in data) {
488
- // step_failed: Terminal state with error
489
- // Uses writeExclusive on a lock file to atomically prevent concurrent
490
- // invocations from both failing the same step (TOCTOU race).
491
- const failedData = data.eventData;
492
- if (validatedStep) {
493
- const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
494
- const lockName = tag
495
- ? `${stepCompositeKey}.terminal.${tag}`
496
- : `${stepCompositeKey}.terminal`;
497
- const terminalLockPath = resolveWithinBase(basedir, '.locks', 'steps', lockName);
498
- const claimed = await writeExclusive(terminalLockPath, '');
499
- if (!claimed) {
500
- throw new EntityConflictError('Cannot modify step in terminal state');
553
+ else if (data.eventType === 'step_completed' && 'eventData' in data) {
554
+ // step_completed: Terminal state with output
555
+ // Uses writeExclusive on a lock file to atomically prevent concurrent
556
+ // invocations from both completing the same step (TOCTOU race).
557
+ const completedData = data.eventData;
558
+ if (validatedStep) {
559
+ const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
560
+ const lockName = tag
561
+ ? `${stepCompositeKey}.terminal.${tag}`
562
+ : `${stepCompositeKey}.terminal`;
563
+ const terminalLockPath = resolveWithinBase(basedir, '.locks', 'steps', lockName);
564
+ const claimed = await writeExclusive(terminalLockPath, '');
565
+ if (!claimed) {
566
+ throw new EntityConflictError('Cannot modify step in terminal state');
567
+ }
568
+ step = {
569
+ ...validatedStep,
570
+ status: 'completed',
571
+ output: completedData.result,
572
+ completedAt: now,
573
+ updatedAt: now,
574
+ };
575
+ await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
501
576
  }
502
- const error = {
503
- message: typeof failedData.error === 'string'
504
- ? failedData.error
505
- : (failedData.error?.message ?? 'Unknown error'),
506
- stack: failedData.stack,
507
- };
508
- step = {
509
- ...validatedStep,
510
- status: 'failed',
511
- error,
512
- completedAt: now,
513
- updatedAt: now,
514
- };
515
- await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
516
577
  }
517
- }
518
- else if (data.eventType === 'step_retrying' && 'eventData' in data) {
519
- // step_retrying: Sets status back to 'pending', records error
520
- // Reuse validatedStep from validation (already read above)
521
- const retryData = data.eventData;
522
- if (validatedStep) {
523
- const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
524
- step = {
525
- ...validatedStep,
526
- status: 'pending',
527
- error: {
528
- message: typeof retryData.error === 'string'
529
- ? retryData.error
530
- : (retryData.error?.message ?? 'Unknown error'),
531
- stack: retryData.stack,
532
- },
533
- retryAfter: retryData.retryAfter,
534
- updatedAt: now,
535
- };
536
- await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
578
+ else if (data.eventType === 'step_failed' && 'eventData' in data) {
579
+ // step_failed: Terminal state with error
580
+ // Uses writeExclusive on a lock file to atomically prevent concurrent
581
+ // invocations from both failing the same step (TOCTOU race).
582
+ const failedData = data.eventData;
583
+ if (validatedStep) {
584
+ const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
585
+ const lockName = tag
586
+ ? `${stepCompositeKey}.terminal.${tag}`
587
+ : `${stepCompositeKey}.terminal`;
588
+ const terminalLockPath = resolveWithinBase(basedir, '.locks', 'steps', lockName);
589
+ const claimed = await writeExclusive(terminalLockPath, '');
590
+ if (!claimed) {
591
+ throw new EntityConflictError('Cannot modify step in terminal state');
592
+ }
593
+ // The error field is SerializedData (Uint8Array) produced by
594
+ // dehydrateStepError. We store it verbatim — consumers hydrate it
595
+ // via hydrateStepError to reconstruct the original thrown value.
596
+ step = {
597
+ ...validatedStep,
598
+ status: 'failed',
599
+ error: failedData.error,
600
+ completedAt: now,
601
+ updatedAt: now,
602
+ };
603
+ await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
604
+ }
537
605
  }
538
- }
539
- else if (
540
- // Hook lifecycle events
541
- data.eventType === 'hook_created' &&
542
- 'eventData' in data) {
543
- const hookData = data.eventData;
544
- // Atomically claim the token using an exclusive-create constraint file.
545
- // This avoids the TOCTOU race of the previous read-all-then-check approach.
546
- const constraintPath = path.join(basedir, 'hooks', 'tokens', `${hashToken(hookData.token)}.json`);
547
- const tokenClaimed = await writeExclusive(constraintPath, JSON.stringify({
548
- token: hookData.token,
549
- hookId: data.correlationId,
550
- runId: effectiveRunId,
551
- }));
552
- if (!tokenClaimed) {
553
- // Create hook_conflict event instead of hook_created
554
- // This allows the workflow to continue and fail gracefully when the hook is awaited
555
- const conflictEvent = {
556
- eventType: 'hook_conflict',
557
- correlationId: data.correlationId,
558
- eventData: {
559
- token: hookData.token,
560
- },
606
+ else if (data.eventType === 'step_retrying' && 'eventData' in data) {
607
+ // step_retrying: Sets status back to 'pending', records error
608
+ // Reuse validatedStep from validation (already read above)
609
+ const retryData = data.eventData;
610
+ if (validatedStep) {
611
+ const stepCompositeKey = `${effectiveRunId}-${data.correlationId}`;
612
+ step = {
613
+ ...validatedStep,
614
+ status: 'pending',
615
+ error: retryData.error,
616
+ retryAfter: retryData.retryAfter,
617
+ updatedAt: now,
618
+ };
619
+ await writeJSON(taggedPath(basedir, 'steps', stepCompositeKey, tag), step, { overwrite: true });
620
+ }
621
+ }
622
+ else if (
623
+ // Hook lifecycle events
624
+ data.eventType === 'hook_created' &&
625
+ 'eventData' in data) {
626
+ const hookData = data.eventData;
627
+ // Atomically claim the token using an exclusive-create constraint file.
628
+ // This avoids the TOCTOU race of the previous read-all-then-check approach.
629
+ const constraintPath = path.join(basedir, 'hooks', 'tokens', `${hashToken(hookData.token)}.json`);
630
+ const tokenClaimed = await writeExclusive(constraintPath, JSON.stringify({
631
+ token: hookData.token,
632
+ hookId: data.correlationId,
561
633
  runId: effectiveRunId,
562
- eventId,
634
+ }));
635
+ if (!tokenClaimed) {
636
+ const existingClaim = await readHookTokenClaim(constraintPath);
637
+ // Create hook_conflict event instead of hook_created
638
+ // This allows the workflow to continue and fail gracefully when the hook is awaited
639
+ const conflictEvent = {
640
+ eventType: 'hook_conflict',
641
+ correlationId: data.correlationId,
642
+ eventData: {
643
+ token: hookData.token,
644
+ ...(existingClaim
645
+ ? { conflictingRunId: existingClaim.runId }
646
+ : {}),
647
+ },
648
+ runId: effectiveRunId,
649
+ eventId,
650
+ createdAt: now,
651
+ specVersion: effectiveSpecVersion,
652
+ };
653
+ // Store the conflict event
654
+ const compositeKey = `${effectiveRunId}-${eventId}`;
655
+ await writeJSON(taggedPath(basedir, 'events', compositeKey, tag), conflictEvent);
656
+ const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
657
+ const filteredEvent = stripEventDataRefs(conflictEvent, resolveData);
658
+ // Return EventResult with conflict event (no hook entity created)
659
+ return {
660
+ event: filteredEvent,
661
+ run,
662
+ step,
663
+ hook: undefined,
664
+ };
665
+ }
666
+ hook = {
667
+ runId: effectiveRunId,
668
+ hookId: data.correlationId,
669
+ token: hookData.token,
670
+ metadata: hookData.metadata,
671
+ ownerId: 'local-owner',
672
+ projectId: 'local-project',
673
+ environment: 'local',
563
674
  createdAt: now,
675
+ // Propagate specVersion from the event to the hook entity
564
676
  specVersion: effectiveSpecVersion,
677
+ isWebhook: hookData.isWebhook ?? false,
678
+ isSystem: hookData.isSystem ?? false,
565
679
  };
566
- // Store the conflict event
567
- const compositeKey = `${effectiveRunId}-${eventId}`;
568
- await writeJSON(taggedPath(basedir, 'events', compositeKey, tag), conflictEvent);
569
- const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
570
- const filteredEvent = stripEventDataRefs(conflictEvent, resolveData);
571
- // Return EventResult with conflict event (no hook entity created)
572
- return {
573
- event: filteredEvent,
574
- run,
575
- step,
576
- hook: undefined,
577
- };
578
- }
579
- hook = {
580
- runId: effectiveRunId,
581
- hookId: data.correlationId,
582
- token: hookData.token,
583
- metadata: hookData.metadata,
584
- ownerId: 'local-owner',
585
- projectId: 'local-project',
586
- environment: 'local',
587
- createdAt: now,
588
- // Propagate specVersion from the event to the hook entity
589
- specVersion: effectiveSpecVersion,
590
- isWebhook: hookData.isWebhook ?? false,
591
- };
592
- await writeJSON(taggedPath(basedir, 'hooks', data.correlationId, tag), hook);
593
- }
594
- else if (data.eventType === 'hook_disposed') {
595
- // hook_disposed: Deletes hook entity, rejects duplicates.
596
- // Uses writeExclusive on a lock file to atomically prevent concurrent
597
- // invocations from both disposing the same hook (TOCTOU race).
598
- const hookLockName = tag
599
- ? `${data.correlationId}.disposed.${tag}`
600
- : `${data.correlationId}.disposed`;
601
- const lockPath = resolveWithinBase(basedir, '.locks', 'hooks', hookLockName);
602
- const claimed = await writeExclusive(lockPath, '');
603
- if (!claimed) {
604
- throw new EntityConflictError(`Hook "${data.correlationId}" already disposed`);
680
+ await writeJSON(taggedPath(basedir, 'hooks', data.correlationId, tag), hook);
605
681
  }
606
- // Read the hook to get its token before deleting
607
- const hookPath = taggedPath(basedir, 'hooks', data.correlationId, tag);
608
- const existingHook = await readJSONWithFallback(basedir, 'hooks', data.correlationId, HookSchema, tag);
609
- if (existingHook) {
610
- // Delete the token constraint file to free up the token for reuse
611
- const disposedConstraintPath = path.join(basedir, 'hooks', 'tokens', `${hashToken(existingHook.token)}.json`);
612
- await deleteJSON(disposedConstraintPath);
682
+ else if (data.eventType === 'hook_disposed') {
683
+ // hook_disposed: Deletes hook entity, rejects duplicates.
684
+ // Uses writeExclusive on a lock file to atomically prevent concurrent
685
+ // invocations from both disposing the same hook (TOCTOU race).
686
+ const hookLockName = tag
687
+ ? `${data.correlationId}.disposed.${tag}`
688
+ : `${data.correlationId}.disposed`;
689
+ const lockPath = resolveWithinBase(basedir, '.locks', 'hooks', hookLockName);
690
+ const claimed = await writeExclusive(lockPath, '');
691
+ if (!claimed) {
692
+ throw new EntityConflictError(`Hook "${data.correlationId}" already disposed`);
693
+ }
694
+ // Read the hook to get its token before deleting
695
+ const hookPath = taggedPath(basedir, 'hooks', data.correlationId, tag);
696
+ const existingHook = await readJSONWithFallback(basedir, 'hooks', data.correlationId, HookSchema, tag);
697
+ if (existingHook) {
698
+ // Delete the token constraint file to free up the token for reuse
699
+ const disposedConstraintPath = path.join(basedir, 'hooks', 'tokens', `${hashToken(existingHook.token)}.json`);
700
+ await deleteJSON(disposedConstraintPath);
701
+ }
702
+ await deleteJSON(hookPath);
613
703
  }
614
- await deleteJSON(hookPath);
615
- }
616
- else if (data.eventType === 'wait_created' && 'eventData' in data) {
617
- // wait_created: Creates wait entity with status 'waiting'
618
- const waitData = data.eventData;
619
- const waitCompositeKey = `${effectiveRunId}-${data.correlationId}`;
620
- const existingWait = await readJSONWithFallback(basedir, 'waits', waitCompositeKey, WaitSchema, tag);
621
- if (existingWait) {
622
- throw new EntityConflictError(`Wait "${data.correlationId}" already exists`);
704
+ else if (data.eventType === 'wait_created' && 'eventData' in data) {
705
+ // wait_created: Creates wait entity with status 'waiting'.
706
+ // Atomic claim on a per-(runId, correlationId) constraint file
707
+ // ensures duplicate wait_created from concurrent invocations
708
+ // surfaces as EntityConflictError (replaces a prior TOCTOU
709
+ // read-then-check that could let both writers through).
710
+ const waitCompositeKey = `${effectiveRunId}-${data.correlationId}`;
711
+ const waitCreatedLockName = tag
712
+ ? `${waitCompositeKey}.created.${tag}`
713
+ : `${waitCompositeKey}.created`;
714
+ const waitCreatedLockPath = resolveWithinBase(basedir, '.locks', 'waits', waitCreatedLockName);
715
+ const waitCreatedClaimed = await writeExclusive(waitCreatedLockPath, '');
716
+ if (!waitCreatedClaimed) {
717
+ throw new EntityConflictError(`Wait "${data.correlationId}" already exists`);
718
+ }
719
+ const waitData = data.eventData;
720
+ wait = {
721
+ waitId: waitCompositeKey,
722
+ runId: effectiveRunId,
723
+ status: 'waiting',
724
+ resumeAt: waitData.resumeAt,
725
+ completedAt: undefined,
726
+ createdAt: now,
727
+ updatedAt: now,
728
+ specVersion: effectiveSpecVersion,
729
+ };
730
+ await writeJSON(taggedPath(basedir, 'waits', waitCompositeKey, tag), wait);
623
731
  }
624
- wait = {
625
- waitId: waitCompositeKey,
626
- runId: effectiveRunId,
627
- status: 'waiting',
628
- resumeAt: waitData.resumeAt,
629
- completedAt: undefined,
630
- createdAt: now,
631
- updatedAt: now,
632
- specVersion: effectiveSpecVersion,
633
- };
634
- await writeJSON(taggedPath(basedir, 'waits', waitCompositeKey, tag), wait);
635
- }
636
- else if (data.eventType === 'wait_completed') {
637
- // wait_completed: Transitions wait to 'completed', rejects duplicates.
638
- // Uses writeExclusive on a lock file to atomically prevent concurrent
639
- // invocations from both completing the same wait (TOCTOU race).
640
- const waitCompositeKey = `${effectiveRunId}-${data.correlationId}`;
641
- const waitLockName = tag
642
- ? `${waitCompositeKey}.completed.${tag}`
643
- : `${waitCompositeKey}.completed`;
644
- const lockPath = resolveWithinBase(basedir, '.locks', 'waits', waitLockName);
645
- const claimed = await writeExclusive(lockPath, '');
646
- if (!claimed) {
647
- throw new EntityConflictError(`Wait "${data.correlationId}" already completed`);
732
+ else if (data.eventType === 'wait_completed') {
733
+ // wait_completed: Transitions wait to 'completed', rejects duplicates.
734
+ // Uses writeExclusive on a lock file to atomically prevent concurrent
735
+ // invocations from both completing the same wait (TOCTOU race).
736
+ const waitCompositeKey = `${effectiveRunId}-${data.correlationId}`;
737
+ const waitLockName = tag
738
+ ? `${waitCompositeKey}.completed.${tag}`
739
+ : `${waitCompositeKey}.completed`;
740
+ const lockPath = resolveWithinBase(basedir, '.locks', 'waits', waitLockName);
741
+ const claimed = await writeExclusive(lockPath, '');
742
+ if (!claimed) {
743
+ throw new EntityConflictError(`Wait "${data.correlationId}" already completed`);
744
+ }
745
+ const existingWait = await readJSONWithFallback(basedir, 'waits', waitCompositeKey, WaitSchema, tag);
746
+ if (!existingWait) {
747
+ // Clean up the lock file we just claimed — the wait doesn't exist
748
+ await fs.unlink(lockPath).catch(() => { });
749
+ throw new WorkflowWorldError(`Wait "${data.correlationId}" not found`);
750
+ }
751
+ // The lock file (writeExclusive above) already prevents concurrent
752
+ // completions no additional status check needed.
753
+ wait = {
754
+ ...existingWait,
755
+ status: 'completed',
756
+ completedAt: now,
757
+ updatedAt: now,
758
+ };
759
+ await writeJSON(taggedPath(basedir, 'waits', waitCompositeKey, tag), wait, { overwrite: true });
648
760
  }
649
- const existingWait = await readJSONWithFallback(basedir, 'waits', waitCompositeKey, WaitSchema, tag);
650
- if (!existingWait) {
651
- // Clean up the lock file we just claimed — the wait doesn't exist
652
- await fs.unlink(lockPath).catch(() => { });
653
- throw new WorkflowWorldError(`Wait "${data.correlationId}" not found`);
761
+ // Note: hook_received events are stored in the event log but don't
762
+ // modify the Hook entity (which doesn't have a payload field)
763
+ // Store event using composite key {runId}-{eventId}
764
+ const compositeKey = `${effectiveRunId}-${eventId}`;
765
+ await writeJSON(taggedPath(basedir, 'events', compositeKey, tag), event);
766
+ const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
767
+ const filteredEvent = stripEventDataRefs(event, resolveData);
768
+ // For run_started: include all events so the runtime can skip
769
+ // the initial events.list call and reduce TTFB.
770
+ let events;
771
+ if (data.eventType === 'run_started' && run) {
772
+ const allEvents = await paginatedFileSystemQuery({
773
+ directory: path.join(basedir, 'events'),
774
+ schema: EventSchema,
775
+ filePrefix: `${effectiveRunId}-`,
776
+ sortOrder: 'asc',
777
+ getCreatedAt: getObjectCreatedAt('evnt'),
778
+ getId: (e) => e.eventId,
779
+ });
780
+ events = allEvents.data;
654
781
  }
655
- // The lock file (writeExclusive above) already prevents concurrent
656
- // completions — no additional status check needed.
657
- wait = {
658
- ...existingWait,
659
- status: 'completed',
660
- completedAt: now,
661
- updatedAt: now,
782
+ // Return EventResult with event and any created/updated entity
783
+ return {
784
+ event: filteredEvent,
785
+ run,
786
+ step,
787
+ hook,
788
+ wait,
789
+ events,
662
790
  };
663
- await writeJSON(taggedPath(basedir, 'waits', waitCompositeKey, tag), wait, { overwrite: true });
664
- }
665
- // Note: hook_received events are stored in the event log but don't
666
- // modify the Hook entity (which doesn't have a payload field)
667
- // Store event using composite key {runId}-{eventId}
668
- const compositeKey = `${effectiveRunId}-${eventId}`;
669
- await writeJSON(taggedPath(basedir, 'events', compositeKey, tag), event);
670
- const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
671
- const filteredEvent = stripEventDataRefs(event, resolveData);
672
- // For run_started: include all events so the runtime can skip
673
- // the initial events.list call and reduce TTFB.
674
- let events;
675
- if (data.eventType === 'run_started' && run) {
676
- const allEvents = await paginatedFileSystemQuery({
677
- directory: path.join(basedir, 'events'),
678
- schema: EventSchema,
679
- filePrefix: `${effectiveRunId}-`,
680
- sortOrder: 'asc',
681
- getCreatedAt: getObjectCreatedAt('evnt'),
682
- getId: (e) => e.eventId,
683
- });
684
- events = allEvents.data;
685
- }
686
- // Return EventResult with event and any created/updated entity
687
- return {
688
- event: filteredEvent,
689
- run,
690
- step,
691
- hook,
692
- wait,
693
- events,
694
- };
791
+ } // end createImpl
695
792
  },
696
793
  async get(runId, eventId, params) {
697
794
  assertSafeEntityId('runId', runId);