@multiplier-labs/stepflow 0.3.1
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/.github/workflows/publish.yml +30 -0
- package/README.md +408 -0
- package/dist/chunk-FQCT3I2A.js +1512 -0
- package/dist/chunk-FQCT3I2A.js.map +1 -0
- package/dist/chunk-GXY66V7Q.js +111 -0
- package/dist/chunk-GXY66V7Q.js.map +1 -0
- package/dist/chunk-U5QGN3RT.js +715 -0
- package/dist/chunk-U5QGN3RT.js.map +1 -0
- package/dist/chunk-XCF4PLN7.js +575 -0
- package/dist/chunk-XCF4PLN7.js.map +1 -0
- package/dist/events/index.d.ts +346 -0
- package/dist/events/index.js +11 -0
- package/dist/events/index.js.map +1 -0
- package/dist/index-CPfGsmrp.d.ts +567 -0
- package/dist/index.d.ts +748 -0
- package/dist/index.js +1707 -0
- package/dist/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/storage/index.d.ts +402 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/types-BVShq7zT.d.ts +150 -0
- package/dist/types-BlwFEGzj.d.ts +276 -0
- package/dist/types-DzkEqnQJ.d.ts +75 -0
- package/docs/github-packages-setup.md +97 -0
- package/docs/stepflow-api-reference.md +1662 -0
- package/package.json +85 -0
- package/src/core/engine.test.ts +1421 -0
- package/src/core/engine.ts +717 -0
- package/src/core/index.ts +7 -0
- package/src/core/orchestrator.ts +590 -0
- package/src/core/types.ts +242 -0
- package/src/events/index.ts +8 -0
- package/src/events/memory.test.ts +109 -0
- package/src/events/memory.ts +95 -0
- package/src/events/socketio.test.ts +428 -0
- package/src/events/socketio.ts +269 -0
- package/src/events/types.ts +121 -0
- package/src/events/webhook.test.ts +695 -0
- package/src/events/webhook.ts +591 -0
- package/src/index.ts +270 -0
- package/src/planning/index.ts +52 -0
- package/src/planning/planner.ts +601 -0
- package/src/planning/planning.test.ts +790 -0
- package/src/planning/registry.test.ts +445 -0
- package/src/planning/registry.ts +369 -0
- package/src/planning/types.ts +570 -0
- package/src/scheduler/cron.test.ts +480 -0
- package/src/scheduler/cron.ts +449 -0
- package/src/scheduler/index.ts +18 -0
- package/src/scheduler/postgres-persistence.test.ts +906 -0
- package/src/scheduler/postgres-persistence.ts +546 -0
- package/src/scheduler/sqlite-persistence.test.ts +235 -0
- package/src/scheduler/sqlite-persistence.ts +268 -0
- package/src/scheduler/types.ts +78 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/memory.test.ts +399 -0
- package/src/storage/memory.ts +248 -0
- package/src/storage/postgres.ts +1550 -0
- package/src/storage/sqlite.test.ts +593 -0
- package/src/storage/sqlite.ts +662 -0
- package/src/storage/types.ts +368 -0
- package/src/utils/errors.test.ts +152 -0
- package/src/utils/errors.ts +191 -0
- package/src/utils/id.ts +21 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.test.ts +127 -0
- package/src/utils/logger.ts +96 -0
- package/src/utils/postgres-deps.ts +63 -0
- package/src/utils/retry.test.ts +197 -0
- package/src/utils/retry.ts +121 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +9 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MemoryStorageAdapter,
|
|
3
|
+
PostgresStorageAdapter,
|
|
4
|
+
SQLiteStorageAdapter
|
|
5
|
+
} from "./chunk-FQCT3I2A.js";
|
|
6
|
+
import {
|
|
7
|
+
MemoryEventTransport,
|
|
8
|
+
SocketIOEventTransport,
|
|
9
|
+
WebhookEventTransport
|
|
10
|
+
} from "./chunk-XCF4PLN7.js";
|
|
11
|
+
import {
|
|
12
|
+
CronScheduler,
|
|
13
|
+
PostgresSchedulePersistence,
|
|
14
|
+
SQLiteSchedulePersistence
|
|
15
|
+
} from "./chunk-U5QGN3RT.js";
|
|
16
|
+
import {
|
|
17
|
+
ConsoleLogger,
|
|
18
|
+
SilentLogger,
|
|
19
|
+
createScopedLogger,
|
|
20
|
+
generateId,
|
|
21
|
+
sanitizeErrorForStorage
|
|
22
|
+
} from "./chunk-GXY66V7Q.js";
|
|
23
|
+
|
|
24
|
+
// src/utils/errors.ts
|
|
25
|
+
var WorkflowEngineError = class _WorkflowEngineError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
details;
|
|
28
|
+
constructor(code, message, details) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "WorkflowEngineError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.details = details;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Convert to a WorkflowError record for storage.
|
|
36
|
+
*/
|
|
37
|
+
toRecord() {
|
|
38
|
+
return {
|
|
39
|
+
code: this.code,
|
|
40
|
+
message: this.message,
|
|
41
|
+
stack: this.stack,
|
|
42
|
+
details: this.details
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a WorkflowError record from any error.
|
|
47
|
+
*/
|
|
48
|
+
static fromError(error, defaultCode = "UNKNOWN_ERROR") {
|
|
49
|
+
if (error instanceof _WorkflowEngineError) {
|
|
50
|
+
return error.toRecord();
|
|
51
|
+
}
|
|
52
|
+
if (error instanceof Error) {
|
|
53
|
+
return {
|
|
54
|
+
code: defaultCode,
|
|
55
|
+
message: error.message,
|
|
56
|
+
stack: error.stack
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
code: defaultCode,
|
|
61
|
+
message: String(error)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var WorkflowNotFoundError = class extends WorkflowEngineError {
|
|
66
|
+
constructor(kind) {
|
|
67
|
+
super("WORKFLOW_NOT_FOUND", `Workflow "${kind}" is not registered`, {
|
|
68
|
+
kind
|
|
69
|
+
});
|
|
70
|
+
this.name = "WorkflowNotFoundError";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var WorkflowAlreadyRegisteredError = class extends WorkflowEngineError {
|
|
74
|
+
constructor(kind) {
|
|
75
|
+
super(
|
|
76
|
+
"WORKFLOW_ALREADY_REGISTERED",
|
|
77
|
+
`Workflow "${kind}" is already registered`,
|
|
78
|
+
{ kind }
|
|
79
|
+
);
|
|
80
|
+
this.name = "WorkflowAlreadyRegisteredError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
var RunNotFoundError = class extends WorkflowEngineError {
|
|
84
|
+
constructor(runId) {
|
|
85
|
+
super("RUN_NOT_FOUND", `Run "${runId}" not found`, { runId });
|
|
86
|
+
this.name = "RunNotFoundError";
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var StepError = class extends WorkflowEngineError {
|
|
90
|
+
stepKey;
|
|
91
|
+
attempt;
|
|
92
|
+
cause;
|
|
93
|
+
constructor(stepKey, message, attempt, cause) {
|
|
94
|
+
super("STEP_ERROR", message, { stepKey, attempt });
|
|
95
|
+
this.name = "StepError";
|
|
96
|
+
this.stepKey = stepKey;
|
|
97
|
+
this.attempt = attempt;
|
|
98
|
+
this.cause = cause;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var StepTimeoutError = class extends WorkflowEngineError {
|
|
102
|
+
stepKey;
|
|
103
|
+
timeoutMs;
|
|
104
|
+
constructor(stepKey, timeoutMs) {
|
|
105
|
+
super("STEP_TIMEOUT", `Step "${stepKey}" timed out after ${timeoutMs}ms`, {
|
|
106
|
+
stepKey,
|
|
107
|
+
timeoutMs
|
|
108
|
+
});
|
|
109
|
+
this.name = "StepTimeoutError";
|
|
110
|
+
this.stepKey = stepKey;
|
|
111
|
+
this.timeoutMs = timeoutMs;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
var WorkflowCanceledError = class extends WorkflowEngineError {
|
|
115
|
+
constructor(runId) {
|
|
116
|
+
super("WORKFLOW_CANCELED", `Workflow run "${runId}" was canceled`, {
|
|
117
|
+
runId
|
|
118
|
+
});
|
|
119
|
+
this.name = "WorkflowCanceledError";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var WaitForRunTimeoutError = class extends WorkflowEngineError {
|
|
123
|
+
runId;
|
|
124
|
+
timeoutMs;
|
|
125
|
+
constructor(runId, timeoutMs) {
|
|
126
|
+
super(
|
|
127
|
+
"WAIT_FOR_RUN_TIMEOUT",
|
|
128
|
+
`Timeout waiting for run ${runId} after ${timeoutMs}ms`,
|
|
129
|
+
{
|
|
130
|
+
runId,
|
|
131
|
+
timeoutMs
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
this.name = "WaitForRunTimeoutError";
|
|
135
|
+
this.runId = runId;
|
|
136
|
+
this.timeoutMs = timeoutMs;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var WorkflowTimeoutError = class extends WorkflowEngineError {
|
|
140
|
+
timeoutMs;
|
|
141
|
+
constructor(runId, timeoutMs) {
|
|
142
|
+
super(
|
|
143
|
+
"WORKFLOW_TIMEOUT",
|
|
144
|
+
`Workflow run "${runId}" timed out after ${timeoutMs}ms`,
|
|
145
|
+
{
|
|
146
|
+
runId,
|
|
147
|
+
timeoutMs
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
this.name = "WorkflowTimeoutError";
|
|
151
|
+
this.timeoutMs = timeoutMs;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// src/utils/retry.ts
|
|
156
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
157
|
+
maxRetries: 3,
|
|
158
|
+
delay: 1e3,
|
|
159
|
+
backoff: 2
|
|
160
|
+
};
|
|
161
|
+
function sleep(ms, signal) {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
if (signal?.aborted) {
|
|
164
|
+
reject(new WorkflowCanceledError("run"));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const cleanup = () => {
|
|
168
|
+
clearTimeout(timeoutId);
|
|
169
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
170
|
+
};
|
|
171
|
+
let onAbort;
|
|
172
|
+
const timeoutId = setTimeout(() => {
|
|
173
|
+
cleanup();
|
|
174
|
+
resolve();
|
|
175
|
+
}, ms);
|
|
176
|
+
if (signal) {
|
|
177
|
+
onAbort = () => {
|
|
178
|
+
cleanup();
|
|
179
|
+
reject(new WorkflowCanceledError("run"));
|
|
180
|
+
};
|
|
181
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async function withRetry(fn, options = {}) {
|
|
186
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
187
|
+
let lastError;
|
|
188
|
+
let currentDelay = opts.delay;
|
|
189
|
+
for (let attempt = 1; attempt <= opts.maxRetries + 1; attempt++) {
|
|
190
|
+
if (opts.signal?.aborted) {
|
|
191
|
+
throw new Error("Aborted");
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
return await fn();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
197
|
+
if (attempt > opts.maxRetries) {
|
|
198
|
+
throw lastError;
|
|
199
|
+
}
|
|
200
|
+
opts.onRetry?.(attempt, lastError, currentDelay);
|
|
201
|
+
await sleep(currentDelay, opts.signal);
|
|
202
|
+
currentDelay = Math.round(currentDelay * opts.backoff);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw lastError ?? new Error("Retry failed");
|
|
206
|
+
}
|
|
207
|
+
function calculateRetryDelay(attempt, baseDelay, backoff) {
|
|
208
|
+
return Math.round(baseDelay * Math.pow(backoff, attempt - 1));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/core/orchestrator.ts
|
|
212
|
+
async function executeWorkflow(options) {
|
|
213
|
+
const {
|
|
214
|
+
runId,
|
|
215
|
+
definition,
|
|
216
|
+
input,
|
|
217
|
+
metadata = {},
|
|
218
|
+
storage,
|
|
219
|
+
events,
|
|
220
|
+
logger,
|
|
221
|
+
abortController,
|
|
222
|
+
spawnChild,
|
|
223
|
+
checkpoint
|
|
224
|
+
} = options;
|
|
225
|
+
const startTime = Date.now();
|
|
226
|
+
const isResume = !!checkpoint;
|
|
227
|
+
await storage.updateRun(runId, {
|
|
228
|
+
status: "running",
|
|
229
|
+
...isResume ? {} : { startedAt: /* @__PURE__ */ new Date() }
|
|
230
|
+
});
|
|
231
|
+
emitEvent(events, logger, {
|
|
232
|
+
runId,
|
|
233
|
+
kind: definition.kind,
|
|
234
|
+
eventType: isResume ? "run.resumed" : "run.started",
|
|
235
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
236
|
+
payload: isResume ? { resumedFrom: Array.from(checkpoint.completedStepKeys) } : void 0
|
|
237
|
+
});
|
|
238
|
+
const context = {
|
|
239
|
+
runId,
|
|
240
|
+
stepId: "",
|
|
241
|
+
// Will be set for each step before handler is called
|
|
242
|
+
kind: definition.kind,
|
|
243
|
+
input,
|
|
244
|
+
results: checkpoint?.results ? { ...checkpoint.results } : {},
|
|
245
|
+
metadata,
|
|
246
|
+
logger: createScopedLogger(logger, runId),
|
|
247
|
+
signal: abortController.signal,
|
|
248
|
+
spawnChild,
|
|
249
|
+
emit: (eventType, payload) => {
|
|
250
|
+
emitEvent(events, logger, {
|
|
251
|
+
runId,
|
|
252
|
+
kind: definition.kind,
|
|
253
|
+
eventType,
|
|
254
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
255
|
+
payload
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
let workflowTimeoutId;
|
|
260
|
+
let workflowTimeoutError;
|
|
261
|
+
if (definition.timeout) {
|
|
262
|
+
workflowTimeoutId = setTimeout(() => {
|
|
263
|
+
workflowTimeoutError = new WorkflowTimeoutError(
|
|
264
|
+
runId,
|
|
265
|
+
definition.timeout
|
|
266
|
+
);
|
|
267
|
+
abortController.abort();
|
|
268
|
+
}, definition.timeout);
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
if (definition.hooks?.beforeRun) {
|
|
272
|
+
await definition.hooks.beforeRun(context);
|
|
273
|
+
}
|
|
274
|
+
for (const step of definition.steps) {
|
|
275
|
+
if (abortController.signal.aborted) {
|
|
276
|
+
throw new WorkflowCanceledError(runId);
|
|
277
|
+
}
|
|
278
|
+
context.currentStep = step.key;
|
|
279
|
+
if (checkpoint?.completedStepKeys.has(step.key)) {
|
|
280
|
+
logger.info(
|
|
281
|
+
`Skipping step "${step.key}" (already completed in previous run)`
|
|
282
|
+
);
|
|
283
|
+
emitEvent(events, logger, {
|
|
284
|
+
runId,
|
|
285
|
+
kind: definition.kind,
|
|
286
|
+
eventType: "step.skipped",
|
|
287
|
+
stepKey: step.key,
|
|
288
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
289
|
+
payload: { reason: "checkpoint" }
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (step.skipIf) {
|
|
294
|
+
const shouldSkip = await step.skipIf(context);
|
|
295
|
+
if (shouldSkip) {
|
|
296
|
+
logger.info(`Skipping step "${step.key}" (condition met)`);
|
|
297
|
+
emitEvent(events, logger, {
|
|
298
|
+
runId,
|
|
299
|
+
kind: definition.kind,
|
|
300
|
+
eventType: "step.skipped",
|
|
301
|
+
stepKey: step.key,
|
|
302
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const result = await executeStep(step, context, {
|
|
308
|
+
definition,
|
|
309
|
+
storage,
|
|
310
|
+
events,
|
|
311
|
+
logger,
|
|
312
|
+
abortController
|
|
313
|
+
});
|
|
314
|
+
context.results[step.key] = result;
|
|
315
|
+
await storage.updateRun(runId, {
|
|
316
|
+
context: { ...context.results }
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (workflowTimeoutId) {
|
|
320
|
+
clearTimeout(workflowTimeoutId);
|
|
321
|
+
}
|
|
322
|
+
const duration = Date.now() - startTime;
|
|
323
|
+
const runResult = {
|
|
324
|
+
status: "succeeded",
|
|
325
|
+
results: context.results,
|
|
326
|
+
duration
|
|
327
|
+
};
|
|
328
|
+
if (definition.hooks?.afterRun) {
|
|
329
|
+
await definition.hooks.afterRun(context, runResult);
|
|
330
|
+
}
|
|
331
|
+
await storage.updateRun(runId, {
|
|
332
|
+
status: "succeeded",
|
|
333
|
+
context: context.results,
|
|
334
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
335
|
+
});
|
|
336
|
+
emitEvent(events, logger, {
|
|
337
|
+
runId,
|
|
338
|
+
kind: definition.kind,
|
|
339
|
+
eventType: "run.completed",
|
|
340
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
341
|
+
payload: { results: context.results, duration }
|
|
342
|
+
});
|
|
343
|
+
return runResult;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (workflowTimeoutId) {
|
|
346
|
+
clearTimeout(workflowTimeoutId);
|
|
347
|
+
}
|
|
348
|
+
const duration = Date.now() - startTime;
|
|
349
|
+
const actualError = workflowTimeoutError ?? error;
|
|
350
|
+
const workflowError = WorkflowEngineError.fromError(actualError);
|
|
351
|
+
const isTimeout = actualError instanceof WorkflowTimeoutError;
|
|
352
|
+
const isCanceled = !isTimeout && actualError instanceof WorkflowCanceledError;
|
|
353
|
+
const status = isCanceled ? "canceled" : "failed";
|
|
354
|
+
const eventType = isTimeout ? "run.timeout" : isCanceled ? "run.canceled" : "run.failed";
|
|
355
|
+
const runResult = {
|
|
356
|
+
status,
|
|
357
|
+
results: context.results,
|
|
358
|
+
error: workflowError,
|
|
359
|
+
duration
|
|
360
|
+
};
|
|
361
|
+
if (definition.hooks?.afterRun) {
|
|
362
|
+
try {
|
|
363
|
+
await definition.hooks.afterRun(context, runResult);
|
|
364
|
+
} catch (hookError) {
|
|
365
|
+
logger.error("afterRun hook failed:", hookError);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
await storage.updateRun(runId, {
|
|
369
|
+
status,
|
|
370
|
+
context: context.results,
|
|
371
|
+
error: workflowError,
|
|
372
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
373
|
+
});
|
|
374
|
+
emitEvent(events, logger, {
|
|
375
|
+
runId,
|
|
376
|
+
kind: definition.kind,
|
|
377
|
+
eventType,
|
|
378
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
379
|
+
payload: { error: workflowError.message, duration }
|
|
380
|
+
});
|
|
381
|
+
return runResult;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function executeStep(step, context, options) {
|
|
385
|
+
const { definition, storage, events, logger, abortController } = options;
|
|
386
|
+
const onError = step.onError ?? definition.defaultOnError ?? "fail";
|
|
387
|
+
const maxRetries = step.maxRetries ?? 3;
|
|
388
|
+
const retryDelay = step.retryDelay ?? 1e3;
|
|
389
|
+
const retryBackoff = step.retryBackoff ?? 2;
|
|
390
|
+
let attempt = 0;
|
|
391
|
+
let lastError;
|
|
392
|
+
while (true) {
|
|
393
|
+
attempt++;
|
|
394
|
+
if (abortController.signal.aborted) {
|
|
395
|
+
throw new WorkflowCanceledError(context.runId);
|
|
396
|
+
}
|
|
397
|
+
const stepRecord = await storage.createStep({
|
|
398
|
+
runId: context.runId,
|
|
399
|
+
stepKey: step.key,
|
|
400
|
+
stepName: step.name,
|
|
401
|
+
status: "running",
|
|
402
|
+
attempt,
|
|
403
|
+
startedAt: /* @__PURE__ */ new Date()
|
|
404
|
+
});
|
|
405
|
+
context.stepId = stepRecord.id;
|
|
406
|
+
emitEvent(events, logger, {
|
|
407
|
+
runId: context.runId,
|
|
408
|
+
kind: context.kind,
|
|
409
|
+
eventType: "step.started",
|
|
410
|
+
stepKey: step.key,
|
|
411
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
412
|
+
payload: { attempt }
|
|
413
|
+
});
|
|
414
|
+
if (definition.hooks?.beforeStep) {
|
|
415
|
+
await definition.hooks.beforeStep(context, step);
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
let result;
|
|
419
|
+
if (step.timeout) {
|
|
420
|
+
result = await executeWithTimeout(
|
|
421
|
+
() => step.handler(context),
|
|
422
|
+
step.timeout,
|
|
423
|
+
abortController.signal,
|
|
424
|
+
step.key
|
|
425
|
+
);
|
|
426
|
+
} else {
|
|
427
|
+
result = await raceWithAbort(
|
|
428
|
+
step.handler(context),
|
|
429
|
+
abortController.signal
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
await storage.updateStep(stepRecord.id, {
|
|
433
|
+
status: "succeeded",
|
|
434
|
+
result,
|
|
435
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
436
|
+
});
|
|
437
|
+
if (definition.hooks?.afterStep) {
|
|
438
|
+
await definition.hooks.afterStep(context, step, result);
|
|
439
|
+
}
|
|
440
|
+
emitEvent(events, logger, {
|
|
441
|
+
runId: context.runId,
|
|
442
|
+
kind: context.kind,
|
|
443
|
+
eventType: "step.completed",
|
|
444
|
+
stepKey: step.key,
|
|
445
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
446
|
+
payload: { result }
|
|
447
|
+
});
|
|
448
|
+
return result;
|
|
449
|
+
} catch (error) {
|
|
450
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
451
|
+
const isCanceled = error instanceof WorkflowCanceledError || abortController.signal.aborted;
|
|
452
|
+
await storage.updateStep(stepRecord.id, {
|
|
453
|
+
status: isCanceled ? "canceled" : "failed",
|
|
454
|
+
error: WorkflowEngineError.fromError(error),
|
|
455
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
456
|
+
});
|
|
457
|
+
if (isCanceled) {
|
|
458
|
+
emitEvent(events, logger, {
|
|
459
|
+
runId: context.runId,
|
|
460
|
+
kind: context.kind,
|
|
461
|
+
eventType: "step.failed",
|
|
462
|
+
stepKey: step.key,
|
|
463
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
464
|
+
payload: { error: lastError.message, attempt }
|
|
465
|
+
});
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
if (definition.hooks?.onStepError) {
|
|
469
|
+
try {
|
|
470
|
+
await definition.hooks.onStepError(context, step, lastError);
|
|
471
|
+
} catch (hookError) {
|
|
472
|
+
logger.error("onStepError hook failed:", hookError);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
emitEvent(events, logger, {
|
|
476
|
+
runId: context.runId,
|
|
477
|
+
kind: context.kind,
|
|
478
|
+
eventType: "step.failed",
|
|
479
|
+
stepKey: step.key,
|
|
480
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
481
|
+
payload: { error: lastError.message, attempt }
|
|
482
|
+
});
|
|
483
|
+
if (onError === "skip") {
|
|
484
|
+
logger.warn(`Step "${step.key}" failed, skipping (strategy: skip)`);
|
|
485
|
+
emitEvent(events, logger, {
|
|
486
|
+
runId: context.runId,
|
|
487
|
+
kind: context.kind,
|
|
488
|
+
eventType: "step.skipped",
|
|
489
|
+
stepKey: step.key,
|
|
490
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
491
|
+
payload: { reason: "error", error: lastError.message }
|
|
492
|
+
});
|
|
493
|
+
return void 0;
|
|
494
|
+
}
|
|
495
|
+
if (onError === "retry" && attempt <= maxRetries) {
|
|
496
|
+
const delay = calculateRetryDelay(attempt, retryDelay, retryBackoff);
|
|
497
|
+
logger.warn(
|
|
498
|
+
`Step "${step.key}" failed, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`
|
|
499
|
+
);
|
|
500
|
+
emitEvent(events, logger, {
|
|
501
|
+
runId: context.runId,
|
|
502
|
+
kind: context.kind,
|
|
503
|
+
eventType: "step.retry",
|
|
504
|
+
stepKey: step.key,
|
|
505
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
506
|
+
payload: { attempt, maxRetries, delay, error: lastError.message }
|
|
507
|
+
});
|
|
508
|
+
try {
|
|
509
|
+
await sleep(delay, abortController.signal);
|
|
510
|
+
} catch (sleepError) {
|
|
511
|
+
if (sleepError instanceof WorkflowCanceledError) {
|
|
512
|
+
await storage.updateStep(stepRecord.id, {
|
|
513
|
+
status: "canceled",
|
|
514
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
throw sleepError;
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
throw new StepError(step.key, lastError.message, attempt, lastError);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async function executeWithTimeout(fn, timeoutMs, signal, stepKey) {
|
|
526
|
+
let timeoutId;
|
|
527
|
+
let onAbort;
|
|
528
|
+
try {
|
|
529
|
+
return await Promise.race([
|
|
530
|
+
fn(),
|
|
531
|
+
new Promise((_, reject) => {
|
|
532
|
+
timeoutId = setTimeout(() => {
|
|
533
|
+
reject(new StepTimeoutError(stepKey, timeoutMs));
|
|
534
|
+
}, timeoutMs);
|
|
535
|
+
onAbort = () => {
|
|
536
|
+
clearTimeout(timeoutId);
|
|
537
|
+
reject(new WorkflowCanceledError("run"));
|
|
538
|
+
};
|
|
539
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
540
|
+
})
|
|
541
|
+
]);
|
|
542
|
+
} finally {
|
|
543
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
544
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function raceWithAbort(promise, signal) {
|
|
548
|
+
if (signal.aborted) {
|
|
549
|
+
throw new WorkflowCanceledError("run");
|
|
550
|
+
}
|
|
551
|
+
let onAbort;
|
|
552
|
+
try {
|
|
553
|
+
return await Promise.race([
|
|
554
|
+
promise,
|
|
555
|
+
new Promise((_, reject) => {
|
|
556
|
+
onAbort = () => reject(new WorkflowCanceledError("run"));
|
|
557
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
558
|
+
})
|
|
559
|
+
]);
|
|
560
|
+
} finally {
|
|
561
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function emitEvent(events, logger, event) {
|
|
565
|
+
try {
|
|
566
|
+
events.emit(event);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
logger.error("Failed to emit event:", error);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/core/engine.ts
|
|
573
|
+
var WorkflowEngine = class _WorkflowEngine {
|
|
574
|
+
registry = /* @__PURE__ */ new Map();
|
|
575
|
+
storage;
|
|
576
|
+
events;
|
|
577
|
+
logger;
|
|
578
|
+
activeRuns = /* @__PURE__ */ new Map();
|
|
579
|
+
settings;
|
|
580
|
+
runQueue = [];
|
|
581
|
+
constructor(config = {}) {
|
|
582
|
+
this.storage = config.storage ?? new MemoryStorageAdapter();
|
|
583
|
+
this.events = config.events ?? new MemoryEventTransport();
|
|
584
|
+
this.logger = config.logger ?? new ConsoleLogger();
|
|
585
|
+
this.settings = config.settings ?? {};
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Initialize the engine and its storage/event adapters.
|
|
589
|
+
* Call this before starting runs if your storage adapter requires initialization
|
|
590
|
+
* (e.g., PostgresStorageAdapter).
|
|
591
|
+
*/
|
|
592
|
+
async initialize() {
|
|
593
|
+
if (this.storage.initialize) {
|
|
594
|
+
await this.storage.initialize();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Get the current number of active runs.
|
|
599
|
+
*/
|
|
600
|
+
getActiveRunCount() {
|
|
601
|
+
return this.activeRuns.size;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Get the number of queued runs waiting for capacity.
|
|
605
|
+
*/
|
|
606
|
+
getQueuedRunCount() {
|
|
607
|
+
return this.runQueue.length;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Check if capacity is available for a new run.
|
|
611
|
+
*/
|
|
612
|
+
hasCapacity() {
|
|
613
|
+
const maxConcurrency = this.settings.maxConcurrency;
|
|
614
|
+
if (maxConcurrency === void 0 || maxConcurrency <= 0) {
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
return this.activeRuns.size < maxConcurrency;
|
|
618
|
+
}
|
|
619
|
+
// ============================================================================
|
|
620
|
+
// Workflow Registration
|
|
621
|
+
// ============================================================================
|
|
622
|
+
/**
|
|
623
|
+
* Register a workflow definition.
|
|
624
|
+
* Must be called before runs of this type can be started.
|
|
625
|
+
*
|
|
626
|
+
* @param definition - The workflow definition
|
|
627
|
+
* @throws WorkflowAlreadyRegisteredError if already registered
|
|
628
|
+
*/
|
|
629
|
+
registerWorkflow(definition) {
|
|
630
|
+
if (this.registry.has(definition.kind)) {
|
|
631
|
+
throw new WorkflowAlreadyRegisteredError(definition.kind);
|
|
632
|
+
}
|
|
633
|
+
this.registry.set(definition.kind, definition);
|
|
634
|
+
this.logger.debug(`Registered workflow: ${definition.kind}`);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Unregister a workflow definition.
|
|
638
|
+
*
|
|
639
|
+
* @param kind - The workflow kind to unregister
|
|
640
|
+
* @returns true if the workflow was unregistered, false if not found
|
|
641
|
+
*/
|
|
642
|
+
unregisterWorkflow(kind) {
|
|
643
|
+
const deleted = this.registry.delete(kind);
|
|
644
|
+
if (deleted) {
|
|
645
|
+
this.logger.debug(`Unregistered workflow: ${kind}`);
|
|
646
|
+
}
|
|
647
|
+
return deleted;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Get a registered workflow definition.
|
|
651
|
+
*
|
|
652
|
+
* @param kind - The workflow kind
|
|
653
|
+
* @returns The workflow definition or undefined
|
|
654
|
+
*/
|
|
655
|
+
getWorkflow(kind) {
|
|
656
|
+
return this.registry.get(kind);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Get all registered workflow kinds.
|
|
660
|
+
*/
|
|
661
|
+
getRegisteredWorkflows() {
|
|
662
|
+
return Array.from(this.registry.keys());
|
|
663
|
+
}
|
|
664
|
+
// ============================================================================
|
|
665
|
+
// Run Management
|
|
666
|
+
// ============================================================================
|
|
667
|
+
/**
|
|
668
|
+
* Start a new workflow run (non-blocking).
|
|
669
|
+
* The run executes asynchronously and this method returns immediately.
|
|
670
|
+
* If maxConcurrency is set and reached, the run is queued.
|
|
671
|
+
*
|
|
672
|
+
* @param options - Run options including kind and input
|
|
673
|
+
* @returns The generated run ID
|
|
674
|
+
* @throws WorkflowNotFoundError if the workflow kind is not registered
|
|
675
|
+
*/
|
|
676
|
+
async startRun(options) {
|
|
677
|
+
const definition = this.registry.get(options.kind);
|
|
678
|
+
if (!definition) {
|
|
679
|
+
throw new WorkflowNotFoundError(options.kind);
|
|
680
|
+
}
|
|
681
|
+
const run = await this.storage.createRun({
|
|
682
|
+
kind: options.kind,
|
|
683
|
+
status: "queued",
|
|
684
|
+
parentRunId: options.parentRunId,
|
|
685
|
+
input: options.input ?? {},
|
|
686
|
+
metadata: options.metadata ?? {},
|
|
687
|
+
context: {}
|
|
688
|
+
});
|
|
689
|
+
const runId = run.id;
|
|
690
|
+
this.events.emit({
|
|
691
|
+
runId,
|
|
692
|
+
kind: options.kind,
|
|
693
|
+
eventType: "run.created",
|
|
694
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
695
|
+
payload: {
|
|
696
|
+
input: options.input,
|
|
697
|
+
metadata: options.metadata,
|
|
698
|
+
priority: options.priority ?? 0
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
if (this.hasCapacity()) {
|
|
702
|
+
this.executeRun(
|
|
703
|
+
runId,
|
|
704
|
+
definition,
|
|
705
|
+
options.input ?? {},
|
|
706
|
+
options.metadata,
|
|
707
|
+
options.delay
|
|
708
|
+
);
|
|
709
|
+
} else {
|
|
710
|
+
this.queueRun({
|
|
711
|
+
runId,
|
|
712
|
+
definition,
|
|
713
|
+
input: options.input ?? {},
|
|
714
|
+
metadata: options.metadata,
|
|
715
|
+
priority: options.priority ?? 0,
|
|
716
|
+
queuedAt: /* @__PURE__ */ new Date()
|
|
717
|
+
});
|
|
718
|
+
this.logger.debug(
|
|
719
|
+
`Run ${runId} queued (${this.runQueue.length} in queue)`
|
|
720
|
+
);
|
|
721
|
+
this.events.emit({
|
|
722
|
+
runId,
|
|
723
|
+
kind: options.kind,
|
|
724
|
+
eventType: "run.queued",
|
|
725
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
726
|
+
payload: { queuePosition: this.runQueue.length }
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return runId;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Queue a run in priority order.
|
|
733
|
+
*/
|
|
734
|
+
queueRun(queuedRun) {
|
|
735
|
+
let inserted = false;
|
|
736
|
+
for (let i = 0; i < this.runQueue.length; i++) {
|
|
737
|
+
if (queuedRun.priority > this.runQueue[i].priority) {
|
|
738
|
+
this.runQueue.splice(i, 0, queuedRun);
|
|
739
|
+
inserted = true;
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (!inserted) {
|
|
744
|
+
this.runQueue.push(queuedRun);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Execute a run (internal method).
|
|
749
|
+
*/
|
|
750
|
+
executeRun(runId, definition, input, metadata, delay) {
|
|
751
|
+
this.launchRun(runId, definition, input, metadata, delay);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Launch a workflow run asynchronously.
|
|
755
|
+
* Shared by both executeRun (new runs) and resumeRun (checkpoint recovery).
|
|
756
|
+
*/
|
|
757
|
+
launchRun(runId, definition, input, metadata, delay, checkpoint) {
|
|
758
|
+
const abortController = new AbortController();
|
|
759
|
+
this.activeRuns.set(runId, abortController);
|
|
760
|
+
const execute = async () => {
|
|
761
|
+
try {
|
|
762
|
+
await executeWorkflow({
|
|
763
|
+
runId,
|
|
764
|
+
definition,
|
|
765
|
+
input,
|
|
766
|
+
metadata,
|
|
767
|
+
storage: this.storage,
|
|
768
|
+
events: this.events,
|
|
769
|
+
logger: this.logger,
|
|
770
|
+
abortController,
|
|
771
|
+
spawnChild: (childOptions) => this.spawnChild(runId, childOptions),
|
|
772
|
+
checkpoint
|
|
773
|
+
});
|
|
774
|
+
} finally {
|
|
775
|
+
this.activeRuns.delete(runId);
|
|
776
|
+
this.processQueue();
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
if (delay) {
|
|
780
|
+
setTimeout(execute, delay);
|
|
781
|
+
} else {
|
|
782
|
+
setImmediate(execute);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Process the queue and start runs if capacity is available.
|
|
787
|
+
*/
|
|
788
|
+
processQueue() {
|
|
789
|
+
while (this.hasCapacity() && this.runQueue.length > 0) {
|
|
790
|
+
const next = this.runQueue.shift();
|
|
791
|
+
this.logger.debug(`Starting queued run ${next.runId}`);
|
|
792
|
+
this.events.emit({
|
|
793
|
+
runId: next.runId,
|
|
794
|
+
kind: next.definition.kind,
|
|
795
|
+
eventType: "run.dequeued",
|
|
796
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
797
|
+
});
|
|
798
|
+
this.executeRun(next.runId, next.definition, next.input, next.metadata);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Start a child workflow from within a parent workflow.
|
|
803
|
+
* Called internally by the context.spawnChild helper.
|
|
804
|
+
*/
|
|
805
|
+
async spawnChild(parentRunId, options) {
|
|
806
|
+
return this.startRun({
|
|
807
|
+
kind: options.kind,
|
|
808
|
+
input: options.input,
|
|
809
|
+
metadata: options.metadata,
|
|
810
|
+
parentRunId
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Cancel a running workflow.
|
|
815
|
+
* Signals the workflow to stop at the next cancellation point.
|
|
816
|
+
*
|
|
817
|
+
* @param runId - The run ID to cancel
|
|
818
|
+
* @throws {RunNotFoundError} If no run with the given ID exists
|
|
819
|
+
*/
|
|
820
|
+
async cancelRun(runId) {
|
|
821
|
+
const run = await this.storage.getRun(runId);
|
|
822
|
+
if (!run) {
|
|
823
|
+
throw new RunNotFoundError(runId);
|
|
824
|
+
}
|
|
825
|
+
if (["succeeded", "failed", "canceled", "timeout"].includes(run.status)) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const queueIndex = this.runQueue.findIndex((q) => q.runId === runId);
|
|
829
|
+
if (queueIndex !== -1) {
|
|
830
|
+
this.runQueue.splice(queueIndex, 1);
|
|
831
|
+
}
|
|
832
|
+
const controller = this.activeRuns.get(runId);
|
|
833
|
+
if (controller) {
|
|
834
|
+
controller.abort();
|
|
835
|
+
}
|
|
836
|
+
await this.storage.updateRun(runId, {
|
|
837
|
+
status: "canceled",
|
|
838
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
839
|
+
});
|
|
840
|
+
this.events.emit({
|
|
841
|
+
runId,
|
|
842
|
+
kind: run.kind,
|
|
843
|
+
eventType: "run.canceled",
|
|
844
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Get the current status of a run.
|
|
849
|
+
*
|
|
850
|
+
* @param runId - The run ID to look up
|
|
851
|
+
* @returns The run record or null if not found
|
|
852
|
+
*/
|
|
853
|
+
async getRunStatus(runId) {
|
|
854
|
+
return this.storage.getRun(runId);
|
|
855
|
+
}
|
|
856
|
+
static TERMINAL_STATUSES = [
|
|
857
|
+
"succeeded",
|
|
858
|
+
"failed",
|
|
859
|
+
"canceled",
|
|
860
|
+
"timeout"
|
|
861
|
+
];
|
|
862
|
+
static TERMINAL_EVENT_TYPES = [
|
|
863
|
+
"run.completed",
|
|
864
|
+
"run.failed",
|
|
865
|
+
"run.canceled",
|
|
866
|
+
"run.timeout"
|
|
867
|
+
];
|
|
868
|
+
/**
|
|
869
|
+
* Wait for a run to complete.
|
|
870
|
+
* Subscribes to run events and resolves when a terminal event fires.
|
|
871
|
+
* Falls back to an initial storage read to avoid race conditions.
|
|
872
|
+
*
|
|
873
|
+
* @param runId - The run ID to wait for
|
|
874
|
+
* @param options - Wait options
|
|
875
|
+
* @returns The final run record
|
|
876
|
+
*/
|
|
877
|
+
async waitForRun(runId, options = {}) {
|
|
878
|
+
const timeout = options.timeout ?? 6e4;
|
|
879
|
+
const existingRun = await this.storage.getRun(runId);
|
|
880
|
+
if (!existingRun) {
|
|
881
|
+
throw new RunNotFoundError(runId);
|
|
882
|
+
}
|
|
883
|
+
if (_WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
|
|
884
|
+
return existingRun;
|
|
885
|
+
}
|
|
886
|
+
return new Promise((resolve, reject) => {
|
|
887
|
+
let timeoutId;
|
|
888
|
+
let unsubscribe;
|
|
889
|
+
const cleanup = () => {
|
|
890
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
891
|
+
if (unsubscribe) unsubscribe();
|
|
892
|
+
};
|
|
893
|
+
timeoutId = setTimeout(() => {
|
|
894
|
+
cleanup();
|
|
895
|
+
reject(new WaitForRunTimeoutError(runId, timeout));
|
|
896
|
+
}, timeout);
|
|
897
|
+
unsubscribe = this.events.subscribe(runId, async (event) => {
|
|
898
|
+
if (_WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
|
|
899
|
+
cleanup();
|
|
900
|
+
try {
|
|
901
|
+
const run = await this.storage.getRun(runId);
|
|
902
|
+
if (!run) {
|
|
903
|
+
reject(new RunNotFoundError(runId));
|
|
904
|
+
} else {
|
|
905
|
+
resolve(run);
|
|
906
|
+
}
|
|
907
|
+
} catch (err) {
|
|
908
|
+
reject(err);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
// ============================================================================
|
|
915
|
+
// Resume Support
|
|
916
|
+
// ============================================================================
|
|
917
|
+
/**
|
|
918
|
+
* Resume an interrupted workflow run from its checkpoint.
|
|
919
|
+
* The run must be in 'queued' or 'running' status.
|
|
920
|
+
*
|
|
921
|
+
* @param runId - The run ID to resume
|
|
922
|
+
* @returns The run ID (same as input)
|
|
923
|
+
* @throws {RunNotFoundError} If no run with the given ID exists
|
|
924
|
+
* @throws {Error} If the run status is not 'queued' or 'running'
|
|
925
|
+
* @throws {WorkflowNotFoundError} If the workflow kind is not registered
|
|
926
|
+
*/
|
|
927
|
+
async resumeRun(runId) {
|
|
928
|
+
const run = await this.storage.getRun(runId);
|
|
929
|
+
if (!run) {
|
|
930
|
+
throw new RunNotFoundError(runId);
|
|
931
|
+
}
|
|
932
|
+
if (!["queued", "running"].includes(run.status)) {
|
|
933
|
+
throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
|
|
934
|
+
}
|
|
935
|
+
const definition = this.registry.get(run.kind);
|
|
936
|
+
if (!definition) {
|
|
937
|
+
throw new WorkflowNotFoundError(run.kind);
|
|
938
|
+
}
|
|
939
|
+
if (this.activeRuns.has(runId)) {
|
|
940
|
+
this.logger.warn(`Run ${runId} is already active, skipping resume`);
|
|
941
|
+
return runId;
|
|
942
|
+
}
|
|
943
|
+
this.logger.info(`Resuming run ${runId} from checkpoint`);
|
|
944
|
+
const completedStepKeys = new Set(Object.keys(run.context));
|
|
945
|
+
this.launchRun(runId, definition, run.input, run.metadata, void 0, {
|
|
946
|
+
completedStepKeys,
|
|
947
|
+
results: run.context
|
|
948
|
+
});
|
|
949
|
+
return runId;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Get all runs that were interrupted and can be resumed.
|
|
953
|
+
* Returns runs with status 'queued' or 'running'.
|
|
954
|
+
*/
|
|
955
|
+
async getResumableRuns() {
|
|
956
|
+
const result = await this.storage.listRuns({
|
|
957
|
+
status: ["queued", "running"]
|
|
958
|
+
});
|
|
959
|
+
return result.items;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Resume all interrupted runs.
|
|
963
|
+
* Useful for recovering after a server restart.
|
|
964
|
+
*
|
|
965
|
+
* @returns Array of resumed run IDs
|
|
966
|
+
*/
|
|
967
|
+
async resumeAllInterrupted() {
|
|
968
|
+
const resumableRuns = await this.getResumableRuns();
|
|
969
|
+
const resumedIds = [];
|
|
970
|
+
for (const run of resumableRuns) {
|
|
971
|
+
if (this.registry.has(run.kind)) {
|
|
972
|
+
try {
|
|
973
|
+
await this.resumeRun(run.id);
|
|
974
|
+
resumedIds.push(run.id);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
this.logger.error(`Failed to resume run ${run.id}:`, error);
|
|
977
|
+
}
|
|
978
|
+
} else {
|
|
979
|
+
this.logger.warn(
|
|
980
|
+
`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return resumedIds;
|
|
985
|
+
}
|
|
986
|
+
// ============================================================================
|
|
987
|
+
// Event Subscription
|
|
988
|
+
// ============================================================================
|
|
989
|
+
/**
|
|
990
|
+
* Subscribe to events for a specific run.
|
|
991
|
+
*
|
|
992
|
+
* @param runId - The run ID to subscribe to
|
|
993
|
+
* @param callback - Event handler
|
|
994
|
+
* @returns Unsubscribe function
|
|
995
|
+
*/
|
|
996
|
+
subscribeToRun(runId, callback) {
|
|
997
|
+
return this.events.subscribe(runId, callback);
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Subscribe to all workflow events.
|
|
1001
|
+
*
|
|
1002
|
+
* @param callback - Event handler
|
|
1003
|
+
* @returns Unsubscribe function
|
|
1004
|
+
*/
|
|
1005
|
+
subscribeToAll(callback) {
|
|
1006
|
+
return this.events.subscribeAll(callback);
|
|
1007
|
+
}
|
|
1008
|
+
// ============================================================================
|
|
1009
|
+
// Storage Access
|
|
1010
|
+
// ============================================================================
|
|
1011
|
+
/**
|
|
1012
|
+
* Get the storage adapter.
|
|
1013
|
+
* Useful for querying runs and steps directly.
|
|
1014
|
+
*/
|
|
1015
|
+
getStorage() {
|
|
1016
|
+
return this.storage;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Get the event transport.
|
|
1020
|
+
* Useful for custom event handling.
|
|
1021
|
+
*/
|
|
1022
|
+
getEvents() {
|
|
1023
|
+
return this.events;
|
|
1024
|
+
}
|
|
1025
|
+
// ============================================================================
|
|
1026
|
+
// Lifecycle
|
|
1027
|
+
// ============================================================================
|
|
1028
|
+
/**
|
|
1029
|
+
* Shutdown the engine gracefully.
|
|
1030
|
+
* Cancels all active runs and closes resources.
|
|
1031
|
+
*/
|
|
1032
|
+
async shutdown() {
|
|
1033
|
+
this.logger.info("Shutting down workflow engine...");
|
|
1034
|
+
for (const [runId, controller] of this.activeRuns) {
|
|
1035
|
+
this.logger.debug(`Canceling active run: ${runId}`);
|
|
1036
|
+
controller.abort();
|
|
1037
|
+
}
|
|
1038
|
+
if (this.events.close) {
|
|
1039
|
+
this.events.close();
|
|
1040
|
+
}
|
|
1041
|
+
if (this.storage.close) {
|
|
1042
|
+
await this.storage.close();
|
|
1043
|
+
}
|
|
1044
|
+
this.activeRuns.clear();
|
|
1045
|
+
this.logger.info("Workflow engine shutdown complete");
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
// src/planning/registry.ts
|
|
1050
|
+
import RE2 from "re2";
|
|
1051
|
+
var MemoryStepHandlerRegistry = class {
|
|
1052
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1053
|
+
tagIndex = /* @__PURE__ */ new Map();
|
|
1054
|
+
/** Register a step handler. Throws if a handler with the same ID is already registered. */
|
|
1055
|
+
register(handler) {
|
|
1056
|
+
if (this.handlers.has(handler.id)) {
|
|
1057
|
+
throw new Error(`Step handler '${handler.id}' is already registered`);
|
|
1058
|
+
}
|
|
1059
|
+
this.handlers.set(handler.id, handler);
|
|
1060
|
+
if (handler.tags) {
|
|
1061
|
+
for (const tag of handler.tags) {
|
|
1062
|
+
if (!this.tagIndex.has(tag)) {
|
|
1063
|
+
this.tagIndex.set(tag, /* @__PURE__ */ new Set());
|
|
1064
|
+
}
|
|
1065
|
+
this.tagIndex.get(tag).add(handler.id);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
/** Get a handler by its unique ID, or undefined if not registered. */
|
|
1070
|
+
get(id) {
|
|
1071
|
+
return this.handlers.get(id);
|
|
1072
|
+
}
|
|
1073
|
+
/** Check whether a handler with the given ID is registered. */
|
|
1074
|
+
has(id) {
|
|
1075
|
+
return this.handlers.has(id);
|
|
1076
|
+
}
|
|
1077
|
+
/** List all registered step handlers. */
|
|
1078
|
+
list() {
|
|
1079
|
+
return Array.from(this.handlers.values());
|
|
1080
|
+
}
|
|
1081
|
+
/** List all handlers tagged with the given tag. */
|
|
1082
|
+
listByTag(tag) {
|
|
1083
|
+
const ids = this.tagIndex.get(tag);
|
|
1084
|
+
if (!ids) return [];
|
|
1085
|
+
return Array.from(ids).map((id) => this.handlers.get(id)).filter((h) => h !== void 0);
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Resolve a handler reference to a WorkflowStep handler function.
|
|
1089
|
+
* Returns undefined if the handler is not found.
|
|
1090
|
+
*/
|
|
1091
|
+
resolve(handlerRef) {
|
|
1092
|
+
const registered = this.handlers.get(handlerRef);
|
|
1093
|
+
return registered?.handler;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Clear all registered handlers (useful for testing).
|
|
1097
|
+
*/
|
|
1098
|
+
clear() {
|
|
1099
|
+
this.handlers.clear();
|
|
1100
|
+
this.tagIndex.clear();
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
var MemoryRecipeRegistry = class {
|
|
1104
|
+
recipes = /* @__PURE__ */ new Map();
|
|
1105
|
+
kindIndex = /* @__PURE__ */ new Map();
|
|
1106
|
+
variantIndex = /* @__PURE__ */ new Map();
|
|
1107
|
+
// "kind:variant" -> recipeId
|
|
1108
|
+
tagIndex = /* @__PURE__ */ new Map();
|
|
1109
|
+
/** Register a single recipe. Throws if a recipe with the same ID or kind:variant pair is already registered. */
|
|
1110
|
+
register(recipe) {
|
|
1111
|
+
if (this.recipes.has(recipe.id)) {
|
|
1112
|
+
throw new Error(`Recipe '${recipe.id}' is already registered`);
|
|
1113
|
+
}
|
|
1114
|
+
this.recipes.set(recipe.id, recipe);
|
|
1115
|
+
if (!this.kindIndex.has(recipe.workflowKind)) {
|
|
1116
|
+
this.kindIndex.set(recipe.workflowKind, /* @__PURE__ */ new Set());
|
|
1117
|
+
}
|
|
1118
|
+
this.kindIndex.get(recipe.workflowKind).add(recipe.id);
|
|
1119
|
+
const variantKey = `${recipe.workflowKind}:${recipe.variant}`;
|
|
1120
|
+
if (this.variantIndex.has(variantKey)) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
`Recipe variant '${recipe.variant}' for '${recipe.workflowKind}' is already registered`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
this.variantIndex.set(variantKey, recipe.id);
|
|
1126
|
+
if (recipe.tags) {
|
|
1127
|
+
for (const tag of recipe.tags) {
|
|
1128
|
+
if (!this.tagIndex.has(tag)) {
|
|
1129
|
+
this.tagIndex.set(tag, /* @__PURE__ */ new Set());
|
|
1130
|
+
}
|
|
1131
|
+
this.tagIndex.get(tag).add(recipe.id);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/** Register multiple recipes at once. */
|
|
1136
|
+
registerAll(recipes) {
|
|
1137
|
+
for (const recipe of recipes) {
|
|
1138
|
+
this.register(recipe);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/** Get a recipe by its unique ID, or undefined if not registered. */
|
|
1142
|
+
get(recipeId) {
|
|
1143
|
+
return this.recipes.get(recipeId);
|
|
1144
|
+
}
|
|
1145
|
+
/** Check whether a recipe with the given ID is registered. */
|
|
1146
|
+
has(recipeId) {
|
|
1147
|
+
return this.recipes.has(recipeId);
|
|
1148
|
+
}
|
|
1149
|
+
/** Get all recipes registered for a given workflow kind. */
|
|
1150
|
+
getByKind(workflowKind) {
|
|
1151
|
+
const ids = this.kindIndex.get(workflowKind);
|
|
1152
|
+
if (!ids) return [];
|
|
1153
|
+
return Array.from(ids).map((id) => this.recipes.get(id)).filter((r) => r !== void 0);
|
|
1154
|
+
}
|
|
1155
|
+
/** Get the recipe for a specific workflow kind and variant combination. */
|
|
1156
|
+
getVariant(workflowKind, variant) {
|
|
1157
|
+
const variantKey = `${workflowKind}:${variant}`;
|
|
1158
|
+
const recipeId = this.variantIndex.get(variantKey);
|
|
1159
|
+
if (!recipeId) return void 0;
|
|
1160
|
+
return this.recipes.get(recipeId);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Get the default recipe for a workflow kind.
|
|
1164
|
+
* Returns the 'default' variant if one exists, otherwise falls back to the
|
|
1165
|
+
* recipe with the lowest numeric priority value (lower number = higher precedence).
|
|
1166
|
+
*/
|
|
1167
|
+
getDefault(workflowKind) {
|
|
1168
|
+
const defaultRecipe = this.getVariant(workflowKind, "default");
|
|
1169
|
+
if (defaultRecipe) return defaultRecipe;
|
|
1170
|
+
const recipes = this.getByKind(workflowKind);
|
|
1171
|
+
if (recipes.length === 0) return void 0;
|
|
1172
|
+
return recipes.reduce((lowest, current) => {
|
|
1173
|
+
const currentPriority = current.priority ?? 0;
|
|
1174
|
+
const lowestPriority = lowest.priority ?? 0;
|
|
1175
|
+
return currentPriority < lowestPriority ? current : lowest;
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
/** List all variant names registered for a workflow kind. */
|
|
1179
|
+
listVariants(workflowKind) {
|
|
1180
|
+
const recipes = this.getByKind(workflowKind);
|
|
1181
|
+
return recipes.map((r) => r.variant);
|
|
1182
|
+
}
|
|
1183
|
+
/** Query recipes with optional filters for kind, variant, tags, and input conditions. */
|
|
1184
|
+
query(options) {
|
|
1185
|
+
let results = this.list();
|
|
1186
|
+
if (options.workflowKind) {
|
|
1187
|
+
results = results.filter((r) => r.workflowKind === options.workflowKind);
|
|
1188
|
+
}
|
|
1189
|
+
if (options.variant) {
|
|
1190
|
+
results = results.filter((r) => r.variant === options.variant);
|
|
1191
|
+
}
|
|
1192
|
+
if (options.tags && options.tags.length > 0) {
|
|
1193
|
+
results = results.filter(
|
|
1194
|
+
(r) => options.tags.some((tag) => r.tags?.includes(tag))
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
if (options.matchConditions) {
|
|
1198
|
+
results = results.filter((r) => {
|
|
1199
|
+
if (!r.conditions || r.conditions.length === 0) return true;
|
|
1200
|
+
return this.evaluateConditions(r.conditions, options.matchConditions);
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
return results;
|
|
1204
|
+
}
|
|
1205
|
+
/** List all registered recipes. */
|
|
1206
|
+
list() {
|
|
1207
|
+
return Array.from(this.recipes.values());
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Clear all registered recipes (useful for testing).
|
|
1211
|
+
*/
|
|
1212
|
+
clear() {
|
|
1213
|
+
this.recipes.clear();
|
|
1214
|
+
this.kindIndex.clear();
|
|
1215
|
+
this.variantIndex.clear();
|
|
1216
|
+
this.tagIndex.clear();
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Evaluate recipe conditions against an input.
|
|
1220
|
+
* Returns true if all conditions match.
|
|
1221
|
+
*
|
|
1222
|
+
* Note: This intentionally mirrors the condition evaluation in planner.ts
|
|
1223
|
+
* to keep registry queries independent of the planner implementation.
|
|
1224
|
+
*/
|
|
1225
|
+
evaluateConditions(conditions, input) {
|
|
1226
|
+
if (!conditions || conditions.length === 0) return true;
|
|
1227
|
+
return conditions.every((condition) => {
|
|
1228
|
+
const fieldValue = this.getNestedValue(input, condition.field);
|
|
1229
|
+
return this.evaluateCondition(condition, fieldValue);
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Get a nested value from an object using dot notation.
|
|
1234
|
+
*/
|
|
1235
|
+
getNestedValue(obj, path) {
|
|
1236
|
+
return path.split(".").reduce((current, key) => {
|
|
1237
|
+
if (current && typeof current === "object" && key in current) {
|
|
1238
|
+
return current[key];
|
|
1239
|
+
}
|
|
1240
|
+
return void 0;
|
|
1241
|
+
}, obj);
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Evaluate a single condition.
|
|
1245
|
+
*/
|
|
1246
|
+
evaluateCondition(condition, fieldValue) {
|
|
1247
|
+
const { operator, value } = condition;
|
|
1248
|
+
switch (operator) {
|
|
1249
|
+
case "eq":
|
|
1250
|
+
return fieldValue === value;
|
|
1251
|
+
case "neq":
|
|
1252
|
+
return fieldValue !== value;
|
|
1253
|
+
case "gt":
|
|
1254
|
+
return typeof fieldValue === "number" && typeof value === "number" ? fieldValue > value : false;
|
|
1255
|
+
case "gte":
|
|
1256
|
+
return typeof fieldValue === "number" && typeof value === "number" ? fieldValue >= value : false;
|
|
1257
|
+
case "lt":
|
|
1258
|
+
return typeof fieldValue === "number" && typeof value === "number" ? fieldValue < value : false;
|
|
1259
|
+
case "lte":
|
|
1260
|
+
return typeof fieldValue === "number" && typeof value === "number" ? fieldValue <= value : false;
|
|
1261
|
+
case "contains":
|
|
1262
|
+
if (typeof fieldValue === "string" && typeof value === "string") {
|
|
1263
|
+
return fieldValue.includes(value);
|
|
1264
|
+
}
|
|
1265
|
+
if (Array.isArray(fieldValue)) {
|
|
1266
|
+
return fieldValue.includes(value);
|
|
1267
|
+
}
|
|
1268
|
+
return false;
|
|
1269
|
+
case "matches":
|
|
1270
|
+
if (typeof fieldValue === "string" && typeof value === "string") {
|
|
1271
|
+
try {
|
|
1272
|
+
return new RE2(value).test(fieldValue);
|
|
1273
|
+
} catch {
|
|
1274
|
+
return false;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return false;
|
|
1278
|
+
case "exists":
|
|
1279
|
+
return fieldValue !== void 0 && fieldValue !== null && fieldValue !== "";
|
|
1280
|
+
case "notExists":
|
|
1281
|
+
return fieldValue === void 0 || fieldValue === null || fieldValue === "";
|
|
1282
|
+
default:
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
function createRegistry() {
|
|
1288
|
+
return {
|
|
1289
|
+
recipes: new MemoryRecipeRegistry(),
|
|
1290
|
+
handlers: new MemoryStepHandlerRegistry()
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/planning/planner.ts
|
|
1295
|
+
import RE22 from "re2";
|
|
1296
|
+
function getNestedValue(obj, path) {
|
|
1297
|
+
return path.split(".").reduce((current, key) => {
|
|
1298
|
+
if (current && typeof current === "object" && key in current) {
|
|
1299
|
+
return current[key];
|
|
1300
|
+
}
|
|
1301
|
+
return void 0;
|
|
1302
|
+
}, obj);
|
|
1303
|
+
}
|
|
1304
|
+
function evaluateCondition(operator, fieldValue, conditionValue) {
|
|
1305
|
+
switch (operator) {
|
|
1306
|
+
case "eq":
|
|
1307
|
+
return fieldValue === conditionValue;
|
|
1308
|
+
case "neq":
|
|
1309
|
+
return fieldValue !== conditionValue;
|
|
1310
|
+
case "gt":
|
|
1311
|
+
return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue > conditionValue : false;
|
|
1312
|
+
case "gte":
|
|
1313
|
+
return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue >= conditionValue : false;
|
|
1314
|
+
case "lt":
|
|
1315
|
+
return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue < conditionValue : false;
|
|
1316
|
+
case "lte":
|
|
1317
|
+
return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue <= conditionValue : false;
|
|
1318
|
+
case "contains":
|
|
1319
|
+
if (typeof fieldValue === "string" && typeof conditionValue === "string") {
|
|
1320
|
+
return fieldValue.includes(conditionValue);
|
|
1321
|
+
}
|
|
1322
|
+
if (Array.isArray(fieldValue)) {
|
|
1323
|
+
return fieldValue.includes(conditionValue);
|
|
1324
|
+
}
|
|
1325
|
+
return false;
|
|
1326
|
+
case "matches":
|
|
1327
|
+
if (typeof fieldValue === "string" && typeof conditionValue === "string") {
|
|
1328
|
+
try {
|
|
1329
|
+
return new RE22(conditionValue).test(fieldValue);
|
|
1330
|
+
} catch {
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return false;
|
|
1335
|
+
case "exists":
|
|
1336
|
+
return fieldValue !== void 0 && fieldValue !== null && fieldValue !== "";
|
|
1337
|
+
case "notExists":
|
|
1338
|
+
return fieldValue === void 0 || fieldValue === null || fieldValue === "";
|
|
1339
|
+
default:
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
function scoreConditions(conditions, input) {
|
|
1344
|
+
if (!conditions || conditions.length === 0) {
|
|
1345
|
+
return 10;
|
|
1346
|
+
}
|
|
1347
|
+
let matched = 0;
|
|
1348
|
+
for (const condition of conditions) {
|
|
1349
|
+
const fieldValue = getNestedValue(input, condition.field);
|
|
1350
|
+
if (evaluateCondition(condition.operator, fieldValue, condition.value)) {
|
|
1351
|
+
matched++;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (matched < conditions.length) {
|
|
1355
|
+
return 0;
|
|
1356
|
+
}
|
|
1357
|
+
return Math.min(100, 50 + conditions.length * 10);
|
|
1358
|
+
}
|
|
1359
|
+
var RuleBasedPlanner = class {
|
|
1360
|
+
recipeRegistry;
|
|
1361
|
+
handlerRegistry;
|
|
1362
|
+
validateHandlers;
|
|
1363
|
+
resourceEstimates;
|
|
1364
|
+
constructor(config) {
|
|
1365
|
+
this.recipeRegistry = config.recipeRegistry;
|
|
1366
|
+
this.handlerRegistry = config.handlerRegistry;
|
|
1367
|
+
this.validateHandlers = config.validateHandlers ?? false;
|
|
1368
|
+
this.resourceEstimates = {
|
|
1369
|
+
apiCallsPerStep: config.resourceEstimates?.apiCallsPerStep ?? 1,
|
|
1370
|
+
tokensPerStep: config.resourceEstimates?.tokensPerStep ?? 500,
|
|
1371
|
+
durationPerStep: config.resourceEstimates?.durationPerStep ?? 2e3,
|
|
1372
|
+
apiCallsPerChild: config.resourceEstimates?.apiCallsPerChild ?? 5
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Select the best recipe for a workflow kind and input.
|
|
1377
|
+
*/
|
|
1378
|
+
async selectRecipe(workflowKind, input, context) {
|
|
1379
|
+
if (context?.hints?.forceRecipeId) {
|
|
1380
|
+
const forced = this.recipeRegistry.get(context.hints.forceRecipeId);
|
|
1381
|
+
if (forced) {
|
|
1382
|
+
return {
|
|
1383
|
+
recipe: forced,
|
|
1384
|
+
score: 100,
|
|
1385
|
+
reason: `Forced recipe: ${context.hints.forceRecipeId}`
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (context?.hints?.preferredVariant) {
|
|
1390
|
+
const preferred = this.recipeRegistry.getVariant(
|
|
1391
|
+
workflowKind,
|
|
1392
|
+
context.hints.preferredVariant
|
|
1393
|
+
);
|
|
1394
|
+
if (preferred) {
|
|
1395
|
+
return {
|
|
1396
|
+
recipe: preferred,
|
|
1397
|
+
score: 90,
|
|
1398
|
+
reason: `Preferred variant: ${context.hints.preferredVariant}`
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
const recipes = this.recipeRegistry.getByKind(workflowKind);
|
|
1403
|
+
if (recipes.length === 0) {
|
|
1404
|
+
throw new Error(`No recipes found for workflow kind: ${workflowKind}`);
|
|
1405
|
+
}
|
|
1406
|
+
const scored = recipes.map((recipe) => ({
|
|
1407
|
+
recipe,
|
|
1408
|
+
conditionScore: scoreConditions(recipe.conditions, input),
|
|
1409
|
+
priorityScore: recipe.priority ?? 0
|
|
1410
|
+
}));
|
|
1411
|
+
scored.sort((a, b) => {
|
|
1412
|
+
if (b.conditionScore !== a.conditionScore) {
|
|
1413
|
+
return b.conditionScore - a.conditionScore;
|
|
1414
|
+
}
|
|
1415
|
+
return b.priorityScore - a.priorityScore;
|
|
1416
|
+
});
|
|
1417
|
+
const best = scored.find((s) => s.conditionScore > 0);
|
|
1418
|
+
if (best) {
|
|
1419
|
+
const reason = this.buildSelectionReason(best.recipe, input);
|
|
1420
|
+
return {
|
|
1421
|
+
recipe: best.recipe,
|
|
1422
|
+
score: best.conditionScore,
|
|
1423
|
+
reason
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
const defaultRecipe = this.recipeRegistry.getDefault(workflowKind);
|
|
1427
|
+
if (defaultRecipe) {
|
|
1428
|
+
return {
|
|
1429
|
+
recipe: defaultRecipe,
|
|
1430
|
+
score: 10,
|
|
1431
|
+
reason: `Fallback to default recipe: ${defaultRecipe.id}`
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
recipe: recipes[0],
|
|
1436
|
+
score: 5,
|
|
1437
|
+
reason: `No matching recipe, using first available: ${recipes[0].id}`
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Generate a plan from a recipe and input.
|
|
1442
|
+
*/
|
|
1443
|
+
async generatePlan(recipe, input, context) {
|
|
1444
|
+
const modifications = [];
|
|
1445
|
+
let steps = recipe.steps.map((step) => this.recipeStepToPlannedStep(step));
|
|
1446
|
+
if (context?.hints) {
|
|
1447
|
+
const { skipSteps, includeSteps, additionalConfig } = context.hints;
|
|
1448
|
+
if (skipSteps && skipSteps.length > 0) {
|
|
1449
|
+
for (const stepKey of skipSteps) {
|
|
1450
|
+
const stepIndex = steps.findIndex((s) => s.key === stepKey);
|
|
1451
|
+
if (stepIndex >= 0) {
|
|
1452
|
+
steps = steps.filter((s) => s.key !== stepKey);
|
|
1453
|
+
modifications.push({
|
|
1454
|
+
type: "remove_step",
|
|
1455
|
+
stepKey,
|
|
1456
|
+
value: null,
|
|
1457
|
+
reason: `Skipped via planning hints`
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (additionalConfig) {
|
|
1463
|
+
for (const step of steps) {
|
|
1464
|
+
step.config = { ...step.config, ...additionalConfig };
|
|
1465
|
+
}
|
|
1466
|
+
modifications.push({
|
|
1467
|
+
type: "modify_step",
|
|
1468
|
+
value: additionalConfig,
|
|
1469
|
+
reason: "Applied additional config from planning hints"
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (context?.constraints) {
|
|
1474
|
+
const constraintMods = this.applyConstraints(steps, context.constraints);
|
|
1475
|
+
modifications.push(...constraintMods.modifications);
|
|
1476
|
+
steps = constraintMods.steps;
|
|
1477
|
+
}
|
|
1478
|
+
const plan = {
|
|
1479
|
+
id: generateId(),
|
|
1480
|
+
recipeId: recipe.id,
|
|
1481
|
+
variant: recipe.variant,
|
|
1482
|
+
modifications,
|
|
1483
|
+
steps,
|
|
1484
|
+
defaults: recipe.defaults ?? {},
|
|
1485
|
+
reasoning: this.buildPlanReasoning(recipe, modifications, context),
|
|
1486
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1487
|
+
};
|
|
1488
|
+
plan.resourceEstimate = this.estimateResources(plan);
|
|
1489
|
+
return plan;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Combined operation: select recipe and generate plan.
|
|
1493
|
+
*/
|
|
1494
|
+
async plan(workflowKind, input, context) {
|
|
1495
|
+
const selection = await this.selectRecipe(workflowKind, input, context);
|
|
1496
|
+
const plan = await this.generatePlan(selection.recipe, input, context);
|
|
1497
|
+
plan.reasoning = `${selection.reason}. ${plan.reasoning ?? ""}`.trim();
|
|
1498
|
+
return plan;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Validate a plan before execution.
|
|
1502
|
+
*/
|
|
1503
|
+
validatePlan(plan) {
|
|
1504
|
+
const errors = [];
|
|
1505
|
+
const warnings = [];
|
|
1506
|
+
if (!plan.steps || plan.steps.length === 0) {
|
|
1507
|
+
errors.push("Plan has no steps");
|
|
1508
|
+
}
|
|
1509
|
+
const stepKeys = /* @__PURE__ */ new Set();
|
|
1510
|
+
for (const step of plan.steps) {
|
|
1511
|
+
if (stepKeys.has(step.key)) {
|
|
1512
|
+
errors.push(`Duplicate step key: ${step.key}`);
|
|
1513
|
+
}
|
|
1514
|
+
stepKeys.add(step.key);
|
|
1515
|
+
}
|
|
1516
|
+
for (const step of plan.steps) {
|
|
1517
|
+
if (!step.key) {
|
|
1518
|
+
errors.push("Step missing key");
|
|
1519
|
+
}
|
|
1520
|
+
if (!step.name) {
|
|
1521
|
+
warnings.push(`Step '${step.key}' missing name`);
|
|
1522
|
+
}
|
|
1523
|
+
if (!step.handlerRef) {
|
|
1524
|
+
errors.push(`Step '${step.key}' missing handlerRef`);
|
|
1525
|
+
}
|
|
1526
|
+
if (this.validateHandlers && this.handlerRegistry) {
|
|
1527
|
+
if (!this.handlerRegistry.has(step.handlerRef)) {
|
|
1528
|
+
errors.push(
|
|
1529
|
+
`Step '${step.key}' references unknown handler: ${step.handlerRef}`
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (plan.childWorkflows) {
|
|
1535
|
+
for (const child of plan.childWorkflows) {
|
|
1536
|
+
if (!child.kind) {
|
|
1537
|
+
errors.push("Child workflow missing kind");
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return {
|
|
1542
|
+
valid: errors.length === 0,
|
|
1543
|
+
errors,
|
|
1544
|
+
warnings
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Estimate resources required for a plan.
|
|
1549
|
+
*/
|
|
1550
|
+
estimateResources(plan) {
|
|
1551
|
+
const {
|
|
1552
|
+
apiCallsPerStep,
|
|
1553
|
+
tokensPerStep,
|
|
1554
|
+
durationPerStep,
|
|
1555
|
+
apiCallsPerChild
|
|
1556
|
+
} = this.resourceEstimates;
|
|
1557
|
+
const stepCount = plan.steps.length;
|
|
1558
|
+
const childCount = plan.childWorkflows?.length ?? 0;
|
|
1559
|
+
return {
|
|
1560
|
+
apiCalls: stepCount * apiCallsPerStep + childCount * apiCallsPerChild,
|
|
1561
|
+
tokens: stepCount * tokensPerStep,
|
|
1562
|
+
duration: stepCount * durationPerStep
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
// ============================================================================
|
|
1566
|
+
// Private Helpers
|
|
1567
|
+
// ============================================================================
|
|
1568
|
+
/**
|
|
1569
|
+
* Convert a recipe step to a planned step.
|
|
1570
|
+
*/
|
|
1571
|
+
recipeStepToPlannedStep(step) {
|
|
1572
|
+
return {
|
|
1573
|
+
key: step.key,
|
|
1574
|
+
name: step.name,
|
|
1575
|
+
handlerRef: step.handlerRef,
|
|
1576
|
+
config: step.config ?? {},
|
|
1577
|
+
onError: step.onError,
|
|
1578
|
+
maxRetries: step.maxRetries,
|
|
1579
|
+
retryDelay: step.retryDelay,
|
|
1580
|
+
retryBackoff: step.retryBackoff,
|
|
1581
|
+
timeout: step.timeout,
|
|
1582
|
+
skipCondition: step.skipCondition
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Apply constraints to steps and generate modifications.
|
|
1587
|
+
*/
|
|
1588
|
+
applyConstraints(steps, constraints) {
|
|
1589
|
+
const modifications = [];
|
|
1590
|
+
const perStepTimeout = constraints.maxDuration ? Math.floor(constraints.maxDuration / steps.length) : void 0;
|
|
1591
|
+
const modifiedSteps = steps.map((step) => {
|
|
1592
|
+
const modified = { ...step };
|
|
1593
|
+
if (constraints.priority === "speed") {
|
|
1594
|
+
modified.timeout = modified.timeout ? Math.min(modified.timeout, 3e4) : 3e4;
|
|
1595
|
+
modified.maxRetries = Math.min(modified.maxRetries ?? 3, 1);
|
|
1596
|
+
}
|
|
1597
|
+
if (constraints.priority === "cost") {
|
|
1598
|
+
modified.maxRetries = 0;
|
|
1599
|
+
}
|
|
1600
|
+
if (perStepTimeout !== void 0) {
|
|
1601
|
+
modified.timeout = modified.timeout ? Math.min(modified.timeout, perStepTimeout) : perStepTimeout;
|
|
1602
|
+
}
|
|
1603
|
+
return modified;
|
|
1604
|
+
});
|
|
1605
|
+
if (constraints.priority === "speed") {
|
|
1606
|
+
modifications.push({
|
|
1607
|
+
type: "set_default",
|
|
1608
|
+
value: { priority: "speed" },
|
|
1609
|
+
reason: "Optimized for speed: reduced timeouts and retries"
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
if (constraints.priority === "cost") {
|
|
1613
|
+
modifications.push({
|
|
1614
|
+
type: "set_default",
|
|
1615
|
+
value: { priority: "cost" },
|
|
1616
|
+
reason: "Optimized for cost: disabled retries"
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
if (constraints.maxDuration) {
|
|
1620
|
+
modifications.push({
|
|
1621
|
+
type: "set_default",
|
|
1622
|
+
value: { maxDuration: constraints.maxDuration },
|
|
1623
|
+
reason: `Applied duration constraint: ${constraints.maxDuration}ms total`
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
return { steps: modifiedSteps, modifications };
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Build a human-readable selection reason.
|
|
1630
|
+
*/
|
|
1631
|
+
buildSelectionReason(recipe, input) {
|
|
1632
|
+
const parts = [`Selected recipe: ${recipe.id}`];
|
|
1633
|
+
if (recipe.conditions && recipe.conditions.length > 0) {
|
|
1634
|
+
const conditionDescriptions = recipe.conditions.map((c) => {
|
|
1635
|
+
const value = getNestedValue(input, c.field);
|
|
1636
|
+
return `${c.field} ${c.operator} ${JSON.stringify(c.value)} (actual: ${JSON.stringify(value)})`;
|
|
1637
|
+
});
|
|
1638
|
+
parts.push(`Matched conditions: ${conditionDescriptions.join(", ")}`);
|
|
1639
|
+
}
|
|
1640
|
+
if (recipe.priority !== void 0 && recipe.priority > 0) {
|
|
1641
|
+
parts.push(`Priority: ${recipe.priority}`);
|
|
1642
|
+
}
|
|
1643
|
+
return parts.join(". ");
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Build reasoning text for a plan.
|
|
1647
|
+
*/
|
|
1648
|
+
buildPlanReasoning(recipe, modifications, context) {
|
|
1649
|
+
const parts = [];
|
|
1650
|
+
parts.push(`Using recipe '${recipe.name}' (${recipe.variant} variant)`);
|
|
1651
|
+
if (modifications.length > 0) {
|
|
1652
|
+
parts.push(`Applied ${modifications.length} modification(s)`);
|
|
1653
|
+
}
|
|
1654
|
+
if (context?.constraints) {
|
|
1655
|
+
const constraintList = [];
|
|
1656
|
+
if (context.constraints.priority) {
|
|
1657
|
+
constraintList.push(`priority=${context.constraints.priority}`);
|
|
1658
|
+
}
|
|
1659
|
+
if (context.constraints.maxDuration) {
|
|
1660
|
+
constraintList.push(`maxDuration=${context.constraints.maxDuration}ms`);
|
|
1661
|
+
}
|
|
1662
|
+
if (context.constraints.maxApiCalls) {
|
|
1663
|
+
constraintList.push(`maxApiCalls=${context.constraints.maxApiCalls}`);
|
|
1664
|
+
}
|
|
1665
|
+
if (constraintList.length > 0) {
|
|
1666
|
+
parts.push(`Constraints: ${constraintList.join(", ")}`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return parts.join(". ") + ".";
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
export {
|
|
1673
|
+
ConsoleLogger,
|
|
1674
|
+
CronScheduler,
|
|
1675
|
+
DEFAULT_RETRY_OPTIONS,
|
|
1676
|
+
MemoryEventTransport,
|
|
1677
|
+
MemoryRecipeRegistry,
|
|
1678
|
+
MemoryStepHandlerRegistry,
|
|
1679
|
+
MemoryStorageAdapter,
|
|
1680
|
+
PostgresSchedulePersistence,
|
|
1681
|
+
PostgresStorageAdapter as PostgresStorage,
|
|
1682
|
+
PostgresStorageAdapter,
|
|
1683
|
+
RuleBasedPlanner,
|
|
1684
|
+
RunNotFoundError,
|
|
1685
|
+
SQLiteSchedulePersistence,
|
|
1686
|
+
SQLiteStorageAdapter,
|
|
1687
|
+
SilentLogger,
|
|
1688
|
+
SocketIOEventTransport,
|
|
1689
|
+
StepError,
|
|
1690
|
+
StepTimeoutError,
|
|
1691
|
+
WaitForRunTimeoutError,
|
|
1692
|
+
WebhookEventTransport,
|
|
1693
|
+
WorkflowAlreadyRegisteredError,
|
|
1694
|
+
WorkflowCanceledError,
|
|
1695
|
+
WorkflowEngine,
|
|
1696
|
+
WorkflowEngineError,
|
|
1697
|
+
WorkflowNotFoundError,
|
|
1698
|
+
WorkflowTimeoutError,
|
|
1699
|
+
calculateRetryDelay,
|
|
1700
|
+
createRegistry,
|
|
1701
|
+
createScopedLogger,
|
|
1702
|
+
generateId,
|
|
1703
|
+
sanitizeErrorForStorage,
|
|
1704
|
+
sleep,
|
|
1705
|
+
withRetry
|
|
1706
|
+
};
|
|
1707
|
+
//# sourceMappingURL=index.js.map
|