@kb-labs/workflow-engine 2.116.14 → 2.118.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 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;