@kb-labs/workflow-engine 2.118.2 → 2.119.0-canary.0b1ec4151
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 +66 -6
- package/dist/index.js +274 -119
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
882
|
+
if (!run) {
|
|
781
883
|
return;
|
|
782
884
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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.
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1163
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
|
1307
|
-
*
|
|
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.
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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.
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
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
|
-
|
|
1433
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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) {
|