@kb-labs/workflow-engine 2.116.14 → 2.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +20 -0
- package/dist/index.js +179 -4
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
package/dist/index.d.ts
CHANGED
|
@@ -225,6 +225,19 @@ declare class WorkflowEngine {
|
|
|
225
225
|
runFromInline(spec: unknown, input: Omit<CreateRunInput, 'spec'>): Promise<WorkflowRun>;
|
|
226
226
|
getRun(runId: string): Promise<WorkflowRun | null>;
|
|
227
227
|
cancelRun(runId: string): Promise<void>;
|
|
228
|
+
/**
|
|
229
|
+
* Reconcile all persisted parent/child links. This is deliberately engine-side
|
|
230
|
+
* instead of a worker-local polling task: it is safe to call after a daemon
|
|
231
|
+
* restart and is idempotent when several reconciliations race.
|
|
232
|
+
*/
|
|
233
|
+
reconcileChildInvocations(): Promise<number>;
|
|
234
|
+
/** Run reconciliation on terminal-child events; startup calls it once too. */
|
|
235
|
+
reconcileChildInvocation(childRunId: string): Promise<number>;
|
|
236
|
+
private childResultEnvelope;
|
|
237
|
+
private resolveDeclaredOutputs;
|
|
238
|
+
private failChildInvocation;
|
|
239
|
+
private listStoredRuns;
|
|
240
|
+
private findChildRuns;
|
|
228
241
|
/**
|
|
229
242
|
* Mark job as failed and optionally schedule retry.
|
|
230
243
|
* Implements exponential/linear backoff retry logic.
|
|
@@ -281,6 +294,13 @@ declare class WorkflowEngine {
|
|
|
281
294
|
* Mark step as waiting for human approval.
|
|
282
295
|
*/
|
|
283
296
|
markStepWaitingApproval(runId: string, jobId: string, stepId: string): Promise<void>;
|
|
297
|
+
/**
|
|
298
|
+
* Park a step while its child workflow runs. The worker returns after this
|
|
299
|
+
* transition, so parent workflows never consume the pool needed by children.
|
|
300
|
+
*/
|
|
301
|
+
markStepWaitingChild(runId: string, jobId: string, stepId: string, childRunId: string): Promise<void>;
|
|
302
|
+
/** Re-queue a parked parent job after its child workflow reaches a terminal state. */
|
|
303
|
+
resumeJob(runId: string, jobId: string): Promise<void>;
|
|
284
304
|
/**
|
|
285
305
|
* Resolve a pending approval — approve or reject.
|
|
286
306
|
* On approve: marks step as success with approval outputs.
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, access, mkdir, writeFile, readdir } from 'fs/promises';
|
|
2
2
|
import { resolve, join, basename } from 'path';
|
|
3
3
|
import { parse, stringify } from 'yaml';
|
|
4
|
-
import { WorkflowSpecSchema, evaluateExpression } from '@kb-labs/workflow-contracts';
|
|
4
|
+
import { WorkflowSpecSchema, resolveExpression, evaluateExpression } from '@kb-labs/workflow-contracts';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { WORKFLOW_REDIS_CHANNEL, EVENT_NAMES, IDEMPOTENCY_TTL_ENV, CONCURRENCY_TTL_ENV } from '@kb-labs/workflow-constants';
|
|
7
7
|
import { classifyFailure, decideRetry } from '@kb-labs/core-retry';
|
|
@@ -79,6 +79,7 @@ ${issues}`);
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
// src/state-store.ts
|
|
82
|
+
var RUN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
82
83
|
var StateStore = class {
|
|
83
84
|
constructor(cache, logger) {
|
|
84
85
|
this.logger = logger;
|
|
@@ -89,7 +90,7 @@ var StateStore = class {
|
|
|
89
90
|
async saveRun(run) {
|
|
90
91
|
const key = `kb:run:${run.id}`;
|
|
91
92
|
this.logger.debug("Persisting workflow run", { runId: run.id, key });
|
|
92
|
-
await this.cache.set(key, JSON.stringify(run));
|
|
93
|
+
await this.cache.set(key, JSON.stringify(run), RUN_TTL_MS);
|
|
93
94
|
const timestamp = new Date(run.createdAt).getTime();
|
|
94
95
|
await this.cache.zadd("workflow:runs:index", timestamp, run.id);
|
|
95
96
|
}
|
|
@@ -330,7 +331,7 @@ var RunCoordinator = class {
|
|
|
330
331
|
createdAt: timestamp,
|
|
331
332
|
queuedAt: timestamp,
|
|
332
333
|
trigger: input.trigger,
|
|
333
|
-
env: input.spec.env,
|
|
334
|
+
env: input.env ?? input.spec.env,
|
|
334
335
|
inputs: input.inputs,
|
|
335
336
|
secrets: input.spec.secrets,
|
|
336
337
|
jobs,
|
|
@@ -338,7 +339,9 @@ var RunCoordinator = class {
|
|
|
338
339
|
idempotencyKey: input.idempotencyKey,
|
|
339
340
|
concurrencyGroup: input.concurrencyGroup,
|
|
340
341
|
target: input.spec.target,
|
|
341
|
-
isolation: input.spec.isolation
|
|
342
|
+
isolation: input.spec.isolation,
|
|
343
|
+
outputDeclarations: input.spec.outputs,
|
|
344
|
+
...input.metadata
|
|
342
345
|
},
|
|
343
346
|
artifacts: []
|
|
344
347
|
};
|
|
@@ -774,6 +777,9 @@ var WorkflowEngine = class {
|
|
|
774
777
|
}
|
|
775
778
|
async cancelRun(runId) {
|
|
776
779
|
const run = await this.getRun(runId);
|
|
780
|
+
if (!run || ["success", "failed", "cancelled", "skipped", "dlq"].includes(run.status)) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
777
783
|
await this.stateStore.updateRun(runId, (draft) => {
|
|
778
784
|
draft.status = "cancelled";
|
|
779
785
|
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -790,6 +796,112 @@ var WorkflowEngine = class {
|
|
|
790
796
|
runId,
|
|
791
797
|
payload: { reason: "cancelled by parent workflow" }
|
|
792
798
|
});
|
|
799
|
+
const descendants = await this.findChildRuns(runId);
|
|
800
|
+
await Promise.all(descendants.map((child) => this.cancelRun(child.id)));
|
|
801
|
+
await this.reconcileChildInvocation(runId);
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Reconcile all persisted parent/child links. This is deliberately engine-side
|
|
805
|
+
* instead of a worker-local polling task: it is safe to call after a daemon
|
|
806
|
+
* restart and is idempotent when several reconciliations race.
|
|
807
|
+
*/
|
|
808
|
+
async reconcileChildInvocations() {
|
|
809
|
+
const runs = await this.listStoredRuns();
|
|
810
|
+
let reconciled = 0;
|
|
811
|
+
for (const parent of runs) {
|
|
812
|
+
for (const job of parent.jobs) {
|
|
813
|
+
for (const step of job.steps) {
|
|
814
|
+
if (step.status !== "waiting_child") {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const childRunId = step.metadata?.["childRunId"];
|
|
818
|
+
if (typeof childRunId !== "string") {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
const child = await this.getRun(childRunId);
|
|
822
|
+
if (parent.status === "cancelled") {
|
|
823
|
+
if (child) {
|
|
824
|
+
await this.cancelRun(child.id);
|
|
825
|
+
}
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (!child) {
|
|
829
|
+
await this.failChildInvocation(parent.id, job.id, step.id, childRunId, "not found");
|
|
830
|
+
reconciled++;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (!["success", "failed", "cancelled", "skipped", "dlq"].includes(child.status)) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (child.status === "success") {
|
|
837
|
+
const outputs = this.childResultEnvelope(child);
|
|
838
|
+
await this.markStepCompleted(parent.id, job.id, step.id, outputs);
|
|
839
|
+
await this.resumeJob(parent.id, job.id);
|
|
840
|
+
} else {
|
|
841
|
+
await this.failChildInvocation(parent.id, job.id, step.id, child.id, child.status);
|
|
842
|
+
}
|
|
843
|
+
reconciled++;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return reconciled;
|
|
848
|
+
}
|
|
849
|
+
/** Run reconciliation on terminal-child events; startup calls it once too. */
|
|
850
|
+
async reconcileChildInvocation(childRunId) {
|
|
851
|
+
const parents = (await this.listStoredRuns()).filter(
|
|
852
|
+
(run) => run.jobs.some((job) => job.steps.some(
|
|
853
|
+
(step) => step.status === "waiting_child" && step.metadata?.["childRunId"] === childRunId
|
|
854
|
+
))
|
|
855
|
+
);
|
|
856
|
+
if (parents.length === 0) {
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
return this.reconcileChildInvocations();
|
|
860
|
+
}
|
|
861
|
+
childResultEnvelope(child) {
|
|
862
|
+
const stepOutputs = {};
|
|
863
|
+
for (const job of child.jobs) {
|
|
864
|
+
for (const step of job.steps) {
|
|
865
|
+
if (step.status === "success" && step.spec.id && step.outputs) {
|
|
866
|
+
stepOutputs[step.spec.id] = step.outputs;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
runId: child.id,
|
|
872
|
+
status: child.status,
|
|
873
|
+
outputs: Object.keys(child.result?.outputs ?? {}).length > 0 ? child.result?.outputs : stepOutputs,
|
|
874
|
+
artifacts: child.artifacts ?? []
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
resolveDeclaredOutputs(run) {
|
|
878
|
+
const declarations = run.metadata?.outputDeclarations;
|
|
879
|
+
if (!declarations) {
|
|
880
|
+
return {};
|
|
881
|
+
}
|
|
882
|
+
const context = this.buildExpressionContext(run);
|
|
883
|
+
return Object.fromEntries(Object.entries(declarations).map(([name, declaration]) => {
|
|
884
|
+
const source = declaration && typeof declaration === "object" ? declaration.source : void 0;
|
|
885
|
+
return [name, typeof source === "string" ? resolveExpression(source, context) : void 0];
|
|
886
|
+
}));
|
|
887
|
+
}
|
|
888
|
+
async failChildInvocation(parentRunId, parentJobId, parentStepId, childRunId, childStatus) {
|
|
889
|
+
const error = new Error(`Child workflow ${childStatus} (run ${childRunId})`);
|
|
890
|
+
await this.markStepFailed(parentRunId, parentJobId, parentStepId, error, {
|
|
891
|
+
runId: childRunId,
|
|
892
|
+
status: childStatus
|
|
893
|
+
});
|
|
894
|
+
await this.markJobFailed(parentRunId, parentJobId, error, void 0, false);
|
|
895
|
+
}
|
|
896
|
+
async listStoredRuns() {
|
|
897
|
+
const runIds = await this.stateStore.getAllRunIds();
|
|
898
|
+
const runs = await Promise.all([...new Set(runIds)].map((id) => this.getRun(id)));
|
|
899
|
+
return runs.filter((run) => run !== null);
|
|
900
|
+
}
|
|
901
|
+
async findChildRuns(parentRunId) {
|
|
902
|
+
return (await this.listStoredRuns()).filter(
|
|
903
|
+
(run) => run.trigger.parentRunId === parentRunId || run.metadata?.parentRunId === parentRunId
|
|
904
|
+
);
|
|
793
905
|
}
|
|
794
906
|
/**
|
|
795
907
|
* Mark job as failed and optionally schedule retry.
|
|
@@ -1055,6 +1167,11 @@ var WorkflowEngine = class {
|
|
|
1055
1167
|
const updated = await this.stateStore.updateRun(runId, (draft) => {
|
|
1056
1168
|
draft.status = "success";
|
|
1057
1169
|
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1170
|
+
draft.result = {
|
|
1171
|
+
...draft.result ?? { status: "success" },
|
|
1172
|
+
status: "success",
|
|
1173
|
+
outputs: this.resolveDeclaredOutputs(draft)
|
|
1174
|
+
};
|
|
1058
1175
|
return draft;
|
|
1059
1176
|
});
|
|
1060
1177
|
this.logger.info("Workflow run completed successfully", { runId });
|
|
@@ -1073,6 +1190,7 @@ var WorkflowEngine = class {
|
|
|
1073
1190
|
if (updated) {
|
|
1074
1191
|
await this.snapshotTerminalRun(updated);
|
|
1075
1192
|
}
|
|
1193
|
+
await this.reconcileChildInvocation(runId);
|
|
1076
1194
|
} else if (anyFailed) {
|
|
1077
1195
|
const failedJob = run.jobs.find((j) => j.status === "failed");
|
|
1078
1196
|
const updated = await this.stateStore.updateRun(runId, (draft) => {
|
|
@@ -1106,6 +1224,7 @@ var WorkflowEngine = class {
|
|
|
1106
1224
|
if (updated) {
|
|
1107
1225
|
await this.snapshotTerminalRun(updated);
|
|
1108
1226
|
}
|
|
1227
|
+
await this.reconcileChildInvocation(runId);
|
|
1109
1228
|
}
|
|
1110
1229
|
}
|
|
1111
1230
|
/**
|
|
@@ -1183,6 +1302,34 @@ var WorkflowEngine = class {
|
|
|
1183
1302
|
});
|
|
1184
1303
|
this.logger.info("Step waiting for approval", { runId, jobId, stepId });
|
|
1185
1304
|
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Park a step while its child workflow runs. The worker returns after this
|
|
1307
|
+
* transition, so parent workflows never consume the pool needed by children.
|
|
1308
|
+
*/
|
|
1309
|
+
async markStepWaitingChild(runId, jobId, stepId, childRunId) {
|
|
1310
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
1311
|
+
draft.status = "waiting_child";
|
|
1312
|
+
draft.startedAt ??= (/* @__PURE__ */ new Date()).toISOString();
|
|
1313
|
+
draft.metadata = { ...draft.metadata ?? {}, childRunId };
|
|
1314
|
+
});
|
|
1315
|
+
await this.events.publish({
|
|
1316
|
+
type: EVENT_NAMES.step.waitingChild,
|
|
1317
|
+
runId,
|
|
1318
|
+
jobId,
|
|
1319
|
+
stepId,
|
|
1320
|
+
payload: { childRunId }
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
/** Re-queue a parked parent job after its child workflow reaches a terminal state. */
|
|
1324
|
+
async resumeJob(runId, jobId) {
|
|
1325
|
+
const job = await this.stateStore.updateJob(runId, jobId, (draft) => {
|
|
1326
|
+
draft.status = "queued";
|
|
1327
|
+
draft.finishedAt = void 0;
|
|
1328
|
+
});
|
|
1329
|
+
if (job) {
|
|
1330
|
+
await this.scheduler.enqueueJob(runId, job, job.priority ?? "normal");
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1186
1333
|
/**
|
|
1187
1334
|
* Resolve a pending approval — approve or reject.
|
|
1188
1335
|
* On approve: marks step as success with approval outputs.
|
|
@@ -1245,6 +1392,10 @@ var WorkflowEngine = class {
|
|
|
1245
1392
|
const runIds = await this.stateStore.getAllRunIds();
|
|
1246
1393
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1247
1394
|
let count = 0;
|
|
1395
|
+
const runs = await this.listStoredRuns();
|
|
1396
|
+
const protectedChildRunIds = new Set(
|
|
1397
|
+
runs.flatMap((run) => run.jobs.flatMap((job) => job.steps.filter((step) => step.status === "waiting_child").map((step) => step.metadata?.["childRunId"]).filter((id) => typeof id === "string")))
|
|
1398
|
+
);
|
|
1248
1399
|
await Promise.all(
|
|
1249
1400
|
runIds.map(async (runId) => {
|
|
1250
1401
|
const run = await this.stateStore.getRun(runId);
|
|
@@ -1254,6 +1405,30 @@ var WorkflowEngine = class {
|
|
|
1254
1405
|
if (run.status !== "running" && run.status !== "queued") {
|
|
1255
1406
|
return;
|
|
1256
1407
|
}
|
|
1408
|
+
if (run.jobs.some((job) => job.steps.some((step) => step.status === "waiting_child"))) {
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (protectedChildRunIds.has(run.id)) {
|
|
1412
|
+
const hasRunningJob = run.jobs.some((job) => job.status === "running");
|
|
1413
|
+
if (hasRunningJob) {
|
|
1414
|
+
await this.stateStore.updateRun(runId, (draft) => {
|
|
1415
|
+
for (const job of draft.jobs) {
|
|
1416
|
+
if (job.status === "running") {
|
|
1417
|
+
job.status = "interrupted";
|
|
1418
|
+
job.finishedAt = now;
|
|
1419
|
+
for (const step of job.steps) {
|
|
1420
|
+
if (step.status === "running") {
|
|
1421
|
+
step.status = "queued";
|
|
1422
|
+
step.startedAt = void 0;
|
|
1423
|
+
step.finishedAt = void 0;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1257
1432
|
await this.stateStore.updateRun(runId, (draft) => {
|
|
1258
1433
|
draft.status = "failed";
|
|
1259
1434
|
draft.finishedAt = now;
|