@kb-labs/workflow-engine 2.118.2 → 2.119.0-canary.2077501f1

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.js CHANGED
@@ -2,8 +2,8 @@ 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
4
  import { WorkflowSpecSchema, resolveExpression, evaluateExpression } from '@kb-labs/workflow-contracts';
5
+ import { assertTransition, WORKFLOW_REDIS_CHANNEL, EVENT_NAMES, IllegalStateTransitionError, isTerminal, IDEMPOTENCY_TTL_ENV, CONCURRENCY_TTL_ENV } from '@kb-labs/workflow-constants';
5
6
  import { randomUUID } from 'crypto';
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';
8
8
  import { createFileSystemArtifactClient } from '@kb-labs/workflow-artifacts';
9
9
  import { existsSync } from 'fs';
@@ -77,6 +77,47 @@ ${issues}`);
77
77
  return { spec, source };
78
78
  }
79
79
  };
80
+ var LOCK_TTL_MS = 5e3;
81
+ var LOCK_ACQUIRE_TIMEOUT_MS = 15e3;
82
+ var LOCK_RETRY_BASE_MS = 20;
83
+ var LOCK_RETRY_MAX_MS = 250;
84
+ var LockAcquireTimeoutError = class extends Error {
85
+ constructor(lockKey) {
86
+ super(`Timed out waiting for lock: ${lockKey}`);
87
+ this.lockKey = lockKey;
88
+ this.name = "LockAcquireTimeoutError";
89
+ }
90
+ lockKey;
91
+ };
92
+ function sleep(ms) {
93
+ return new Promise((resolve4) => {
94
+ setTimeout(resolve4, ms);
95
+ });
96
+ }
97
+ async function withLock(cache, lockKey, fn) {
98
+ const token = randomUUID();
99
+ const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
100
+ let delay = LOCK_RETRY_BASE_MS;
101
+ for (; ; ) {
102
+ const acquired = await cache.setIfNotExists(lockKey, token, LOCK_TTL_MS);
103
+ if (acquired) {
104
+ break;
105
+ }
106
+ if (Date.now() >= deadline) {
107
+ throw new LockAcquireTimeoutError(lockKey);
108
+ }
109
+ await sleep(delay + Math.random() * delay);
110
+ delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS);
111
+ }
112
+ try {
113
+ return await fn();
114
+ } finally {
115
+ const current = await cache.get(lockKey);
116
+ if (current === token) {
117
+ await cache.delete(lockKey);
118
+ }
119
+ }
120
+ }
80
121
 
81
122
  // src/state-store.ts
82
123
  var RUN_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -118,16 +159,30 @@ var StateStore = class {
118
159
  const runIds = await this.cache.zrangebyscore("workflow:runs:index", -Infinity, Infinity);
119
160
  return runIds ?? [];
120
161
  }
162
+ /**
163
+ * Holds an exclusive per-run lock (see `withLock`) for the whole
164
+ * read-modify-write, so `mutator` runs exactly once per call — no other
165
+ * writer can observe or clobber the run in between. This is what actually
166
+ * fixes the original bug class (a job marked `failed` while its step was
167
+ * mid-write to `waiting_approval`): the two writes can no longer interleave.
168
+ *
169
+ * `mutator` must still not `await` — it runs synchronously against a
170
+ * single in-memory draft while the lock is held; async work belongs after
171
+ * `updateRun` resolves (and holding the lock across an `await` would just
172
+ * make every other writer to this run block on it needlessly).
173
+ */
121
174
  async updateRun(runId, mutator) {
122
- const run = await this.getRun(runId);
123
- if (!run) {
124
- return null;
125
- }
126
- const draft = clone(run);
127
- const result = mutator(draft);
128
- const next = result ?? draft;
129
- await this.saveRun(next);
130
- return next;
175
+ return withLock(this.cache, `kb:lock:run:${runId}`, async () => {
176
+ const run = await this.getRun(runId);
177
+ if (!run) {
178
+ return null;
179
+ }
180
+ const draft = clone(run);
181
+ const result = mutator(draft);
182
+ const next = result ?? draft;
183
+ await this.saveRun(next);
184
+ return next;
185
+ });
131
186
  }
132
187
  async updateJob(runId, jobId, mutator) {
133
188
  let updatedJob = null;
@@ -177,9 +232,44 @@ var StateStore = class {
177
232
  });
178
233
  return updatedStep;
179
234
  }
235
+ /**
236
+ * Like `updateRun`, but validates the status transition against the
237
+ * workflow state machine before applying it — throws
238
+ * `IllegalStateTransitionError` (from `@kb-labs/workflow-constants`) if
239
+ * `to` is not reachable from the run's current status. `mutate` sets any
240
+ * *other* fields; it must not itself assign `.status` (this method owns
241
+ * that assignment, after the check).
242
+ */
243
+ async transitionRun(runId, to, mutate = () => {
244
+ }, options) {
245
+ return this.updateRun(runId, (draft) => {
246
+ assertTransition("run", draft.status, to, options);
247
+ draft.status = to;
248
+ mutate(draft);
249
+ });
250
+ }
251
+ /** Job-level counterpart of `transitionRun` — see its docblock. */
252
+ async transitionJob(runId, jobId, to, mutate = () => {
253
+ }, options) {
254
+ return this.updateJob(runId, jobId, (draft) => {
255
+ assertTransition("job", draft.status, to, options);
256
+ draft.status = to;
257
+ mutate(draft);
258
+ });
259
+ }
260
+ /** Step-level counterpart of `transitionRun` — see its docblock. */
261
+ async transitionStep(runId, jobId, stepId, to, mutate = () => {
262
+ }, options) {
263
+ return this.updateStep(runId, jobId, stepId, (draft) => {
264
+ assertTransition("step", draft.status, to, options);
265
+ draft.status = to;
266
+ mutate(draft);
267
+ });
268
+ }
180
269
  async releaseBlockedJobs(runId, completedJobName) {
181
- const released = [];
270
+ let released = [];
182
271
  await this.updateRun(runId, (run) => {
272
+ released = [];
183
273
  for (const job of run.jobs) {
184
274
  if (job.status !== "queued" || !job.blocked) {
185
275
  continue;
@@ -520,29 +610,41 @@ var Scheduler = class {
520
610
  priority: entry.priority
521
611
  });
522
612
  }
613
+ /**
614
+ * The read (`zrangebyscore`) and the remove (`zrem`) below are two
615
+ * separate cache round-trips, not one atomic op — without the lock, two
616
+ * daemon instances racing this method could both read the same top entry
617
+ * before either removes it, and both would go on to execute the same job
618
+ * (the daemon runs multiple instances in production, so this is a live
619
+ * bug, not a theoretical one). `withLock` serializes dequeues against this
620
+ * one priority queue across every process sharing the same cache backend,
621
+ * the same way `StateStore.updateRun` serializes writes to one run.
622
+ */
523
623
  async dequeueFromPriority(priority) {
524
- const now = Date.now();
525
624
  const key = `kb:jobqueue:${priority}`;
526
- const results = await this.cache.zrangebyscore(
527
- key,
528
- 0,
529
- now + this.lookAheadMs
530
- );
531
- if (results.length === 0) {
532
- return null;
533
- }
534
- const raw = results[0];
535
- if (typeof raw !== "string") {
536
- return null;
537
- }
538
- try {
539
- const entry = JSON.parse(raw);
540
- await this.cache.zrem(key, raw);
541
- return entry;
542
- } catch (error) {
543
- this.logger.error("Failed to parse job queue entry", error instanceof Error ? error : void 0);
544
- return null;
545
- }
625
+ return withLock(this.cache, `kb:lock:queue:${priority}`, async () => {
626
+ const now = Date.now();
627
+ const results = await this.cache.zrangebyscore(
628
+ key,
629
+ 0,
630
+ now + this.lookAheadMs
631
+ );
632
+ if (results.length === 0) {
633
+ return null;
634
+ }
635
+ const raw = results[0];
636
+ if (typeof raw !== "string") {
637
+ return null;
638
+ }
639
+ try {
640
+ const entry = JSON.parse(raw);
641
+ await this.cache.zrem(key, raw);
642
+ return entry;
643
+ } catch (error) {
644
+ this.logger.error("Failed to parse job queue entry", error instanceof Error ? error : void 0);
645
+ return null;
646
+ }
647
+ });
546
648
  }
547
649
  getDefaultPriority() {
548
650
  return this.defaultPriority;
@@ -777,14 +879,19 @@ var WorkflowEngine = class {
777
879
  }
778
880
  async cancelRun(runId) {
779
881
  const run = await this.getRun(runId);
780
- if (!run || ["success", "failed", "cancelled", "skipped", "dlq"].includes(run.status)) {
882
+ if (!run) {
781
883
  return;
782
884
  }
783
- await this.stateStore.updateRun(runId, (draft) => {
784
- draft.status = "cancelled";
785
- draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
786
- return draft;
787
- });
885
+ try {
886
+ await this.stateStore.transitionRun(runId, "cancelled", (draft) => {
887
+ draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
888
+ });
889
+ } catch (error) {
890
+ if (error instanceof IllegalStateTransitionError) {
891
+ return;
892
+ }
893
+ throw error;
894
+ }
788
895
  this.analytics?.track("workflow.run.cancelled", {
789
896
  runId,
790
897
  name: run?.name,
@@ -918,8 +1025,7 @@ var WorkflowEngine = class {
918
1025
  this.logger.warn("Cannot mark job as failed: job not found", { runId, jobId });
919
1026
  return;
920
1027
  }
921
- await this.stateStore.updateJob(runId, jobId, (draft) => {
922
- draft.status = "failed";
1028
+ await this.stateStore.transitionJob(runId, jobId, "failed", (draft) => {
923
1029
  draft.error = {
924
1030
  message: error.message,
925
1031
  stack: error.stack,
@@ -970,12 +1076,19 @@ var WorkflowEngine = class {
970
1076
  backoffMs
971
1077
  });
972
1078
  setTimeout(async () => {
973
- await this.stateStore.updateJob(runId, jobId, (draft) => {
974
- draft.status = "queued";
975
- draft.error = void 0;
976
- draft.startedAt = void 0;
977
- draft.finishedAt = void 0;
978
- });
1079
+ try {
1080
+ await this.stateStore.transitionJob(runId, jobId, "queued", (draft) => {
1081
+ draft.error = void 0;
1082
+ draft.startedAt = void 0;
1083
+ draft.finishedAt = void 0;
1084
+ });
1085
+ } catch (transitionError) {
1086
+ if (transitionError instanceof IllegalStateTransitionError) {
1087
+ this.logger.info("Dropping stale job retry: job is no longer failed", { runId, jobId });
1088
+ return;
1089
+ }
1090
+ throw transitionError;
1091
+ }
979
1092
  const updatedRun = await this.stateStore.getRun(runId);
980
1093
  const updatedJob = updatedRun?.jobs.find((j) => j.id === jobId);
981
1094
  if (updatedJob) {
@@ -998,8 +1111,7 @@ var WorkflowEngine = class {
998
1111
  const released = await this.stateStore.releaseBlockedJobs(runId, failedJobName);
999
1112
  for (const downstreamJob of released) {
1000
1113
  const now = (/* @__PURE__ */ new Date()).toISOString();
1001
- await this.stateStore.updateJob(runId, downstreamJob.id, (draft) => {
1002
- draft.status = "cancelled";
1114
+ await this.stateStore.transitionJob(runId, downstreamJob.id, "cancelled", (draft) => {
1003
1115
  draft.blocked = false;
1004
1116
  draft.finishedAt = now;
1005
1117
  draft.error = { message: reason, timestamp: now };
@@ -1023,8 +1135,7 @@ var WorkflowEngine = class {
1023
1135
  * Interrupted jobs will be retried on next daemon startup.
1024
1136
  */
1025
1137
  async markJobInterrupted(runId, jobId) {
1026
- await this.stateStore.updateJob(runId, jobId, (draft) => {
1027
- draft.status = "interrupted";
1138
+ await this.stateStore.transitionJob(runId, jobId, "interrupted", (draft) => {
1028
1139
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1029
1140
  });
1030
1141
  this.logger.warn("Job interrupted", { runId, jobId });
@@ -1033,8 +1144,7 @@ var WorkflowEngine = class {
1033
1144
  * Mark job as started (running).
1034
1145
  */
1035
1146
  async markJobStarted(runId, jobId) {
1036
- await this.stateStore.updateJob(runId, jobId, (draft) => {
1037
- draft.status = "running";
1147
+ await this.stateStore.transitionJob(runId, jobId, "running", (draft) => {
1038
1148
  draft.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1039
1149
  });
1040
1150
  await this.stateStore.updateRun(runId, (draft) => {
@@ -1070,8 +1180,7 @@ var WorkflowEngine = class {
1070
1180
  const job = run?.jobs.find((j) => j.id === jobId);
1071
1181
  const startTime = job?.startedAt ? new Date(job.startedAt).getTime() : Date.now();
1072
1182
  const duration = Date.now() - startTime;
1073
- await this.stateStore.updateJob(runId, jobId, (draft) => {
1074
- draft.status = "success";
1183
+ await this.stateStore.transitionJob(runId, jobId, "success", (draft) => {
1075
1184
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1076
1185
  });
1077
1186
  this.logger.info("Job completed successfully", { runId, jobId });
@@ -1131,8 +1240,7 @@ var WorkflowEngine = class {
1131
1240
  /** Mark a job as skipped (success) and release any jobs blocked on it. */
1132
1241
  async skipJob(runId, job) {
1133
1242
  const now = (/* @__PURE__ */ new Date()).toISOString();
1134
- await this.stateStore.updateJob(runId, job.id, (draft) => {
1135
- draft.status = "success";
1243
+ await this.stateStore.transitionJob(runId, job.id, "success", (draft) => {
1136
1244
  draft.startedAt = now;
1137
1245
  draft.finishedAt = now;
1138
1246
  });
@@ -1159,13 +1267,12 @@ var WorkflowEngine = class {
1159
1267
  }
1160
1268
  const allSuccess = run.jobs.every((j) => j.status === "success");
1161
1269
  const anyFailed = run.jobs.some((j) => j.status === "failed");
1162
- const anyRunning = run.jobs.some((j) => j.status === "running" || j.status === "queued");
1163
- if (anyRunning) {
1270
+ const anyNonTerminal = run.jobs.some((j) => !isTerminal(j.status, "job"));
1271
+ if (anyNonTerminal) {
1164
1272
  return;
1165
1273
  }
1166
1274
  if (allSuccess) {
1167
- const updated = await this.stateStore.updateRun(runId, (draft) => {
1168
- draft.status = "success";
1275
+ const updated = await this.stateStore.transitionRun(runId, "success", (draft) => {
1169
1276
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1170
1277
  draft.result = {
1171
1278
  ...draft.result ?? { status: "success" },
@@ -1193,8 +1300,7 @@ var WorkflowEngine = class {
1193
1300
  await this.reconcileChildInvocation(runId);
1194
1301
  } else if (anyFailed) {
1195
1302
  const failedJob = run.jobs.find((j) => j.status === "failed");
1196
- const updated = await this.stateStore.updateRun(runId, (draft) => {
1197
- draft.status = "failed";
1303
+ const updated = await this.stateStore.transitionRun(runId, "failed", (draft) => {
1198
1304
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1199
1305
  if (failedJob?.error) {
1200
1306
  draft.result = {
@@ -1231,8 +1337,7 @@ var WorkflowEngine = class {
1231
1337
  * Mark step as started (running).
1232
1338
  */
1233
1339
  async markStepStarted(runId, jobId, stepId) {
1234
- await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
1235
- draft.status = "running";
1340
+ await this.stateStore.transitionStep(runId, jobId, stepId, "running", (draft) => {
1236
1341
  draft.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1237
1342
  });
1238
1343
  this.logger.debug("Step started", { runId, jobId, stepId });
@@ -1247,8 +1352,7 @@ var WorkflowEngine = class {
1247
1352
  * Mark step as completed successfully with output.
1248
1353
  */
1249
1354
  async markStepCompleted(runId, jobId, stepId, output) {
1250
- await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
1251
- draft.status = "success";
1355
+ await this.stateStore.transitionStep(runId, jobId, stepId, "success", (draft) => {
1252
1356
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1253
1357
  if (output !== void 0) {
1254
1358
  draft.outputs = output;
@@ -1267,8 +1371,7 @@ var WorkflowEngine = class {
1267
1371
  * Mark step as failed with error.
1268
1372
  */
1269
1373
  async markStepFailed(runId, jobId, stepId, error, outputs) {
1270
- await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
1271
- draft.status = "failed";
1374
+ await this.stateStore.transitionStep(runId, jobId, stepId, "failed", (draft) => {
1272
1375
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1273
1376
  draft.error = {
1274
1377
  message: error.message,
@@ -1290,10 +1393,29 @@ var WorkflowEngine = class {
1290
1393
  /**
1291
1394
  * Mark step as waiting for human approval.
1292
1395
  */
1396
+ /**
1397
+ * Park a step waiting for human approval — and park its parent job with
1398
+ * it, in the SAME atomic write (one `transitionJob` call touching both the
1399
+ * job's own status and its nested step). Two separate writes (step then
1400
+ * job) would leave a window where a reader could observe step=waiting but
1401
+ * job=running; going through one call closes that window entirely, not
1402
+ * just narrows it.
1403
+ *
1404
+ * The job-level `waiting_approval` status is what makes the daemon-restart
1405
+ * exemption in `cleanupStaleRuns` structural: that force-fail loop only
1406
+ * ever touches `running`/`queued` jobs, so a parked job is never in its
1407
+ * blast radius — no bespoke "is this job actually abandoned or just
1408
+ * waiting on a human" check needed there.
1409
+ */
1293
1410
  async markStepWaitingApproval(runId, jobId, stepId) {
1294
- await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
1295
- draft.status = "waiting_approval";
1296
- draft.startedAt = draft.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1411
+ await this.stateStore.transitionJob(runId, jobId, "waiting_approval", (jobDraft) => {
1412
+ const step = jobDraft.steps.find((s) => s.id === stepId);
1413
+ if (!step) {
1414
+ return;
1415
+ }
1416
+ assertTransition("step", step.status, "waiting_approval");
1417
+ step.status = "waiting_approval";
1418
+ step.startedAt = step.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1297
1419
  });
1298
1420
  await this.events.publish({
1299
1421
  type: EVENT_NAMES.step.waitingApproval,
@@ -1303,14 +1425,21 @@ var WorkflowEngine = class {
1303
1425
  this.logger.info("Step waiting for approval", { runId, jobId, stepId });
1304
1426
  }
1305
1427
  /**
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.
1428
+ * Park a step (and its parent job see `markStepWaitingApproval`'s
1429
+ * docblock for why job+step move together in one write) while its child
1430
+ * workflow runs. The worker returns after this transition, so parent
1431
+ * workflows never consume the pool needed by children.
1308
1432
  */
1309
1433
  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 };
1434
+ await this.stateStore.transitionJob(runId, jobId, "waiting_child", (jobDraft) => {
1435
+ const step = jobDraft.steps.find((s) => s.id === stepId);
1436
+ if (!step) {
1437
+ return;
1438
+ }
1439
+ assertTransition("step", step.status, "waiting_child");
1440
+ step.status = "waiting_child";
1441
+ step.startedAt ??= (/* @__PURE__ */ new Date()).toISOString();
1442
+ step.metadata = { ...step.metadata ?? {}, childRunId };
1314
1443
  });
1315
1444
  await this.events.publish({
1316
1445
  type: EVENT_NAMES.step.waitingChild,
@@ -1322,8 +1451,7 @@ var WorkflowEngine = class {
1322
1451
  }
1323
1452
  /** Re-queue a parked parent job after its child workflow reaches a terminal state. */
1324
1453
  async resumeJob(runId, jobId) {
1325
- const job = await this.stateStore.updateJob(runId, jobId, (draft) => {
1326
- draft.status = "queued";
1454
+ const job = await this.stateStore.transitionJob(runId, jobId, "queued", (draft) => {
1327
1455
  draft.finishedAt = void 0;
1328
1456
  });
1329
1457
  if (job) {
@@ -1334,42 +1462,54 @@ var WorkflowEngine = class {
1334
1462
  * Resolve a pending approval — approve or reject.
1335
1463
  * On approve: marks step as success with approval outputs.
1336
1464
  * On reject: marks step as failed with rejection error.
1465
+ *
1466
+ * Resolves the step AND un-parks the job (`waiting_approval` → `queued`)
1467
+ * in one atomic write — same reasoning as `markStepWaitingApproval` — then
1468
+ * re-enqueues the job. This re-enqueue is what actually resumes execution:
1469
+ * with approval no longer polled in-process (the worker parks and returns
1470
+ * instead of waiting), nothing else will ever pick this job back up.
1337
1471
  */
1338
1472
  async resolveApproval(runId, jobId, stepId, action, data, comment) {
1339
- if (action === "approve") {
1340
- await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
1341
- draft.status = "success";
1342
- draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1343
- draft.outputs = {
1344
- approved: true,
1345
- action,
1346
- ...comment ? { comment } : {},
1347
- ...data ?? {}
1348
- };
1349
- });
1350
- this.logger.info("Approval granted", { runId, jobId, stepId, comment });
1351
- } else {
1352
- await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
1353
- draft.status = "failed";
1354
- draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1355
- draft.error = {
1356
- message: comment || "Approval rejected",
1357
- code: "APPROVAL_REJECTED"
1358
- };
1359
- draft.outputs = {
1360
- approved: false,
1361
- action,
1362
- ...comment ? { comment } : {},
1363
- ...data ?? {}
1364
- };
1473
+ const run = await this.stateStore.getRun(runId);
1474
+ const existingJob = run?.jobs.find((j) => j.id === jobId);
1475
+ if (!run || !existingJob) {
1476
+ throw new Error(`Cannot resolve approval: run or job not found (runId=${runId}, jobId=${jobId})`);
1477
+ }
1478
+ if (isTerminal(run.status, "run") || isTerminal(existingJob.status, "job")) {
1479
+ throw new IllegalStateTransitionError("job", existingJob.status, "queued", {
1480
+ reason: `run.status=${run.status}`
1365
1481
  });
1366
- this.logger.info("Approval rejected", { runId, jobId, stepId, comment });
1367
1482
  }
1483
+ const stepStatus = action === "approve" ? "success" : "failed";
1484
+ const outputs = {
1485
+ approved: action === "approve",
1486
+ action,
1487
+ ...comment ? { comment } : {},
1488
+ ...data ?? {}
1489
+ };
1490
+ const job = await this.stateStore.transitionJob(runId, jobId, "queued", (jobDraft) => {
1491
+ const step = jobDraft.steps.find((s) => s.id === stepId);
1492
+ if (!step) {
1493
+ return;
1494
+ }
1495
+ assertTransition("step", step.status, stepStatus);
1496
+ step.status = stepStatus;
1497
+ step.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1498
+ step.outputs = outputs;
1499
+ if (action === "reject") {
1500
+ step.error = { message: comment || "Approval rejected", code: "APPROVAL_REJECTED" };
1501
+ }
1502
+ jobDraft.finishedAt = void 0;
1503
+ });
1504
+ this.logger.info(action === "approve" ? "Approval granted" : "Approval rejected", { runId, jobId, stepId, comment });
1368
1505
  await this.events.publish({
1369
1506
  type: EVENT_NAMES.step.updated,
1370
1507
  runId,
1371
1508
  payload: { jobId, stepId, action }
1372
1509
  });
1510
+ if (job) {
1511
+ await this.scheduler.enqueueJob(runId, job, job.priority ?? "normal");
1512
+ }
1373
1513
  }
1374
1514
  /**
1375
1515
  * Get the state store for direct access (used by worker for gate restart-from).
@@ -1384,9 +1524,12 @@ var WorkflowEngine = class {
1384
1524
  return this.scheduler;
1385
1525
  }
1386
1526
  /**
1387
- * Mark stale running/queued runs as failed on daemon startup.
1388
- * Runs that were in-flight when the daemon crashed are unrecoverable
1389
- * their executor process is gone, so we mark them failed immediately.
1527
+ * Mark stale running/queued jobs as failed on daemon startup — their
1528
+ * executor process is gone, so they're unrecoverable. The run itself is
1529
+ * only finalized as 'failed' if nothing else could still complete it;
1530
+ * a run with one abandoned job and one job legitimately parked on a human
1531
+ * approval or a child workflow stays 'running' (only the abandoned job is
1532
+ * failed) until the parked one resolves.
1390
1533
  */
1391
1534
  async cleanupStaleRuns() {
1392
1535
  const runIds = await this.stateStore.getAllRunIds();
@@ -1405,9 +1548,6 @@ var WorkflowEngine = class {
1405
1548
  if (run.status !== "running" && run.status !== "queued") {
1406
1549
  return;
1407
1550
  }
1408
- if (run.jobs.some((job) => job.steps.some((step) => step.status === "waiting_child"))) {
1409
- return;
1410
- }
1411
1551
  if (protectedChildRunIds.has(run.id)) {
1412
1552
  const hasRunningJob = run.jobs.some((job) => job.status === "running");
1413
1553
  if (hasRunningJob) {
@@ -1429,14 +1569,31 @@ var WorkflowEngine = class {
1429
1569
  }
1430
1570
  return;
1431
1571
  }
1432
- await this.stateStore.updateRun(runId, (draft) => {
1433
- draft.status = "failed";
1572
+ const hasParkedJob = run.jobs.some((job) => job.status === "waiting_approval" || job.status === "waiting_child");
1573
+ const abandonedJobIds = run.jobs.filter((job) => job.status === "running" || job.status === "queued").map((job) => job.id);
1574
+ if (abandonedJobIds.length === 0) {
1575
+ return;
1576
+ }
1577
+ if (hasParkedJob) {
1578
+ await this.stateStore.updateRun(runId, (draft) => {
1579
+ for (const job of draft.jobs) {
1580
+ if (abandonedJobIds.includes(job.id)) {
1581
+ job.status = "failed";
1582
+ job.error = { message: "Daemon restarted \u2014 run was abandoned" };
1583
+ job.finishedAt = now;
1584
+ }
1585
+ }
1586
+ });
1587
+ count++;
1588
+ return;
1589
+ }
1590
+ await this.stateStore.transitionRun(runId, "failed", (draft) => {
1434
1591
  draft.finishedAt = now;
1435
1592
  if (draft.startedAt) {
1436
1593
  draft.durationMs = new Date(now).getTime() - new Date(draft.startedAt).getTime();
1437
1594
  }
1438
1595
  for (const job of draft.jobs) {
1439
- if (job.status === "running" || job.status === "queued") {
1596
+ if (abandonedJobIds.includes(job.id)) {
1440
1597
  job.status = "failed";
1441
1598
  job.error = { message: "Daemon restarted \u2014 run was abandoned" };
1442
1599
  job.finishedAt = now;
@@ -1467,8 +1624,7 @@ var WorkflowEngine = class {
1467
1624
  await Promise.all(
1468
1625
  interruptedJobs.map(async (job) => {
1469
1626
  this.logger.info("Resuming interrupted job", { runId, jobId: job.id });
1470
- await this.stateStore.updateJob(runId, job.id, (draft) => {
1471
- draft.status = "queued";
1627
+ await this.stateStore.transitionJob(runId, job.id, "queued", (draft) => {
1472
1628
  draft.startedAt = void 0;
1473
1629
  draft.finishedAt = void 0;
1474
1630
  });
@@ -1523,9 +1679,8 @@ var WorkflowEngine = class {
1523
1679
  return updated;
1524
1680
  }
1525
1681
  async finalizeRun(runId, status, context = {}) {
1526
- const updated = await this.stateStore.updateRun(runId, (run) => {
1682
+ const updated = await this.stateStore.transitionRun(runId, status, (run) => {
1527
1683
  const now = (/* @__PURE__ */ new Date()).toISOString();
1528
- run.status = status;
1529
1684
  run.finishedAt = now;
1530
1685
  run.durationMs = computeDurationMs(run.startedAt ?? run.queuedAt, now);
1531
1686
  if (context.jobs) {