@workflow/world-postgres 4.1.0-beta.28 → 4.1.0-beta.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/drizzle/schema.d.ts +118 -4
- package/dist/drizzle/schema.d.ts.map +1 -1
- package/dist/drizzle/schema.js +11 -2
- package/dist/drizzle/schema.js.map +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +614 -280
- package/dist/storage.js.map +1 -1
- package/package.json +8 -8
- package/src/drizzle/migrations/0005_add_spec_version.sql +5 -0
- package/src/drizzle/migrations/0006_add_error_cbor.sql +5 -0
- package/src/drizzle/migrations/meta/0005_snapshot.json +575 -0
- package/src/drizzle/migrations/meta/_journal.json +14 -0
- package/src/drizzle/migrations/0001_update_error_schema.sql +0 -7
package/dist/storage.js
CHANGED
|
@@ -1,121 +1,67 @@
|
|
|
1
|
-
import { WorkflowAPIError } from '@workflow/errors';
|
|
2
|
-
import { EventSchema, HookSchema, StepSchema, WorkflowRunSchema, } from '@workflow/world';
|
|
3
|
-
import { and, desc, eq, gt, lt, sql } from 'drizzle-orm';
|
|
1
|
+
import { RunNotSupportedError, WorkflowAPIError } from '@workflow/errors';
|
|
2
|
+
import { EventSchema, HookSchema, isLegacySpecVersion, requiresNewerWorld, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, } from '@workflow/world';
|
|
3
|
+
import { and, desc, eq, gt, lt, notInArray, sql } from 'drizzle-orm';
|
|
4
4
|
import { monotonicFactory } from 'ulid';
|
|
5
5
|
import { Schema } from './drizzle/index.js';
|
|
6
6
|
import { compact } from './util.js';
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Parse legacy errorJson (text column with JSON-stringified StructuredError).
|
|
9
|
+
* Used for backwards compatibility when reading from deprecated error column.
|
|
9
10
|
*/
|
|
10
|
-
function
|
|
11
|
-
if (!
|
|
12
|
-
return
|
|
11
|
+
function parseErrorJson(errorJson) {
|
|
12
|
+
if (!errorJson)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(errorJson);
|
|
16
|
+
if (typeof parsed === 'object' && parsed.message !== undefined) {
|
|
17
|
+
return {
|
|
18
|
+
message: parsed.message,
|
|
19
|
+
stack: parsed.stack,
|
|
20
|
+
code: parsed.code,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Not a structured error object, treat as plain string
|
|
24
|
+
return { message: String(parsed) };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Not JSON, treat as plain string error message
|
|
28
|
+
return { message: errorJson };
|
|
13
29
|
}
|
|
14
|
-
const { error, ...rest } = data;
|
|
15
|
-
return {
|
|
16
|
-
...rest,
|
|
17
|
-
error: JSON.stringify({
|
|
18
|
-
message: error.message,
|
|
19
|
-
stack: error.stack,
|
|
20
|
-
code: error.code,
|
|
21
|
-
}),
|
|
22
|
-
};
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
|
-
* Deserialize
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - If error is a plain string → treat as error message
|
|
29
|
-
* - If errorStack/errorCode exist (legacy) → combine into StructuredError
|
|
32
|
+
* Deserialize run data, handling legacy error fields.
|
|
33
|
+
* The error field should already be deserialized from CBOR or fallback to errorJson.
|
|
34
|
+
* This function only handles very old legacy fields (errorStack, errorCode).
|
|
30
35
|
*/
|
|
31
36
|
function deserializeRunError(run) {
|
|
32
|
-
const {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Try to parse as structured error JSON
|
|
37
|
-
if (error) {
|
|
38
|
-
try {
|
|
39
|
-
const parsed = JSON.parse(error);
|
|
40
|
-
if (typeof parsed === 'object' && parsed.message !== undefined) {
|
|
41
|
-
return {
|
|
42
|
-
...rest,
|
|
43
|
-
error: {
|
|
44
|
-
message: parsed.message,
|
|
45
|
-
stack: parsed.stack,
|
|
46
|
-
code: parsed.code,
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
// Not JSON, treat as plain string
|
|
53
|
-
}
|
|
37
|
+
const { errorStack, errorCode, ...rest } = run;
|
|
38
|
+
// If no legacy fields, return as-is (error is already a StructuredError or undefined)
|
|
39
|
+
if (!errorStack && !errorCode) {
|
|
40
|
+
return rest;
|
|
54
41
|
}
|
|
55
|
-
//
|
|
42
|
+
// Very old legacy: separate errorStack/errorCode fields
|
|
43
|
+
const existingError = rest.error;
|
|
56
44
|
return {
|
|
57
45
|
...rest,
|
|
58
46
|
error: {
|
|
59
|
-
message:
|
|
60
|
-
stack: errorStack,
|
|
61
|
-
code: errorCode,
|
|
47
|
+
message: existingError?.message || '',
|
|
48
|
+
stack: existingError?.stack || errorStack,
|
|
49
|
+
code: existingError?.code || errorCode,
|
|
62
50
|
},
|
|
63
51
|
};
|
|
64
52
|
}
|
|
65
53
|
/**
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
function serializeStepError(data) {
|
|
69
|
-
if (!data.error) {
|
|
70
|
-
return data;
|
|
71
|
-
}
|
|
72
|
-
const { error, ...rest } = data;
|
|
73
|
-
return {
|
|
74
|
-
...rest,
|
|
75
|
-
error: JSON.stringify({
|
|
76
|
-
message: error.message,
|
|
77
|
-
stack: error.stack,
|
|
78
|
-
code: error.code,
|
|
79
|
-
}),
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Deserialize error JSON string (or legacy flat fields) into a StructuredError object for steps
|
|
54
|
+
* Deserialize step data, mapping DB columns to interface fields.
|
|
55
|
+
* The error field should already be deserialized from CBOR or fallback to errorJson.
|
|
84
56
|
*/
|
|
85
57
|
function deserializeStepError(step) {
|
|
86
|
-
const {
|
|
87
|
-
if (!error) {
|
|
88
|
-
return step;
|
|
89
|
-
}
|
|
90
|
-
// Try to parse as structured error JSON
|
|
91
|
-
if (error) {
|
|
92
|
-
try {
|
|
93
|
-
const parsed = JSON.parse(error);
|
|
94
|
-
if (typeof parsed === 'object' && parsed.message !== undefined) {
|
|
95
|
-
return {
|
|
96
|
-
...rest,
|
|
97
|
-
error: {
|
|
98
|
-
message: parsed.message,
|
|
99
|
-
stack: parsed.stack,
|
|
100
|
-
code: parsed.code,
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// Not JSON, treat as plain string
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Backwards compatibility: handle legacy separate fields or plain string error
|
|
58
|
+
const { startedAt, ...rest } = step;
|
|
110
59
|
return {
|
|
111
60
|
...rest,
|
|
112
|
-
|
|
113
|
-
message: error || '',
|
|
114
|
-
},
|
|
61
|
+
startedAt,
|
|
115
62
|
};
|
|
116
63
|
}
|
|
117
64
|
export function createRunsStorage(drizzle) {
|
|
118
|
-
const ulid = monotonicFactory();
|
|
119
65
|
const { runs } = Schema;
|
|
120
66
|
const get = drizzle
|
|
121
67
|
.select()
|
|
@@ -124,7 +70,7 @@ export function createRunsStorage(drizzle) {
|
|
|
124
70
|
.limit(1)
|
|
125
71
|
.prepare('workflow_runs_get');
|
|
126
72
|
return {
|
|
127
|
-
async
|
|
73
|
+
get: (async (id, params) => {
|
|
128
74
|
const [value] = await get.execute({ id });
|
|
129
75
|
if (!value) {
|
|
130
76
|
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
@@ -132,29 +78,13 @@ export function createRunsStorage(drizzle) {
|
|
|
132
78
|
value.output ||= value.outputJson;
|
|
133
79
|
value.input ||= value.inputJson;
|
|
134
80
|
value.executionContext ||= value.executionContextJson;
|
|
81
|
+
value.error ||= parseErrorJson(value.errorJson);
|
|
135
82
|
const deserialized = deserializeRunError(compact(value));
|
|
136
83
|
const parsed = WorkflowRunSchema.parse(deserialized);
|
|
137
84
|
const resolveData = params?.resolveData ?? 'all';
|
|
138
85
|
return filterRunData(parsed, resolveData);
|
|
139
|
-
},
|
|
140
|
-
async
|
|
141
|
-
// TODO: we might want to guard this for only specific statuses
|
|
142
|
-
const [value] = await drizzle
|
|
143
|
-
.update(Schema.runs)
|
|
144
|
-
.set({ status: 'cancelled', completedAt: sql `now()` })
|
|
145
|
-
.where(eq(runs.runId, id))
|
|
146
|
-
.returning();
|
|
147
|
-
if (!value) {
|
|
148
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
149
|
-
}
|
|
150
|
-
// Clean up all hooks for this run when cancelling
|
|
151
|
-
await drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, id));
|
|
152
|
-
const deserialized = deserializeRunError(compact(value));
|
|
153
|
-
const parsed = WorkflowRunSchema.parse(deserialized);
|
|
154
|
-
const resolveData = params?.resolveData ?? 'all';
|
|
155
|
-
return filterRunData(parsed, resolveData);
|
|
156
|
-
},
|
|
157
|
-
async list(params) {
|
|
86
|
+
}),
|
|
87
|
+
list: (async (params) => {
|
|
158
88
|
const limit = params?.pagination?.limit ?? 20;
|
|
159
89
|
const fromCursor = params?.pagination?.cursor;
|
|
160
90
|
const all = await drizzle
|
|
@@ -168,6 +98,10 @@ export function createRunsStorage(drizzle) {
|
|
|
168
98
|
const resolveData = params?.resolveData ?? 'all';
|
|
169
99
|
return {
|
|
170
100
|
data: values.map((v) => {
|
|
101
|
+
v.output ||= v.outputJson;
|
|
102
|
+
v.input ||= v.inputJson;
|
|
103
|
+
v.executionContext ||= v.executionContextJson;
|
|
104
|
+
v.error ||= parseErrorJson(v.errorJson);
|
|
171
105
|
const deserialized = deserializeRunError(compact(v));
|
|
172
106
|
const parsed = WorkflowRunSchema.parse(deserialized);
|
|
173
107
|
return filterRunData(parsed, resolveData);
|
|
@@ -175,87 +109,578 @@ export function createRunsStorage(drizzle) {
|
|
|
175
109
|
hasMore,
|
|
176
110
|
cursor: values.at(-1)?.runId ?? null,
|
|
177
111
|
};
|
|
178
|
-
},
|
|
179
|
-
async create(data) {
|
|
180
|
-
const runId = `wrun_${ulid()}`;
|
|
181
|
-
const [value] = await drizzle
|
|
182
|
-
.insert(runs)
|
|
183
|
-
.values({
|
|
184
|
-
runId,
|
|
185
|
-
input: data.input,
|
|
186
|
-
executionContext: data.executionContext,
|
|
187
|
-
deploymentId: data.deploymentId,
|
|
188
|
-
status: 'pending',
|
|
189
|
-
workflowName: data.workflowName,
|
|
190
|
-
})
|
|
191
|
-
.onConflictDoNothing()
|
|
192
|
-
.returning();
|
|
193
|
-
if (!value) {
|
|
194
|
-
throw new WorkflowAPIError(`Run ${runId} already exists`, {
|
|
195
|
-
status: 409,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
return deserializeRunError(compact(value));
|
|
199
|
-
},
|
|
200
|
-
async update(id, data) {
|
|
201
|
-
// Fetch current run to check if startedAt is already set
|
|
202
|
-
const [currentRun] = await drizzle
|
|
203
|
-
.select()
|
|
204
|
-
.from(runs)
|
|
205
|
-
.where(eq(runs.runId, id))
|
|
206
|
-
.limit(1);
|
|
207
|
-
if (!currentRun) {
|
|
208
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
209
|
-
}
|
|
210
|
-
// Serialize the error field if present
|
|
211
|
-
const serialized = serializeRunError(data);
|
|
212
|
-
const updates = {
|
|
213
|
-
...serialized,
|
|
214
|
-
output: data.output,
|
|
215
|
-
};
|
|
216
|
-
// Only set startedAt the first time transitioning to 'running'
|
|
217
|
-
if (data.status === 'running' && !currentRun.startedAt) {
|
|
218
|
-
updates.startedAt = new Date();
|
|
219
|
-
}
|
|
220
|
-
const isBecomingTerminal = data.status === 'completed' ||
|
|
221
|
-
data.status === 'failed' ||
|
|
222
|
-
data.status === 'cancelled';
|
|
223
|
-
if (isBecomingTerminal) {
|
|
224
|
-
updates.completedAt = new Date();
|
|
225
|
-
}
|
|
226
|
-
const [value] = await drizzle
|
|
227
|
-
.update(runs)
|
|
228
|
-
.set(updates)
|
|
229
|
-
.where(eq(runs.runId, id))
|
|
230
|
-
.returning();
|
|
231
|
-
if (!value) {
|
|
232
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
233
|
-
}
|
|
234
|
-
// If transitioning to a terminal status, clean up all hooks for this run
|
|
235
|
-
if (isBecomingTerminal) {
|
|
236
|
-
await drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, id));
|
|
237
|
-
}
|
|
238
|
-
return deserializeRunError(compact(value));
|
|
239
|
-
},
|
|
112
|
+
}),
|
|
240
113
|
};
|
|
241
114
|
}
|
|
242
115
|
function map(obj, fn) {
|
|
243
116
|
return obj ? fn(obj) : undefined;
|
|
244
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Handle events for legacy runs (pre-event-sourcing, specVersion < 2).
|
|
120
|
+
* Legacy runs use different behavior:
|
|
121
|
+
* - run_cancelled: Skip event storage, directly update run
|
|
122
|
+
* - wait_completed: Store event only (no entity mutation)
|
|
123
|
+
* - hook_received: Store event only (hooks exist via old system, no entity mutation)
|
|
124
|
+
* - Other events: Throw error (not supported for legacy runs)
|
|
125
|
+
*/
|
|
126
|
+
async function handleLegacyEventPostgres(drizzle, runId, eventId, data, currentRun, params) {
|
|
127
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
128
|
+
switch (data.eventType) {
|
|
129
|
+
case 'run_cancelled': {
|
|
130
|
+
// Legacy: Skip event storage, directly update run to cancelled
|
|
131
|
+
const now = new Date();
|
|
132
|
+
// Update run status to cancelled
|
|
133
|
+
await drizzle
|
|
134
|
+
.update(Schema.runs)
|
|
135
|
+
.set({
|
|
136
|
+
status: 'cancelled',
|
|
137
|
+
completedAt: now,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
})
|
|
140
|
+
.where(eq(Schema.runs.runId, runId));
|
|
141
|
+
// Delete all hooks for this run
|
|
142
|
+
await drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, runId));
|
|
143
|
+
// Fetch updated run for return value
|
|
144
|
+
const [updatedRun] = await drizzle
|
|
145
|
+
.select()
|
|
146
|
+
.from(Schema.runs)
|
|
147
|
+
.where(eq(Schema.runs.runId, runId))
|
|
148
|
+
.limit(1);
|
|
149
|
+
// Return without event (legacy behavior skips event storage)
|
|
150
|
+
// Type assertion: EventResult expects WorkflowRun, filterRunData may return WorkflowRunWithoutData
|
|
151
|
+
return {
|
|
152
|
+
run: updatedRun
|
|
153
|
+
? filterRunData(deserializeRunError(compact(updatedRun)), resolveData)
|
|
154
|
+
: undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case 'wait_completed':
|
|
158
|
+
case 'hook_received': {
|
|
159
|
+
// Legacy: Store event only (no entity mutation)
|
|
160
|
+
// - wait_completed: for replay purposes
|
|
161
|
+
// - hook_received: hooks exist via old system, just record the event
|
|
162
|
+
const [insertedEvent] = await drizzle
|
|
163
|
+
.insert(Schema.events)
|
|
164
|
+
.values({
|
|
165
|
+
runId,
|
|
166
|
+
eventId,
|
|
167
|
+
correlationId: data.correlationId,
|
|
168
|
+
eventType: data.eventType,
|
|
169
|
+
eventData: 'eventData' in data ? data.eventData : undefined,
|
|
170
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
171
|
+
})
|
|
172
|
+
.returning({ createdAt: Schema.events.createdAt });
|
|
173
|
+
const event = EventSchema.parse({
|
|
174
|
+
...data,
|
|
175
|
+
...insertedEvent,
|
|
176
|
+
runId,
|
|
177
|
+
eventId,
|
|
178
|
+
});
|
|
179
|
+
return { event: filterEventData(event, resolveData) };
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
throw new Error(`Event type '${data.eventType}' not supported for legacy runs ` +
|
|
183
|
+
`(specVersion: ${currentRun.specVersion || 'undefined'}). ` +
|
|
184
|
+
`Please upgrade @workflow packages.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
245
187
|
export function createEventsStorage(drizzle) {
|
|
246
188
|
const ulid = monotonicFactory();
|
|
247
189
|
const { events } = Schema;
|
|
190
|
+
// Prepared statements for validation queries (performance optimization)
|
|
191
|
+
const getRunForValidation = drizzle
|
|
192
|
+
.select({
|
|
193
|
+
status: Schema.runs.status,
|
|
194
|
+
specVersion: Schema.runs.specVersion,
|
|
195
|
+
})
|
|
196
|
+
.from(Schema.runs)
|
|
197
|
+
.where(eq(Schema.runs.runId, sql.placeholder('runId')))
|
|
198
|
+
.limit(1)
|
|
199
|
+
.prepare('events_get_run_for_validation');
|
|
200
|
+
const getStepForValidation = drizzle
|
|
201
|
+
.select({
|
|
202
|
+
status: Schema.steps.status,
|
|
203
|
+
startedAt: Schema.steps.startedAt,
|
|
204
|
+
})
|
|
205
|
+
.from(Schema.steps)
|
|
206
|
+
.where(and(eq(Schema.steps.runId, sql.placeholder('runId')), eq(Schema.steps.stepId, sql.placeholder('stepId'))))
|
|
207
|
+
.limit(1)
|
|
208
|
+
.prepare('events_get_step_for_validation');
|
|
209
|
+
const getHookByToken = drizzle
|
|
210
|
+
.select({ hookId: Schema.hooks.hookId })
|
|
211
|
+
.from(Schema.hooks)
|
|
212
|
+
.where(eq(Schema.hooks.token, sql.placeholder('token')))
|
|
213
|
+
.limit(1)
|
|
214
|
+
.prepare('events_get_hook_by_token');
|
|
248
215
|
return {
|
|
249
216
|
async create(runId, data, params) {
|
|
250
217
|
const eventId = `wevt_${ulid()}`;
|
|
218
|
+
// For run_created events, generate runId server-side if null or empty
|
|
219
|
+
let effectiveRunId;
|
|
220
|
+
if (data.eventType === 'run_created' && (!runId || runId === '')) {
|
|
221
|
+
effectiveRunId = `wrun_${ulid()}`;
|
|
222
|
+
}
|
|
223
|
+
else if (!runId) {
|
|
224
|
+
throw new Error('runId is required for non-run_created events');
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
effectiveRunId = runId;
|
|
228
|
+
}
|
|
229
|
+
// specVersion is always sent by the runtime, but we provide a fallback for safety
|
|
230
|
+
const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
|
|
231
|
+
// Track entity created/updated for EventResult
|
|
232
|
+
let run;
|
|
233
|
+
let step;
|
|
234
|
+
let hook;
|
|
235
|
+
const now = new Date();
|
|
236
|
+
// Helper to check if run is in terminal state
|
|
237
|
+
const isRunTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
|
|
238
|
+
// Helper to check if step is in terminal state
|
|
239
|
+
const isStepTerminal = (status) => ['completed', 'failed'].includes(status);
|
|
240
|
+
// ============================================================
|
|
241
|
+
// VALIDATION: Terminal state and event ordering checks
|
|
242
|
+
// ============================================================
|
|
243
|
+
// Get current run state for validation (if not creating a new run)
|
|
244
|
+
// Skip run validation for step_completed and step_retrying - they only operate
|
|
245
|
+
// on running steps, and running steps are always allowed to modify regardless
|
|
246
|
+
// of run state. This optimization saves database queries per step event.
|
|
247
|
+
let currentRun = null;
|
|
248
|
+
const skipRunValidationEvents = ['step_completed', 'step_retrying'];
|
|
249
|
+
if (data.eventType !== 'run_created' &&
|
|
250
|
+
!skipRunValidationEvents.includes(data.eventType)) {
|
|
251
|
+
// Use prepared statement for better performance
|
|
252
|
+
const [runValue] = await getRunForValidation.execute({
|
|
253
|
+
runId: effectiveRunId,
|
|
254
|
+
});
|
|
255
|
+
currentRun = runValue ?? null;
|
|
256
|
+
}
|
|
257
|
+
// ============================================================
|
|
258
|
+
// VERSION COMPATIBILITY: Check run spec version
|
|
259
|
+
// ============================================================
|
|
260
|
+
// For events that have fetched the run, check version compatibility.
|
|
261
|
+
// Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
|
|
262
|
+
if (currentRun) {
|
|
263
|
+
// Check if run requires a newer world version
|
|
264
|
+
if (requiresNewerWorld(currentRun.specVersion)) {
|
|
265
|
+
throw new RunNotSupportedError(currentRun.specVersion, SPEC_VERSION_CURRENT);
|
|
266
|
+
}
|
|
267
|
+
// Route to legacy handler for pre-event-sourcing runs
|
|
268
|
+
if (isLegacySpecVersion(currentRun.specVersion)) {
|
|
269
|
+
return handleLegacyEventPostgres(drizzle, effectiveRunId, eventId, data, currentRun, params);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Run terminal state validation
|
|
273
|
+
if (currentRun && isRunTerminal(currentRun.status)) {
|
|
274
|
+
const runTerminalEvents = [
|
|
275
|
+
'run_started',
|
|
276
|
+
'run_completed',
|
|
277
|
+
'run_failed',
|
|
278
|
+
];
|
|
279
|
+
// Idempotent operation: run_cancelled on already cancelled run is allowed
|
|
280
|
+
if (data.eventType === 'run_cancelled' &&
|
|
281
|
+
currentRun.status === 'cancelled') {
|
|
282
|
+
// Get full run for return value
|
|
283
|
+
const [fullRun] = await drizzle
|
|
284
|
+
.select()
|
|
285
|
+
.from(Schema.runs)
|
|
286
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
287
|
+
.limit(1);
|
|
288
|
+
// Create the event (still record it)
|
|
289
|
+
const [value] = await drizzle
|
|
290
|
+
.insert(Schema.events)
|
|
291
|
+
.values({
|
|
292
|
+
runId: effectiveRunId,
|
|
293
|
+
eventId,
|
|
294
|
+
correlationId: data.correlationId,
|
|
295
|
+
eventType: data.eventType,
|
|
296
|
+
eventData: 'eventData' in data ? data.eventData : undefined,
|
|
297
|
+
specVersion: effectiveSpecVersion,
|
|
298
|
+
})
|
|
299
|
+
.returning({ createdAt: Schema.events.createdAt });
|
|
300
|
+
const result = { ...data, ...value, runId: effectiveRunId, eventId };
|
|
301
|
+
const parsed = EventSchema.parse(result);
|
|
302
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
303
|
+
return {
|
|
304
|
+
event: filterEventData(parsed, resolveData),
|
|
305
|
+
run: fullRun ? deserializeRunError(compact(fullRun)) : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// Run state transitions are not allowed on terminal runs
|
|
309
|
+
if (runTerminalEvents.includes(data.eventType) ||
|
|
310
|
+
data.eventType === 'run_cancelled') {
|
|
311
|
+
throw new WorkflowAPIError(`Cannot transition run from terminal state "${currentRun.status}"`, { status: 410 });
|
|
312
|
+
}
|
|
313
|
+
// Creating new entities on terminal runs is not allowed
|
|
314
|
+
if (data.eventType === 'step_created' ||
|
|
315
|
+
data.eventType === 'hook_created') {
|
|
316
|
+
throw new WorkflowAPIError(`Cannot create new entities on run in terminal state "${currentRun.status}"`, { status: 410 });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Step-related event validation (ordering and terminal state)
|
|
320
|
+
// Fetch status + startedAt so we can reuse for step_started (avoid double read)
|
|
321
|
+
// Skip validation for step_completed/step_failed - use conditional UPDATE instead
|
|
322
|
+
let validatedStep = null;
|
|
323
|
+
const stepEventsNeedingValidation = ['step_started', 'step_retrying'];
|
|
324
|
+
if (stepEventsNeedingValidation.includes(data.eventType) &&
|
|
325
|
+
data.correlationId) {
|
|
326
|
+
// Use prepared statement for better performance
|
|
327
|
+
const [existingStep] = await getStepForValidation.execute({
|
|
328
|
+
runId: effectiveRunId,
|
|
329
|
+
stepId: data.correlationId,
|
|
330
|
+
});
|
|
331
|
+
validatedStep = existingStep ?? null;
|
|
332
|
+
// Event ordering: step must exist before these events
|
|
333
|
+
if (!validatedStep) {
|
|
334
|
+
throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, {
|
|
335
|
+
status: 404,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// Step terminal state validation
|
|
339
|
+
if (isStepTerminal(validatedStep.status)) {
|
|
340
|
+
throw new WorkflowAPIError(`Cannot modify step in terminal state "${validatedStep.status}"`, { status: 410 });
|
|
341
|
+
}
|
|
342
|
+
// On terminal runs: only allow completing/failing in-progress steps
|
|
343
|
+
if (currentRun && isRunTerminal(currentRun.status)) {
|
|
344
|
+
if (validatedStep.status !== 'running') {
|
|
345
|
+
throw new WorkflowAPIError(`Cannot modify non-running step on run in terminal state "${currentRun.status}"`, { status: 410 });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Hook-related event validation (ordering)
|
|
350
|
+
const hookEventsRequiringExistence = ['hook_disposed', 'hook_received'];
|
|
351
|
+
if (hookEventsRequiringExistence.includes(data.eventType) &&
|
|
352
|
+
data.correlationId) {
|
|
353
|
+
const [existingHook] = await drizzle
|
|
354
|
+
.select({ hookId: Schema.hooks.hookId })
|
|
355
|
+
.from(Schema.hooks)
|
|
356
|
+
.where(eq(Schema.hooks.hookId, data.correlationId))
|
|
357
|
+
.limit(1);
|
|
358
|
+
if (!existingHook) {
|
|
359
|
+
throw new WorkflowAPIError(`Hook "${data.correlationId}" not found`, {
|
|
360
|
+
status: 404,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ============================================================
|
|
365
|
+
// Entity creation/updates based on event type
|
|
366
|
+
// ============================================================
|
|
367
|
+
// Handle run_created event: create the run entity atomically
|
|
368
|
+
if (data.eventType === 'run_created') {
|
|
369
|
+
const eventData = data.eventData;
|
|
370
|
+
const [runValue] = await drizzle
|
|
371
|
+
.insert(Schema.runs)
|
|
372
|
+
.values({
|
|
373
|
+
runId: effectiveRunId,
|
|
374
|
+
deploymentId: eventData.deploymentId,
|
|
375
|
+
workflowName: eventData.workflowName,
|
|
376
|
+
// Propagate specVersion from the event to the run entity
|
|
377
|
+
specVersion: effectiveSpecVersion,
|
|
378
|
+
input: eventData.input,
|
|
379
|
+
executionContext: eventData.executionContext,
|
|
380
|
+
status: 'pending',
|
|
381
|
+
})
|
|
382
|
+
.onConflictDoNothing()
|
|
383
|
+
.returning();
|
|
384
|
+
if (runValue) {
|
|
385
|
+
run = deserializeRunError(compact(runValue));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Handle run_started event: update run status
|
|
389
|
+
if (data.eventType === 'run_started') {
|
|
390
|
+
const [runValue] = await drizzle
|
|
391
|
+
.update(Schema.runs)
|
|
392
|
+
.set({
|
|
393
|
+
status: 'running',
|
|
394
|
+
startedAt: now,
|
|
395
|
+
updatedAt: now,
|
|
396
|
+
})
|
|
397
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
398
|
+
.returning();
|
|
399
|
+
if (runValue) {
|
|
400
|
+
run = deserializeRunError(compact(runValue));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Handle run_completed event: update run status and cleanup hooks
|
|
404
|
+
if (data.eventType === 'run_completed') {
|
|
405
|
+
const eventData = data.eventData;
|
|
406
|
+
const [runValue] = await drizzle
|
|
407
|
+
.update(Schema.runs)
|
|
408
|
+
.set({
|
|
409
|
+
status: 'completed',
|
|
410
|
+
output: eventData.output,
|
|
411
|
+
completedAt: now,
|
|
412
|
+
updatedAt: now,
|
|
413
|
+
})
|
|
414
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
415
|
+
.returning();
|
|
416
|
+
if (runValue) {
|
|
417
|
+
run = deserializeRunError(compact(runValue));
|
|
418
|
+
}
|
|
419
|
+
// Delete all hooks for this run to allow token reuse
|
|
420
|
+
await drizzle
|
|
421
|
+
.delete(Schema.hooks)
|
|
422
|
+
.where(eq(Schema.hooks.runId, effectiveRunId));
|
|
423
|
+
}
|
|
424
|
+
// Handle run_failed event: update run status and cleanup hooks
|
|
425
|
+
if (data.eventType === 'run_failed') {
|
|
426
|
+
const eventData = data.eventData;
|
|
427
|
+
const errorMessage = typeof eventData.error === 'string'
|
|
428
|
+
? eventData.error
|
|
429
|
+
: (eventData.error?.message ?? 'Unknown error');
|
|
430
|
+
const [runValue] = await drizzle
|
|
431
|
+
.update(Schema.runs)
|
|
432
|
+
.set({
|
|
433
|
+
status: 'failed',
|
|
434
|
+
error: {
|
|
435
|
+
message: errorMessage,
|
|
436
|
+
stack: eventData.error?.stack,
|
|
437
|
+
code: eventData.errorCode,
|
|
438
|
+
},
|
|
439
|
+
completedAt: now,
|
|
440
|
+
updatedAt: now,
|
|
441
|
+
})
|
|
442
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
443
|
+
.returning();
|
|
444
|
+
if (runValue) {
|
|
445
|
+
run = deserializeRunError(compact(runValue));
|
|
446
|
+
}
|
|
447
|
+
// Delete all hooks for this run to allow token reuse
|
|
448
|
+
await drizzle
|
|
449
|
+
.delete(Schema.hooks)
|
|
450
|
+
.where(eq(Schema.hooks.runId, effectiveRunId));
|
|
451
|
+
}
|
|
452
|
+
// Handle run_cancelled event: update run status and cleanup hooks
|
|
453
|
+
if (data.eventType === 'run_cancelled') {
|
|
454
|
+
const [runValue] = await drizzle
|
|
455
|
+
.update(Schema.runs)
|
|
456
|
+
.set({
|
|
457
|
+
status: 'cancelled',
|
|
458
|
+
completedAt: now,
|
|
459
|
+
updatedAt: now,
|
|
460
|
+
})
|
|
461
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
462
|
+
.returning();
|
|
463
|
+
if (runValue) {
|
|
464
|
+
run = deserializeRunError(compact(runValue));
|
|
465
|
+
}
|
|
466
|
+
// Delete all hooks for this run to allow token reuse
|
|
467
|
+
await drizzle
|
|
468
|
+
.delete(Schema.hooks)
|
|
469
|
+
.where(eq(Schema.hooks.runId, effectiveRunId));
|
|
470
|
+
}
|
|
471
|
+
// Handle step_created event: create step entity
|
|
472
|
+
if (data.eventType === 'step_created') {
|
|
473
|
+
const eventData = data.eventData;
|
|
474
|
+
const [stepValue] = await drizzle
|
|
475
|
+
.insert(Schema.steps)
|
|
476
|
+
.values({
|
|
477
|
+
runId: effectiveRunId,
|
|
478
|
+
stepId: data.correlationId,
|
|
479
|
+
stepName: eventData.stepName,
|
|
480
|
+
input: eventData.input,
|
|
481
|
+
status: 'pending',
|
|
482
|
+
attempt: 0,
|
|
483
|
+
// Propagate specVersion from the event to the step entity
|
|
484
|
+
specVersion: effectiveSpecVersion,
|
|
485
|
+
})
|
|
486
|
+
.onConflictDoNothing()
|
|
487
|
+
.returning();
|
|
488
|
+
if (stepValue) {
|
|
489
|
+
step = deserializeStepError(compact(stepValue));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Handle step_started event: increment attempt, set status to 'running'
|
|
493
|
+
// Sets startedAt (maps to startedAt) only on first start
|
|
494
|
+
// Reuse validatedStep from validation (already read above)
|
|
495
|
+
if (data.eventType === 'step_started') {
|
|
496
|
+
const isFirstStart = !validatedStep?.startedAt;
|
|
497
|
+
const [stepValue] = await drizzle
|
|
498
|
+
.update(Schema.steps)
|
|
499
|
+
.set({
|
|
500
|
+
status: 'running',
|
|
501
|
+
// Increment attempt counter using SQL
|
|
502
|
+
attempt: sql `${Schema.steps.attempt} + 1`,
|
|
503
|
+
// Only set startedAt on first start (not updated on retries)
|
|
504
|
+
...(isFirstStart ? { startedAt: now } : {}),
|
|
505
|
+
})
|
|
506
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
|
|
507
|
+
.returning();
|
|
508
|
+
if (stepValue) {
|
|
509
|
+
step = deserializeStepError(compact(stepValue));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Handle step_completed event: update step status
|
|
513
|
+
// Uses conditional UPDATE to skip validation query (performance optimization)
|
|
514
|
+
if (data.eventType === 'step_completed') {
|
|
515
|
+
const eventData = data.eventData;
|
|
516
|
+
const [stepValue] = await drizzle
|
|
517
|
+
.update(Schema.steps)
|
|
518
|
+
.set({
|
|
519
|
+
status: 'completed',
|
|
520
|
+
output: eventData.result,
|
|
521
|
+
completedAt: now,
|
|
522
|
+
})
|
|
523
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
|
|
524
|
+
// Only update if not already in terminal state (validation in WHERE clause)
|
|
525
|
+
notInArray(Schema.steps.status, ['completed', 'failed'])))
|
|
526
|
+
.returning();
|
|
527
|
+
if (stepValue) {
|
|
528
|
+
step = deserializeStepError(compact(stepValue));
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
// Step not updated - check if it exists and why
|
|
532
|
+
const [existing] = await getStepForValidation.execute({
|
|
533
|
+
runId: effectiveRunId,
|
|
534
|
+
stepId: data.correlationId,
|
|
535
|
+
});
|
|
536
|
+
if (!existing) {
|
|
537
|
+
throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
|
|
538
|
+
}
|
|
539
|
+
if (['completed', 'failed'].includes(existing.status)) {
|
|
540
|
+
throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 410 });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Handle step_failed event: terminal state with error
|
|
545
|
+
// Uses conditional UPDATE to skip validation query (performance optimization)
|
|
546
|
+
if (data.eventType === 'step_failed') {
|
|
547
|
+
const eventData = data.eventData;
|
|
548
|
+
const errorMessage = typeof eventData.error === 'string'
|
|
549
|
+
? eventData.error
|
|
550
|
+
: (eventData.error?.message ?? 'Unknown error');
|
|
551
|
+
const [stepValue] = await drizzle
|
|
552
|
+
.update(Schema.steps)
|
|
553
|
+
.set({
|
|
554
|
+
status: 'failed',
|
|
555
|
+
error: {
|
|
556
|
+
message: errorMessage,
|
|
557
|
+
stack: eventData.stack,
|
|
558
|
+
},
|
|
559
|
+
completedAt: now,
|
|
560
|
+
})
|
|
561
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
|
|
562
|
+
// Only update if not already in terminal state (validation in WHERE clause)
|
|
563
|
+
notInArray(Schema.steps.status, ['completed', 'failed'])))
|
|
564
|
+
.returning();
|
|
565
|
+
if (stepValue) {
|
|
566
|
+
step = deserializeStepError(compact(stepValue));
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
// Step not updated - check if it exists and why
|
|
570
|
+
const [existing] = await getStepForValidation.execute({
|
|
571
|
+
runId: effectiveRunId,
|
|
572
|
+
stepId: data.correlationId,
|
|
573
|
+
});
|
|
574
|
+
if (!existing) {
|
|
575
|
+
throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
|
|
576
|
+
}
|
|
577
|
+
if (['completed', 'failed'].includes(existing.status)) {
|
|
578
|
+
throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 410 });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Handle step_retrying event: sets status back to 'pending', records error
|
|
583
|
+
if (data.eventType === 'step_retrying') {
|
|
584
|
+
const eventData = data.eventData;
|
|
585
|
+
const errorMessage = typeof eventData.error === 'string'
|
|
586
|
+
? eventData.error
|
|
587
|
+
: (eventData.error?.message ?? 'Unknown error');
|
|
588
|
+
const [stepValue] = await drizzle
|
|
589
|
+
.update(Schema.steps)
|
|
590
|
+
.set({
|
|
591
|
+
status: 'pending',
|
|
592
|
+
error: {
|
|
593
|
+
message: errorMessage,
|
|
594
|
+
stack: eventData.stack,
|
|
595
|
+
},
|
|
596
|
+
retryAfter: eventData.retryAfter,
|
|
597
|
+
})
|
|
598
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
|
|
599
|
+
.returning();
|
|
600
|
+
if (stepValue) {
|
|
601
|
+
step = deserializeStepError(compact(stepValue));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Handle hook_created event: create hook entity
|
|
605
|
+
// Uses prepared statement for token uniqueness check (performance optimization)
|
|
606
|
+
if (data.eventType === 'hook_created') {
|
|
607
|
+
const eventData = data.eventData;
|
|
608
|
+
// Check for duplicate token using prepared statement
|
|
609
|
+
const [existingHook] = await getHookByToken.execute({
|
|
610
|
+
token: eventData.token,
|
|
611
|
+
});
|
|
612
|
+
if (existingHook) {
|
|
613
|
+
// Create hook_conflict event instead of throwing 409
|
|
614
|
+
// This allows the workflow to continue and fail gracefully when the hook is awaited
|
|
615
|
+
const conflictEventData = {
|
|
616
|
+
token: eventData.token,
|
|
617
|
+
};
|
|
618
|
+
const [conflictValue] = await drizzle
|
|
619
|
+
.insert(events)
|
|
620
|
+
.values({
|
|
621
|
+
runId: effectiveRunId,
|
|
622
|
+
eventId,
|
|
623
|
+
correlationId: data.correlationId,
|
|
624
|
+
eventType: 'hook_conflict',
|
|
625
|
+
eventData: conflictEventData,
|
|
626
|
+
specVersion: effectiveSpecVersion,
|
|
627
|
+
})
|
|
628
|
+
.returning({ createdAt: events.createdAt });
|
|
629
|
+
if (!conflictValue) {
|
|
630
|
+
throw new WorkflowAPIError(`Event ${eventId} could not be created`, { status: 409 });
|
|
631
|
+
}
|
|
632
|
+
const conflictResult = {
|
|
633
|
+
eventType: 'hook_conflict',
|
|
634
|
+
correlationId: data.correlationId,
|
|
635
|
+
eventData: conflictEventData,
|
|
636
|
+
...conflictValue,
|
|
637
|
+
runId: effectiveRunId,
|
|
638
|
+
eventId,
|
|
639
|
+
};
|
|
640
|
+
const parsedConflict = EventSchema.parse(conflictResult);
|
|
641
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
642
|
+
return {
|
|
643
|
+
event: filterEventData(parsedConflict, resolveData),
|
|
644
|
+
run,
|
|
645
|
+
step,
|
|
646
|
+
hook: undefined,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const [hookValue] = await drizzle
|
|
650
|
+
.insert(Schema.hooks)
|
|
651
|
+
.values({
|
|
652
|
+
runId: effectiveRunId,
|
|
653
|
+
hookId: data.correlationId,
|
|
654
|
+
token: eventData.token,
|
|
655
|
+
metadata: eventData.metadata,
|
|
656
|
+
ownerId: '', // TODO: get from context
|
|
657
|
+
projectId: '', // TODO: get from context
|
|
658
|
+
environment: '', // TODO: get from context
|
|
659
|
+
// Propagate specVersion from the event to the hook entity
|
|
660
|
+
specVersion: effectiveSpecVersion,
|
|
661
|
+
})
|
|
662
|
+
.onConflictDoNothing()
|
|
663
|
+
.returning();
|
|
664
|
+
if (hookValue) {
|
|
665
|
+
hookValue.metadata ||= hookValue.metadataJson;
|
|
666
|
+
hook = HookSchema.parse(compact(hookValue));
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Handle hook_disposed event: delete hook entity
|
|
670
|
+
if (data.eventType === 'hook_disposed' && data.correlationId) {
|
|
671
|
+
await drizzle
|
|
672
|
+
.delete(Schema.hooks)
|
|
673
|
+
.where(eq(Schema.hooks.hookId, data.correlationId));
|
|
674
|
+
}
|
|
251
675
|
const [value] = await drizzle
|
|
252
676
|
.insert(events)
|
|
253
677
|
.values({
|
|
254
|
-
runId,
|
|
678
|
+
runId: effectiveRunId,
|
|
255
679
|
eventId,
|
|
256
680
|
correlationId: data.correlationId,
|
|
257
681
|
eventType: data.eventType,
|
|
258
682
|
eventData: 'eventData' in data ? data.eventData : undefined,
|
|
683
|
+
specVersion: effectiveSpecVersion,
|
|
259
684
|
})
|
|
260
685
|
.returning({ createdAt: events.createdAt });
|
|
261
686
|
if (!value) {
|
|
@@ -263,10 +688,10 @@ export function createEventsStorage(drizzle) {
|
|
|
263
688
|
status: 409,
|
|
264
689
|
});
|
|
265
690
|
}
|
|
266
|
-
const result = { ...data, ...value, runId, eventId };
|
|
691
|
+
const result = { ...data, ...value, runId: effectiveRunId, eventId };
|
|
267
692
|
const parsed = EventSchema.parse(result);
|
|
268
693
|
const resolveData = params?.resolveData ?? 'all';
|
|
269
|
-
return filterEventData(parsed, resolveData);
|
|
694
|
+
return { event: filterEventData(parsed, resolveData), run, step, hook };
|
|
270
695
|
},
|
|
271
696
|
async list(params) {
|
|
272
697
|
const limit = params?.pagination?.limit ?? 100;
|
|
@@ -338,30 +763,6 @@ export function createHooksStorage(drizzle) {
|
|
|
338
763
|
const resolveData = params?.resolveData ?? 'all';
|
|
339
764
|
return filterHookData(parsed, resolveData);
|
|
340
765
|
},
|
|
341
|
-
async create(runId, data, params) {
|
|
342
|
-
const [value] = await drizzle
|
|
343
|
-
.insert(hooks)
|
|
344
|
-
.values({
|
|
345
|
-
runId,
|
|
346
|
-
hookId: data.hookId,
|
|
347
|
-
token: data.token,
|
|
348
|
-
metadata: data.metadata,
|
|
349
|
-
ownerId: '', // TODO: get from context
|
|
350
|
-
projectId: '', // TODO: get from context
|
|
351
|
-
environment: '', // TODO: get from context
|
|
352
|
-
})
|
|
353
|
-
.onConflictDoNothing()
|
|
354
|
-
.returning();
|
|
355
|
-
if (!value) {
|
|
356
|
-
throw new WorkflowAPIError(`Hook ${data.hookId} already exists`, {
|
|
357
|
-
status: 409,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
value.metadata ||= value.metadataJson;
|
|
361
|
-
const parsed = HookSchema.parse(compact(value));
|
|
362
|
-
const resolveData = params?.resolveData ?? 'all';
|
|
363
|
-
return filterHookData(parsed, resolveData);
|
|
364
|
-
},
|
|
365
766
|
async getByToken(token, params) {
|
|
366
767
|
const [value] = await getByToken.execute({ token });
|
|
367
768
|
if (!value) {
|
|
@@ -396,46 +797,12 @@ export function createHooksStorage(drizzle) {
|
|
|
396
797
|
hasMore,
|
|
397
798
|
};
|
|
398
799
|
},
|
|
399
|
-
async dispose(hookId, params) {
|
|
400
|
-
const [value] = await drizzle
|
|
401
|
-
.delete(hooks)
|
|
402
|
-
.where(eq(hooks.hookId, hookId))
|
|
403
|
-
.returning();
|
|
404
|
-
if (!value) {
|
|
405
|
-
throw new WorkflowAPIError(`Hook not found: ${hookId}`, {
|
|
406
|
-
status: 404,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
const parsed = HookSchema.parse(compact(value));
|
|
410
|
-
const resolveData = params?.resolveData ?? 'all';
|
|
411
|
-
return filterHookData(parsed, resolveData);
|
|
412
|
-
},
|
|
413
800
|
};
|
|
414
801
|
}
|
|
415
802
|
export function createStepsStorage(drizzle) {
|
|
416
803
|
const { steps } = Schema;
|
|
417
804
|
return {
|
|
418
|
-
async
|
|
419
|
-
const [value] = await drizzle
|
|
420
|
-
.insert(steps)
|
|
421
|
-
.values({
|
|
422
|
-
runId,
|
|
423
|
-
stepId: data.stepId,
|
|
424
|
-
stepName: data.stepName,
|
|
425
|
-
input: data.input,
|
|
426
|
-
status: 'pending',
|
|
427
|
-
attempt: 0,
|
|
428
|
-
})
|
|
429
|
-
.onConflictDoNothing()
|
|
430
|
-
.returning();
|
|
431
|
-
if (!value) {
|
|
432
|
-
throw new WorkflowAPIError(`Step ${data.stepId} already exists`, {
|
|
433
|
-
status: 409,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
return deserializeStepError(compact(value));
|
|
437
|
-
},
|
|
438
|
-
async get(runId, stepId, params) {
|
|
805
|
+
get: (async (runId, stepId, params) => {
|
|
439
806
|
// If runId is not provided, query only by stepId
|
|
440
807
|
const whereClause = runId
|
|
441
808
|
? and(eq(steps.stepId, stepId), eq(steps.runId, runId))
|
|
@@ -451,50 +818,14 @@ export function createStepsStorage(drizzle) {
|
|
|
451
818
|
});
|
|
452
819
|
}
|
|
453
820
|
value.output ||= value.outputJson;
|
|
821
|
+
value.input ||= value.inputJson;
|
|
822
|
+
value.error ||= parseErrorJson(value.errorJson);
|
|
454
823
|
const deserialized = deserializeStepError(compact(value));
|
|
455
824
|
const parsed = StepSchema.parse(deserialized);
|
|
456
825
|
const resolveData = params?.resolveData ?? 'all';
|
|
457
826
|
return filterStepData(parsed, resolveData);
|
|
458
|
-
},
|
|
459
|
-
async
|
|
460
|
-
// Fetch current step to check if startedAt is already set
|
|
461
|
-
const [currentStep] = await drizzle
|
|
462
|
-
.select()
|
|
463
|
-
.from(steps)
|
|
464
|
-
.where(and(eq(steps.stepId, stepId), eq(steps.runId, runId)))
|
|
465
|
-
.limit(1);
|
|
466
|
-
if (!currentStep) {
|
|
467
|
-
throw new WorkflowAPIError(`Step not found: ${stepId}`, {
|
|
468
|
-
status: 404,
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
// Serialize the error field if present
|
|
472
|
-
const serialized = serializeStepError(data);
|
|
473
|
-
const updates = {
|
|
474
|
-
...serialized,
|
|
475
|
-
output: data.output,
|
|
476
|
-
};
|
|
477
|
-
const now = new Date();
|
|
478
|
-
// Only set startedAt the first time the step transitions to 'running'
|
|
479
|
-
if (data.status === 'running' && !currentStep.startedAt) {
|
|
480
|
-
updates.startedAt = now;
|
|
481
|
-
}
|
|
482
|
-
if (data.status === 'completed' || data.status === 'failed') {
|
|
483
|
-
updates.completedAt = now;
|
|
484
|
-
}
|
|
485
|
-
const [value] = await drizzle
|
|
486
|
-
.update(steps)
|
|
487
|
-
.set(updates)
|
|
488
|
-
.where(and(eq(steps.stepId, stepId), eq(steps.runId, runId)))
|
|
489
|
-
.returning();
|
|
490
|
-
if (!value) {
|
|
491
|
-
throw new WorkflowAPIError(`Step not found: ${stepId}`, {
|
|
492
|
-
status: 404,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
return deserializeStepError(compact(value));
|
|
496
|
-
},
|
|
497
|
-
async list(params) {
|
|
827
|
+
}),
|
|
828
|
+
list: (async (params) => {
|
|
498
829
|
const limit = params?.pagination?.limit ?? 20;
|
|
499
830
|
const fromCursor = params?.pagination?.cursor;
|
|
500
831
|
const all = await drizzle
|
|
@@ -508,6 +839,9 @@ export function createStepsStorage(drizzle) {
|
|
|
508
839
|
const resolveData = params?.resolveData ?? 'all';
|
|
509
840
|
return {
|
|
510
841
|
data: values.map((v) => {
|
|
842
|
+
v.output ||= v.outputJson;
|
|
843
|
+
v.input ||= v.inputJson;
|
|
844
|
+
v.error ||= parseErrorJson(v.errorJson);
|
|
511
845
|
const deserialized = deserializeStepError(compact(v));
|
|
512
846
|
const parsed = StepSchema.parse(deserialized);
|
|
513
847
|
return filterStepData(parsed, resolveData);
|
|
@@ -515,20 +849,20 @@ export function createStepsStorage(drizzle) {
|
|
|
515
849
|
hasMore,
|
|
516
850
|
cursor: values.at(-1)?.stepId ?? null,
|
|
517
851
|
};
|
|
518
|
-
},
|
|
852
|
+
}),
|
|
519
853
|
};
|
|
520
854
|
}
|
|
521
855
|
function filterStepData(step, resolveData) {
|
|
522
856
|
if (resolveData === 'none') {
|
|
523
857
|
const { input: _, output: __, ...rest } = step;
|
|
524
|
-
return { input:
|
|
858
|
+
return { input: undefined, output: undefined, ...rest };
|
|
525
859
|
}
|
|
526
860
|
return step;
|
|
527
861
|
}
|
|
528
862
|
function filterRunData(run, resolveData) {
|
|
529
863
|
if (resolveData === 'none') {
|
|
530
864
|
const { input: _, output: __, ...rest } = run;
|
|
531
|
-
return { input:
|
|
865
|
+
return { input: undefined, output: undefined, ...rest };
|
|
532
866
|
}
|
|
533
867
|
return run;
|
|
534
868
|
}
|