@spfn/workflow 0.1.0-alpha.2
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/LICENSE +21 -0
- package/README.md +702 -0
- package/dist/entities/workflow-execution.d.ts +169 -0
- package/dist/entities/workflow-execution.js +48 -0
- package/dist/entities/workflow-execution.js.map +1 -0
- package/dist/entities/workflow-step-execution.d.ts +203 -0
- package/dist/entities/workflow-step-execution.js +94 -0
- package/dist/entities/workflow-step-execution.js.map +1 -0
- package/dist/index.d.ts +545 -0
- package/dist/index.js +824 -0
- package/dist/index.js.map +1 -0
- package/dist/status-JJY5KGcN.d.ts +10 -0
- package/migrations/0000_even_thunderbolt_ross.sql +36 -0
- package/migrations/meta/0000_snapshot.json +308 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
import { eq, and, desc, inArray } from 'drizzle-orm';
|
|
2
|
+
import { Value } from '@sinclair/typebox/value';
|
|
3
|
+
import { createSchema, timestamps } from '@spfn/core/db';
|
|
4
|
+
import { timestamp, text, integer, jsonb, index } from 'drizzle-orm/pg-core';
|
|
5
|
+
|
|
6
|
+
// src/builder/workflow-builder.ts
|
|
7
|
+
var WorkflowBuilder = class _WorkflowBuilder {
|
|
8
|
+
_name;
|
|
9
|
+
_inputSchema;
|
|
10
|
+
_steps = [];
|
|
11
|
+
_resumable = false;
|
|
12
|
+
_rollbackEnabled = true;
|
|
13
|
+
_notifyConfigs = [];
|
|
14
|
+
constructor(name) {
|
|
15
|
+
this._name = name;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Define input schema
|
|
19
|
+
*/
|
|
20
|
+
input(schema) {
|
|
21
|
+
const builder = new _WorkflowBuilder(this._name);
|
|
22
|
+
builder._inputSchema = schema;
|
|
23
|
+
builder._steps = this._steps;
|
|
24
|
+
builder._resumable = this._resumable;
|
|
25
|
+
builder._rollbackEnabled = this._rollbackEnabled;
|
|
26
|
+
builder._notifyConfigs = [...this._notifyConfigs];
|
|
27
|
+
return builder;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Add a sequential step
|
|
31
|
+
*/
|
|
32
|
+
pipe(job, mapper) {
|
|
33
|
+
const stepName = job.name;
|
|
34
|
+
const step = {
|
|
35
|
+
name: stepName,
|
|
36
|
+
job,
|
|
37
|
+
mapper,
|
|
38
|
+
type: "sequential"
|
|
39
|
+
};
|
|
40
|
+
const builder = new _WorkflowBuilder(this._name);
|
|
41
|
+
builder._inputSchema = this._inputSchema;
|
|
42
|
+
builder._steps = [...this._steps, step];
|
|
43
|
+
builder._resumable = this._resumable;
|
|
44
|
+
builder._rollbackEnabled = this._rollbackEnabled;
|
|
45
|
+
builder._notifyConfigs = [...this._notifyConfigs];
|
|
46
|
+
return builder;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Add parallel steps
|
|
50
|
+
*/
|
|
51
|
+
parallel(steps) {
|
|
52
|
+
const parallelGroup = `parallel_${this._steps.length}`;
|
|
53
|
+
const parallelSteps = [];
|
|
54
|
+
for (const [name, [job, mapper]] of Object.entries(steps)) {
|
|
55
|
+
parallelSteps.push({
|
|
56
|
+
name,
|
|
57
|
+
job,
|
|
58
|
+
mapper,
|
|
59
|
+
type: "parallel",
|
|
60
|
+
parallelGroup
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const builder = new _WorkflowBuilder(this._name);
|
|
64
|
+
builder._inputSchema = this._inputSchema;
|
|
65
|
+
builder._steps = [...this._steps, ...parallelSteps];
|
|
66
|
+
builder._resumable = this._resumable;
|
|
67
|
+
builder._rollbackEnabled = this._rollbackEnabled;
|
|
68
|
+
builder._notifyConfigs = [...this._notifyConfigs];
|
|
69
|
+
return builder;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Enable/disable resumable (restart from failure point)
|
|
73
|
+
*/
|
|
74
|
+
resumable(enabled = true) {
|
|
75
|
+
this._resumable = enabled;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Enable/disable rollback on failure
|
|
80
|
+
*/
|
|
81
|
+
rollback(enabled = true) {
|
|
82
|
+
this._rollbackEnabled = enabled;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Configure notifications (chainable — each call adds a separate config)
|
|
87
|
+
*/
|
|
88
|
+
notify(config) {
|
|
89
|
+
this._notifyConfigs.push(config);
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build the workflow definition
|
|
94
|
+
*/
|
|
95
|
+
build() {
|
|
96
|
+
return {
|
|
97
|
+
name: this._name,
|
|
98
|
+
inputSchema: this._inputSchema,
|
|
99
|
+
steps: this._steps,
|
|
100
|
+
resumable: this._resumable,
|
|
101
|
+
rollbackEnabled: this._rollbackEnabled,
|
|
102
|
+
notifyConfigs: this._notifyConfigs,
|
|
103
|
+
_input: void 0
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
function workflow(name) {
|
|
108
|
+
return new WorkflowBuilder(name);
|
|
109
|
+
}
|
|
110
|
+
var workflowSchema = createSchema("@spfn/workflow");
|
|
111
|
+
var workflowExecutions = workflowSchema.table(
|
|
112
|
+
"executions",
|
|
113
|
+
{
|
|
114
|
+
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
115
|
+
/**
|
|
116
|
+
* Workflow name (identifier)
|
|
117
|
+
*/
|
|
118
|
+
workflowName: text("workflow_name").notNull(),
|
|
119
|
+
/**
|
|
120
|
+
* Execution status
|
|
121
|
+
*/
|
|
122
|
+
status: text("status").$type().notNull().default("pending"),
|
|
123
|
+
/**
|
|
124
|
+
* Input data (JSON)
|
|
125
|
+
*/
|
|
126
|
+
input: jsonb("input"),
|
|
127
|
+
/**
|
|
128
|
+
* Current step index
|
|
129
|
+
*/
|
|
130
|
+
currentStep: integer("current_step").notNull().default(0),
|
|
131
|
+
/**
|
|
132
|
+
* Error message (if failed)
|
|
133
|
+
*/
|
|
134
|
+
error: text("error"),
|
|
135
|
+
/**
|
|
136
|
+
* Completed timestamp
|
|
137
|
+
*/
|
|
138
|
+
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
139
|
+
...timestamps()
|
|
140
|
+
},
|
|
141
|
+
(table) => [
|
|
142
|
+
index("wf_exec_workflow_name_idx").on(table.workflowName),
|
|
143
|
+
index("wf_exec_status_idx").on(table.status),
|
|
144
|
+
index("wf_exec_created_at_idx").on(table.createdAt),
|
|
145
|
+
index("wf_exec_workflow_status_idx").on(table.workflowName, table.status)
|
|
146
|
+
]
|
|
147
|
+
);
|
|
148
|
+
var workflowStepExecutions = workflowSchema.table(
|
|
149
|
+
"step_executions",
|
|
150
|
+
{
|
|
151
|
+
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
152
|
+
/**
|
|
153
|
+
* Parent workflow execution ID
|
|
154
|
+
*/
|
|
155
|
+
executionId: text("execution_id").notNull().references(() => workflowExecutions.id, { onDelete: "cascade" }),
|
|
156
|
+
/**
|
|
157
|
+
* Step name (job name)
|
|
158
|
+
*/
|
|
159
|
+
stepName: text("step_name").notNull(),
|
|
160
|
+
/**
|
|
161
|
+
* Step index in the workflow
|
|
162
|
+
*/
|
|
163
|
+
stepIndex: integer("step_index").notNull(),
|
|
164
|
+
/**
|
|
165
|
+
* Step execution status
|
|
166
|
+
*/
|
|
167
|
+
status: text("status").$type().notNull().default("pending"),
|
|
168
|
+
/**
|
|
169
|
+
* Step output data (JSON or URL reference for large data)
|
|
170
|
+
*/
|
|
171
|
+
output: jsonb("output"),
|
|
172
|
+
/**
|
|
173
|
+
* Error message (if failed)
|
|
174
|
+
*/
|
|
175
|
+
error: text("error"),
|
|
176
|
+
/**
|
|
177
|
+
* Started timestamp
|
|
178
|
+
*/
|
|
179
|
+
startedAt: timestamp("started_at", { withTimezone: true }),
|
|
180
|
+
/**
|
|
181
|
+
* Completed timestamp
|
|
182
|
+
*/
|
|
183
|
+
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
184
|
+
...timestamps()
|
|
185
|
+
},
|
|
186
|
+
(table) => [
|
|
187
|
+
index("wf_step_exec_execution_id_idx").on(table.executionId),
|
|
188
|
+
index("wf_step_exec_status_idx").on(table.status),
|
|
189
|
+
index("wf_step_exec_exec_step_idx").on(table.executionId, table.stepIndex)
|
|
190
|
+
]
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// src/engine/types.ts
|
|
194
|
+
var defaultLogger = {
|
|
195
|
+
info: (message, ...args) => console.log(message, ...args),
|
|
196
|
+
error: (message, ...args) => console.error(message, ...args),
|
|
197
|
+
warn: (message, ...args) => console.warn(message, ...args),
|
|
198
|
+
debug: (message, ...args) => console.debug(message, ...args)
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// src/engine/workflow-engine.ts
|
|
202
|
+
var DEFAULT_LARGE_OUTPUT_THRESHOLD = 1024 * 1024;
|
|
203
|
+
var WorkflowEngineImpl = class {
|
|
204
|
+
config;
|
|
205
|
+
workflows;
|
|
206
|
+
subscribers;
|
|
207
|
+
logger;
|
|
208
|
+
/** Track executions currently being processed to prevent race conditions */
|
|
209
|
+
processingExecutions;
|
|
210
|
+
constructor(workflows, config) {
|
|
211
|
+
this.config = config;
|
|
212
|
+
this.workflows = /* @__PURE__ */ new Map();
|
|
213
|
+
this.subscribers = /* @__PURE__ */ new Map();
|
|
214
|
+
this.logger = config.logger ?? defaultLogger;
|
|
215
|
+
this.processingExecutions = /* @__PURE__ */ new Set();
|
|
216
|
+
for (const wf of workflows) {
|
|
217
|
+
this.workflows.set(wf.name, wf);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Get database instance
|
|
222
|
+
*/
|
|
223
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
224
|
+
get db() {
|
|
225
|
+
return this.config.db;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Start a workflow execution
|
|
229
|
+
*/
|
|
230
|
+
async start(name, input) {
|
|
231
|
+
const workflow2 = this.workflows.get(name);
|
|
232
|
+
if (!workflow2) {
|
|
233
|
+
throw new Error(`Workflow '${name}' not found`);
|
|
234
|
+
}
|
|
235
|
+
if (this.config.validateInput !== false && workflow2.inputSchema) {
|
|
236
|
+
if (!Value.Check(workflow2.inputSchema, input)) {
|
|
237
|
+
const errors = [...Value.Errors(workflow2.inputSchema, input)];
|
|
238
|
+
const errorMessages = errors.map((e) => `${e.path}: ${e.message}`).join(", ");
|
|
239
|
+
throw new Error(`Invalid workflow input: ${errorMessages}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const execution = await this.db.transaction(async (tx) => {
|
|
243
|
+
const [exec] = await tx.insert(workflowExecutions).values({
|
|
244
|
+
workflowName: name,
|
|
245
|
+
status: "pending",
|
|
246
|
+
input,
|
|
247
|
+
currentStep: 0
|
|
248
|
+
}).returning();
|
|
249
|
+
const stepRecords = workflow2.steps.map((step, index3) => ({
|
|
250
|
+
executionId: exec.id,
|
|
251
|
+
stepName: step.name,
|
|
252
|
+
stepIndex: index3,
|
|
253
|
+
status: "pending"
|
|
254
|
+
}));
|
|
255
|
+
if (stepRecords.length > 0) {
|
|
256
|
+
await tx.insert(workflowStepExecutions).values(stepRecords);
|
|
257
|
+
}
|
|
258
|
+
return exec;
|
|
259
|
+
});
|
|
260
|
+
this.emitEvent({
|
|
261
|
+
type: "started",
|
|
262
|
+
workflowName: name,
|
|
263
|
+
executionId: execution.id,
|
|
264
|
+
input,
|
|
265
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
266
|
+
});
|
|
267
|
+
this.executeNextStep(execution.id, workflow2, input).catch((error) => {
|
|
268
|
+
this.logger.error(`[Workflow:${name}] Execution error:`, error);
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
id: execution.id,
|
|
272
|
+
workflowName: name,
|
|
273
|
+
status: "pending"
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Execute steps iteratively until completion or failure
|
|
278
|
+
*/
|
|
279
|
+
async executeNextStep(executionId, workflow2, input) {
|
|
280
|
+
while (true) {
|
|
281
|
+
const execution = await this.getExecution(executionId);
|
|
282
|
+
if (!execution || execution.status !== "pending" && execution.status !== "running") {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (execution.status === "pending") {
|
|
286
|
+
await this.updateExecutionStatus(executionId, "running");
|
|
287
|
+
}
|
|
288
|
+
const results = await this.getCompletedResults(executionId);
|
|
289
|
+
const pendingSteps = execution.steps.filter((s) => s.status === "pending");
|
|
290
|
+
if (pendingSteps.length === 0) {
|
|
291
|
+
await this.completeExecution(executionId);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const currentStepIndex = Math.min(...pendingSteps.map((s) => s.stepIndex));
|
|
295
|
+
await this.db.update(workflowExecutions).set({ currentStep: currentStepIndex, updatedAt: /* @__PURE__ */ new Date() }).where(eq(workflowExecutions.id, executionId));
|
|
296
|
+
const stepDef = workflow2.steps[currentStepIndex];
|
|
297
|
+
if (stepDef.type === "parallel") {
|
|
298
|
+
const parallelSteps = workflow2.steps.filter(
|
|
299
|
+
(s) => s.parallelGroup === stepDef.parallelGroup
|
|
300
|
+
);
|
|
301
|
+
const errors = await Promise.all(
|
|
302
|
+
parallelSteps.map(
|
|
303
|
+
(step) => this.executeStep(executionId, workflow2, step, input, results)
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
const firstError = errors.find((e) => e !== null);
|
|
307
|
+
if (firstError) {
|
|
308
|
+
await this.handleStepFailure(executionId, workflow2, firstError);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
const error = await this.executeStep(executionId, workflow2, stepDef, input, results);
|
|
313
|
+
if (error) {
|
|
314
|
+
await this.handleStepFailure(executionId, workflow2, error);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Execute a single step
|
|
322
|
+
*
|
|
323
|
+
* Returns the error message if the step failed, or null if successful.
|
|
324
|
+
*/
|
|
325
|
+
async executeStep(executionId, workflow2, step, input, results) {
|
|
326
|
+
const stepExecution = await this.getStepExecution(executionId, step.name);
|
|
327
|
+
if (!stepExecution || stepExecution.status !== "pending") {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
await this.updateStepStatus(stepExecution.id, "running");
|
|
331
|
+
this.emitEvent({
|
|
332
|
+
type: "step.started",
|
|
333
|
+
workflowName: workflow2.name,
|
|
334
|
+
executionId,
|
|
335
|
+
stepName: step.name,
|
|
336
|
+
stepIndex: stepExecution.stepIndex,
|
|
337
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
338
|
+
});
|
|
339
|
+
try {
|
|
340
|
+
const context = {
|
|
341
|
+
input,
|
|
342
|
+
results,
|
|
343
|
+
execution: {
|
|
344
|
+
id: executionId,
|
|
345
|
+
workflowName: workflow2.name,
|
|
346
|
+
startedAt: /* @__PURE__ */ new Date()
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const stepInput = step.mapper(context);
|
|
350
|
+
const output = await step.job.run(stepInput);
|
|
351
|
+
const storedOutput = await this.storeOutput(output);
|
|
352
|
+
await this.updateStepStatus(stepExecution.id, "completed", storedOutput);
|
|
353
|
+
this.emitEvent({
|
|
354
|
+
type: "step.completed",
|
|
355
|
+
workflowName: workflow2.name,
|
|
356
|
+
executionId,
|
|
357
|
+
stepName: step.name,
|
|
358
|
+
stepIndex: stepExecution.stepIndex,
|
|
359
|
+
output: storedOutput,
|
|
360
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
361
|
+
});
|
|
362
|
+
return null;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
365
|
+
await this.updateStepStatus(stepExecution.id, "failed", void 0, errorMessage);
|
|
366
|
+
this.emitEvent({
|
|
367
|
+
type: "step.failed",
|
|
368
|
+
workflowName: workflow2.name,
|
|
369
|
+
executionId,
|
|
370
|
+
stepName: step.name,
|
|
371
|
+
stepIndex: stepExecution.stepIndex,
|
|
372
|
+
error: errorMessage,
|
|
373
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
374
|
+
});
|
|
375
|
+
return errorMessage;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Handle step failure - rollback if enabled
|
|
380
|
+
*/
|
|
381
|
+
async handleStepFailure(executionId, workflow2, error) {
|
|
382
|
+
await this.db.update(workflowExecutions).set({
|
|
383
|
+
status: "failed",
|
|
384
|
+
error,
|
|
385
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
386
|
+
}).where(eq(workflowExecutions.id, executionId));
|
|
387
|
+
this.emitEvent({
|
|
388
|
+
type: "failed",
|
|
389
|
+
workflowName: workflow2.name,
|
|
390
|
+
executionId,
|
|
391
|
+
error,
|
|
392
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
393
|
+
});
|
|
394
|
+
if (workflow2.rollbackEnabled) {
|
|
395
|
+
await this.executeRollback(executionId, workflow2);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Execute rollback in reverse order
|
|
400
|
+
*/
|
|
401
|
+
async executeRollback(executionId, workflow2) {
|
|
402
|
+
await this.updateExecutionStatus(executionId, "compensating");
|
|
403
|
+
const execution = await this.getExecution(executionId);
|
|
404
|
+
if (!execution) return;
|
|
405
|
+
const completedSteps = execution.steps.filter((s) => s.status === "completed").sort((a, b) => b.stepIndex - a.stepIndex);
|
|
406
|
+
for (const stepExecution of completedSteps) {
|
|
407
|
+
const stepDef = workflow2.steps.find((s) => s.name === stepExecution.stepName);
|
|
408
|
+
if (!stepDef || !stepDef.job.compensate) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const input = execution.input;
|
|
413
|
+
const output = await this.resolveOutput(stepExecution.output);
|
|
414
|
+
await stepDef.job.compensate(input, output);
|
|
415
|
+
await this.updateStepStatus(stepExecution.id, "compensated");
|
|
416
|
+
} catch (compensateError) {
|
|
417
|
+
this.logger.error(
|
|
418
|
+
`[Workflow:${workflow2.name}] Compensate error for step ${stepExecution.stepName}:`,
|
|
419
|
+
compensateError
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
await this.updateExecutionStatus(executionId, "compensated");
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Complete the workflow execution
|
|
427
|
+
*/
|
|
428
|
+
async completeExecution(executionId) {
|
|
429
|
+
const execution = await this.getExecution(executionId);
|
|
430
|
+
if (!execution) return;
|
|
431
|
+
await this.db.update(workflowExecutions).set({
|
|
432
|
+
status: "completed",
|
|
433
|
+
completedAt: /* @__PURE__ */ new Date(),
|
|
434
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
435
|
+
}).where(eq(workflowExecutions.id, executionId));
|
|
436
|
+
this.emitEvent({
|
|
437
|
+
type: "completed",
|
|
438
|
+
workflowName: execution.workflowName,
|
|
439
|
+
executionId,
|
|
440
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Get execution with steps
|
|
445
|
+
*/
|
|
446
|
+
async getExecution(executionId) {
|
|
447
|
+
const [execution] = await this.db.select().from(workflowExecutions).where(eq(workflowExecutions.id, executionId));
|
|
448
|
+
if (!execution) return null;
|
|
449
|
+
const steps = await this.db.select().from(workflowStepExecutions).where(eq(workflowStepExecutions.executionId, executionId)).orderBy(workflowStepExecutions.stepIndex);
|
|
450
|
+
return { ...execution, steps };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Get step execution
|
|
454
|
+
*/
|
|
455
|
+
async getStepExecution(executionId, stepName) {
|
|
456
|
+
const [step] = await this.db.select().from(workflowStepExecutions).where(
|
|
457
|
+
and(
|
|
458
|
+
eq(workflowStepExecutions.executionId, executionId),
|
|
459
|
+
eq(workflowStepExecutions.stepName, stepName)
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
return step || null;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Get completed step results
|
|
466
|
+
*/
|
|
467
|
+
async getCompletedResults(executionId) {
|
|
468
|
+
const steps = await this.db.select().from(workflowStepExecutions).where(
|
|
469
|
+
and(
|
|
470
|
+
eq(workflowStepExecutions.executionId, executionId),
|
|
471
|
+
eq(workflowStepExecutions.status, "completed")
|
|
472
|
+
)
|
|
473
|
+
);
|
|
474
|
+
const results = {};
|
|
475
|
+
for (const step of steps) {
|
|
476
|
+
results[step.stepName] = await this.resolveOutput(step.output);
|
|
477
|
+
}
|
|
478
|
+
return results;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Update execution status
|
|
482
|
+
*/
|
|
483
|
+
async updateExecutionStatus(executionId, status) {
|
|
484
|
+
await this.db.update(workflowExecutions).set({
|
|
485
|
+
status,
|
|
486
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
487
|
+
}).where(eq(workflowExecutions.id, executionId));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Update step status
|
|
491
|
+
*/
|
|
492
|
+
async updateStepStatus(stepId, status, output, error) {
|
|
493
|
+
const updates = {
|
|
494
|
+
status,
|
|
495
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
496
|
+
};
|
|
497
|
+
if (status === "running") {
|
|
498
|
+
updates.startedAt = /* @__PURE__ */ new Date();
|
|
499
|
+
}
|
|
500
|
+
if (status === "completed" || status === "failed") {
|
|
501
|
+
updates.completedAt = /* @__PURE__ */ new Date();
|
|
502
|
+
}
|
|
503
|
+
if (output !== void 0) {
|
|
504
|
+
updates.output = output;
|
|
505
|
+
}
|
|
506
|
+
if (error !== void 0) {
|
|
507
|
+
updates.error = error;
|
|
508
|
+
}
|
|
509
|
+
await this.db.update(workflowStepExecutions).set(updates).where(eq(workflowStepExecutions.id, stepId));
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Store output (handle large data)
|
|
513
|
+
*/
|
|
514
|
+
async storeOutput(output) {
|
|
515
|
+
if (!output || !this.config.storage) return output;
|
|
516
|
+
const json = JSON.stringify(output);
|
|
517
|
+
const threshold = this.config.largeOutputThreshold ?? DEFAULT_LARGE_OUTPUT_THRESHOLD;
|
|
518
|
+
if (Buffer.byteLength(json, "utf8") > threshold) {
|
|
519
|
+
const url = await this.config.storage.upload(output);
|
|
520
|
+
return { $ref: url };
|
|
521
|
+
}
|
|
522
|
+
return output;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Resolve output (fetch from storage if needed)
|
|
526
|
+
*/
|
|
527
|
+
async resolveOutput(output) {
|
|
528
|
+
if (!output) return output;
|
|
529
|
+
const ref = output;
|
|
530
|
+
if (ref.$ref && this.config.storage) {
|
|
531
|
+
return await this.config.storage.download(ref.$ref);
|
|
532
|
+
}
|
|
533
|
+
return output;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Emit event to subscribers and notification providers
|
|
537
|
+
*/
|
|
538
|
+
emitEvent(event) {
|
|
539
|
+
const subscribers = this.subscribers.get(event.executionId);
|
|
540
|
+
if (subscribers) {
|
|
541
|
+
for (const callback of subscribers) {
|
|
542
|
+
try {
|
|
543
|
+
callback(event);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
this.logger.error("[WorkflowEngine] Subscriber error:", error);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const terminalEvents = ["completed", "failed", "cancelled"];
|
|
549
|
+
if (terminalEvents.includes(event.type)) {
|
|
550
|
+
this.subscribers.delete(event.executionId);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
this.sendNotifications(event).catch((error) => {
|
|
554
|
+
this.logger.error("[WorkflowEngine] Notification error:", error);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Send notifications based on workflow config
|
|
559
|
+
*/
|
|
560
|
+
async sendNotifications(event) {
|
|
561
|
+
const workflow2 = this.workflows.get(event.workflowName);
|
|
562
|
+
if (!workflow2 || workflow2.notifyConfigs.length === 0) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
for (const { on, when, providers } of workflow2.notifyConfigs) {
|
|
566
|
+
if (!on.includes(event.type)) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (when && !when(event)) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
await Promise.all(
|
|
573
|
+
providers.map(async (provider) => {
|
|
574
|
+
try {
|
|
575
|
+
await provider.notify(event);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
this.logger.error(
|
|
578
|
+
`[WorkflowEngine] Notification provider '${provider.name}' error:`,
|
|
579
|
+
error
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
})
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Public API methods
|
|
587
|
+
async get(executionId) {
|
|
588
|
+
return this.getExecution(executionId);
|
|
589
|
+
}
|
|
590
|
+
async getStepOutput(executionId, stepName) {
|
|
591
|
+
const step = await this.getStepExecution(executionId, stepName);
|
|
592
|
+
if (!step) return null;
|
|
593
|
+
return this.resolveOutput(step.output);
|
|
594
|
+
}
|
|
595
|
+
async list(options) {
|
|
596
|
+
const conditions = [];
|
|
597
|
+
if (options?.workflowName) {
|
|
598
|
+
conditions.push(eq(workflowExecutions.workflowName, options.workflowName));
|
|
599
|
+
}
|
|
600
|
+
if (options?.status) {
|
|
601
|
+
conditions.push(eq(workflowExecutions.status, options.status));
|
|
602
|
+
}
|
|
603
|
+
let query = this.db.select().from(workflowExecutions).orderBy(desc(workflowExecutions.createdAt));
|
|
604
|
+
if (conditions.length > 0) {
|
|
605
|
+
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
|
606
|
+
}
|
|
607
|
+
if (options?.limit) {
|
|
608
|
+
query = query.limit(options.limit);
|
|
609
|
+
}
|
|
610
|
+
if (options?.offset) {
|
|
611
|
+
query = query.offset(options.offset);
|
|
612
|
+
}
|
|
613
|
+
const executions = await query;
|
|
614
|
+
if (executions.length === 0) {
|
|
615
|
+
return [];
|
|
616
|
+
}
|
|
617
|
+
const executionIds = executions.map((e) => e.id);
|
|
618
|
+
const allSteps = await this.db.select().from(workflowStepExecutions).where(inArray(workflowStepExecutions.executionId, executionIds)).orderBy(workflowStepExecutions.stepIndex);
|
|
619
|
+
const stepsByExecutionId = /* @__PURE__ */ new Map();
|
|
620
|
+
for (const step of allSteps) {
|
|
621
|
+
const steps = stepsByExecutionId.get(step.executionId) ?? [];
|
|
622
|
+
steps.push(step);
|
|
623
|
+
stepsByExecutionId.set(step.executionId, steps);
|
|
624
|
+
}
|
|
625
|
+
return executions.map((execution) => ({
|
|
626
|
+
...execution,
|
|
627
|
+
steps: stepsByExecutionId.get(execution.id) ?? []
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
async retry(executionId) {
|
|
631
|
+
if (this.processingExecutions.has(executionId)) {
|
|
632
|
+
throw new Error(`Execution '${executionId}' is already being processed`);
|
|
633
|
+
}
|
|
634
|
+
const execution = await this.getExecution(executionId);
|
|
635
|
+
if (!execution) {
|
|
636
|
+
throw new Error(`Execution '${executionId}' not found`);
|
|
637
|
+
}
|
|
638
|
+
const retryableStatuses = ["failed", "compensated"];
|
|
639
|
+
if (!retryableStatuses.includes(execution.status)) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Cannot retry execution '${executionId}' with status '${execution.status}'. Only 'failed' or 'compensated' executions can be retried.`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const workflow2 = this.workflows.get(execution.workflowName);
|
|
645
|
+
if (!workflow2) {
|
|
646
|
+
throw new Error(`Workflow '${execution.workflowName}' not found`);
|
|
647
|
+
}
|
|
648
|
+
this.processingExecutions.add(executionId);
|
|
649
|
+
try {
|
|
650
|
+
if (workflow2.resumable) {
|
|
651
|
+
await this.updateExecutionStatus(executionId, "running");
|
|
652
|
+
const failedSteps = execution.steps.filter((s) => s.status === "failed");
|
|
653
|
+
for (const step of failedSteps) {
|
|
654
|
+
await this.updateStepStatus(step.id, "pending");
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
await this.resetAllSteps(execution.steps);
|
|
658
|
+
await this.db.update(workflowExecutions).set({
|
|
659
|
+
status: "pending",
|
|
660
|
+
currentStep: 0,
|
|
661
|
+
error: null,
|
|
662
|
+
completedAt: null,
|
|
663
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
664
|
+
}).where(eq(workflowExecutions.id, executionId));
|
|
665
|
+
}
|
|
666
|
+
this.executeNextStep(
|
|
667
|
+
executionId,
|
|
668
|
+
workflow2,
|
|
669
|
+
execution.input
|
|
670
|
+
).catch((error) => {
|
|
671
|
+
this.logger.error(`[Workflow:${workflow2.name}] Retry error:`, error);
|
|
672
|
+
}).finally(() => {
|
|
673
|
+
this.processingExecutions.delete(executionId);
|
|
674
|
+
});
|
|
675
|
+
return {
|
|
676
|
+
id: executionId,
|
|
677
|
+
workflowName: execution.workflowName,
|
|
678
|
+
status: "pending"
|
|
679
|
+
};
|
|
680
|
+
} catch (error) {
|
|
681
|
+
this.processingExecutions.delete(executionId);
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Reset all steps to pending state
|
|
687
|
+
*/
|
|
688
|
+
async resetAllSteps(steps) {
|
|
689
|
+
if (steps.length === 0) return;
|
|
690
|
+
await this.db.update(workflowStepExecutions).set({
|
|
691
|
+
status: "pending",
|
|
692
|
+
output: null,
|
|
693
|
+
error: null,
|
|
694
|
+
startedAt: null,
|
|
695
|
+
completedAt: null,
|
|
696
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
697
|
+
}).where(inArray(workflowStepExecutions.id, steps.map((s) => s.id)));
|
|
698
|
+
}
|
|
699
|
+
async cancel(executionId, options) {
|
|
700
|
+
const execution = await this.getExecution(executionId);
|
|
701
|
+
if (!execution) {
|
|
702
|
+
throw new Error(`Execution '${executionId}' not found`);
|
|
703
|
+
}
|
|
704
|
+
const cancellableStatuses = ["pending", "running"];
|
|
705
|
+
if (!cancellableStatuses.includes(execution.status)) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`Cannot cancel execution '${executionId}' with status '${execution.status}'. Only 'pending' or 'running' executions can be cancelled.`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const workflow2 = this.workflows.get(execution.workflowName);
|
|
711
|
+
await this.updateExecutionStatus(executionId, "cancelled");
|
|
712
|
+
this.emitEvent({
|
|
713
|
+
type: "cancelled",
|
|
714
|
+
workflowName: execution.workflowName,
|
|
715
|
+
executionId,
|
|
716
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
717
|
+
});
|
|
718
|
+
if (options?.rollback && workflow2) {
|
|
719
|
+
await this.executeRollback(executionId, workflow2);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
subscribe(executionId, callback) {
|
|
723
|
+
if (!this.subscribers.has(executionId)) {
|
|
724
|
+
this.subscribers.set(executionId, /* @__PURE__ */ new Set());
|
|
725
|
+
}
|
|
726
|
+
this.subscribers.get(executionId).add(callback);
|
|
727
|
+
return () => {
|
|
728
|
+
this.subscribers.get(executionId)?.delete(callback);
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
function createWorkflowEngine(options) {
|
|
733
|
+
const { workflows, ...config } = options;
|
|
734
|
+
return new WorkflowEngineImpl(workflows, config);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/notification/providers.ts
|
|
738
|
+
var consoleProvider = {
|
|
739
|
+
name: "console",
|
|
740
|
+
async notify(event) {
|
|
741
|
+
const timestamp3 = event.timestamp.toISOString();
|
|
742
|
+
const prefix = `[Workflow:${event.workflowName}]`;
|
|
743
|
+
switch (event.type) {
|
|
744
|
+
case "started":
|
|
745
|
+
console.log(`${timestamp3} ${prefix} Started (id: ${event.executionId})`);
|
|
746
|
+
break;
|
|
747
|
+
case "completed":
|
|
748
|
+
console.log(`${timestamp3} ${prefix} Completed (id: ${event.executionId})`);
|
|
749
|
+
break;
|
|
750
|
+
case "failed":
|
|
751
|
+
console.error(`${timestamp3} ${prefix} Failed: ${event.error} (id: ${event.executionId})`);
|
|
752
|
+
break;
|
|
753
|
+
case "cancelled":
|
|
754
|
+
console.log(`${timestamp3} ${prefix} Cancelled (id: ${event.executionId})`);
|
|
755
|
+
break;
|
|
756
|
+
case "step.started":
|
|
757
|
+
console.log(`${timestamp3} ${prefix} Step '${event.stepName}' started`);
|
|
758
|
+
break;
|
|
759
|
+
case "step.completed":
|
|
760
|
+
console.log(`${timestamp3} ${prefix} Step '${event.stepName}' completed`);
|
|
761
|
+
break;
|
|
762
|
+
case "step.failed":
|
|
763
|
+
console.error(`${timestamp3} ${prefix} Step '${event.stepName}' failed: ${event.error}`);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
function formatEventAsText(event) {
|
|
769
|
+
const lines = [
|
|
770
|
+
`Workflow: ${event.workflowName}`,
|
|
771
|
+
`Event: ${event.type}`,
|
|
772
|
+
`Execution ID: ${event.executionId}`,
|
|
773
|
+
`Timestamp: ${event.timestamp.toISOString()}`
|
|
774
|
+
];
|
|
775
|
+
if (event.stepName) {
|
|
776
|
+
lines.push(`Step: ${event.stepName}`);
|
|
777
|
+
}
|
|
778
|
+
if (event.error) {
|
|
779
|
+
lines.push(`Error: ${event.error}`);
|
|
780
|
+
}
|
|
781
|
+
return lines.join("\n");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/config/workflow-router.ts
|
|
785
|
+
function defineWorkflowRouter(workflows) {
|
|
786
|
+
const state = {
|
|
787
|
+
engine: null
|
|
788
|
+
};
|
|
789
|
+
return {
|
|
790
|
+
workflows,
|
|
791
|
+
_workflows: workflows,
|
|
792
|
+
get engine() {
|
|
793
|
+
if (!state.engine) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
"Workflow engine not initialized. Make sure the server is started with .workflows(router) configuration."
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return state.engine;
|
|
799
|
+
},
|
|
800
|
+
get isInitialized() {
|
|
801
|
+
return state.engine !== null;
|
|
802
|
+
},
|
|
803
|
+
_init(db, options) {
|
|
804
|
+
if (state.engine) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
state.engine = createWorkflowEngine({
|
|
808
|
+
db,
|
|
809
|
+
workflows,
|
|
810
|
+
largeOutputThreshold: options?.largeOutputThreshold,
|
|
811
|
+
storage: options?.storage,
|
|
812
|
+
logger: options?.logger,
|
|
813
|
+
validateInput: options?.validateInput
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
function isWorkflowRouter(value) {
|
|
819
|
+
return typeof value === "object" && value !== null && "_workflows" in value && "_init" in value && typeof value._init === "function";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export { WorkflowBuilder, consoleProvider, createWorkflowEngine, defaultLogger, defineWorkflowRouter, formatEventAsText, isWorkflowRouter, workflow, workflowExecutions, workflowStepExecutions };
|
|
823
|
+
//# sourceMappingURL=index.js.map
|
|
824
|
+
//# sourceMappingURL=index.js.map
|