@kb-labs/workflow-engine 1.1.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/README.md +408 -0
- package/dist/index.d.ts +1057 -0
- package/dist/index.js +3077 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3077 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, readdir } from 'fs/promises';
|
|
2
|
+
import { resolve, join, basename } from 'path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
import { WorkflowSpecSchema } from '@kb-labs/workflow-contracts';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import { WORKFLOW_REDIS_CHANNEL, EVENT_NAMES, IDEMPOTENCY_TTL_ENV, CONCURRENCY_TTL_ENV } from '@kb-labs/workflow-constants';
|
|
7
|
+
import { createFileSystemArtifactClient } from '@kb-labs/workflow-artifacts';
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { nanoid } from 'nanoid';
|
|
10
|
+
|
|
11
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
12
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
13
|
+
}) : x)(function(x) {
|
|
14
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
15
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
16
|
+
});
|
|
17
|
+
var WorkflowLoader = class {
|
|
18
|
+
constructor(logger) {
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
}
|
|
21
|
+
async fromFile(filePath, options = {}) {
|
|
22
|
+
const cwd = options.cwd ?? process.cwd();
|
|
23
|
+
const absolutePath = resolve(cwd, filePath);
|
|
24
|
+
this.logger.debug(`Loading workflow spec from file`, {
|
|
25
|
+
path: absolutePath
|
|
26
|
+
});
|
|
27
|
+
const raw = await readFile(absolutePath, "utf8");
|
|
28
|
+
const parsed = this.parse(raw, absolutePath);
|
|
29
|
+
return this.validate(parsed, absolutePath);
|
|
30
|
+
}
|
|
31
|
+
fromInline(spec, source = "inline") {
|
|
32
|
+
let candidate = spec;
|
|
33
|
+
if (typeof spec === "string") {
|
|
34
|
+
candidate = this.parse(spec, source);
|
|
35
|
+
}
|
|
36
|
+
return this.validate(candidate, source);
|
|
37
|
+
}
|
|
38
|
+
parse(raw, source) {
|
|
39
|
+
const trimmed = raw.trim();
|
|
40
|
+
if (!trimmed) {
|
|
41
|
+
throw new Error(`Workflow spec ${source} is empty`);
|
|
42
|
+
}
|
|
43
|
+
if (source.endsWith(".json") || trimmed.startsWith("{")) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(trimmed);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
this.logger.error(`Failed to parse JSON workflow spec from ${source}`, error instanceof Error ? error : void 0, {
|
|
49
|
+
errorMessage: message
|
|
50
|
+
});
|
|
51
|
+
throw new Error(`Failed to parse workflow spec JSON: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return parse(trimmed);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
this.logger.error(`Failed to parse YAML workflow spec from ${source}`, error instanceof Error ? error : void 0, {
|
|
59
|
+
errorMessage: message
|
|
60
|
+
});
|
|
61
|
+
throw new Error(`Failed to parse workflow spec YAML: ${message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
validate(candidate, source) {
|
|
65
|
+
const parsed = WorkflowSpecSchema.safeParse(candidate);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
const issues = parsed.error.issues.map(
|
|
68
|
+
(issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`
|
|
69
|
+
).join("\n");
|
|
70
|
+
this.logger.warn(`Workflow spec validation failed`, { source, issues });
|
|
71
|
+
throw new Error(`Workflow spec validation failed:
|
|
72
|
+
${issues}`);
|
|
73
|
+
}
|
|
74
|
+
const spec = parsed.data;
|
|
75
|
+
return { spec, source };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/state-store.ts
|
|
80
|
+
var StateStore = class {
|
|
81
|
+
constructor(cache, logger) {
|
|
82
|
+
this.logger = logger;
|
|
83
|
+
this.cache = cache;
|
|
84
|
+
}
|
|
85
|
+
cache;
|
|
86
|
+
async saveRun(run) {
|
|
87
|
+
const key = `kb:run:${run.id}`;
|
|
88
|
+
this.logger.debug("Persisting workflow run", { runId: run.id, key });
|
|
89
|
+
await this.cache.set(key, JSON.stringify(run));
|
|
90
|
+
const timestamp = new Date(run.createdAt).getTime();
|
|
91
|
+
await this.cache.zadd("workflow:runs:index", timestamp, run.id);
|
|
92
|
+
}
|
|
93
|
+
async getRun(runId) {
|
|
94
|
+
const key = `kb:run:${runId}`;
|
|
95
|
+
const payload = await this.cache.get(key);
|
|
96
|
+
if (!payload) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(payload);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
this.logger.error("Failed to parse stored workflow run", error instanceof Error ? error : void 0, {
|
|
103
|
+
runId
|
|
104
|
+
});
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async deleteRun(runId) {
|
|
109
|
+
const key = `kb:run:${runId}`;
|
|
110
|
+
await this.cache.delete(key);
|
|
111
|
+
await this.cache.zrem("workflow:runs:index", runId);
|
|
112
|
+
}
|
|
113
|
+
async getAllRunIds() {
|
|
114
|
+
const runIds = await this.cache.zrangebyscore("workflow:runs:index", -Infinity, Infinity);
|
|
115
|
+
return runIds ?? [];
|
|
116
|
+
}
|
|
117
|
+
async updateRun(runId, mutator) {
|
|
118
|
+
const run = await this.getRun(runId);
|
|
119
|
+
if (!run) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const draft = clone(run);
|
|
123
|
+
const result = mutator(draft);
|
|
124
|
+
const next = result ?? draft;
|
|
125
|
+
await this.saveRun(next);
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
async updateJob(runId, jobId, mutator) {
|
|
129
|
+
let updatedJob = null;
|
|
130
|
+
await this.updateRun(runId, (run) => {
|
|
131
|
+
const index = run.jobs.findIndex((job) => job.id === jobId);
|
|
132
|
+
if (index === -1) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const existingJob = run.jobs[index];
|
|
136
|
+
if (!existingJob) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const jobDraft = clone(existingJob);
|
|
140
|
+
const result = mutator(jobDraft);
|
|
141
|
+
const nextJob = result ?? jobDraft;
|
|
142
|
+
run.jobs[index] = nextJob;
|
|
143
|
+
updatedJob = nextJob;
|
|
144
|
+
});
|
|
145
|
+
return updatedJob;
|
|
146
|
+
}
|
|
147
|
+
async updateStep(runId, jobId, stepId, mutator) {
|
|
148
|
+
let updatedStep = null;
|
|
149
|
+
await this.updateRun(runId, (run) => {
|
|
150
|
+
const jobIndex = run.jobs.findIndex((job2) => job2.id === jobId);
|
|
151
|
+
if (jobIndex === -1) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const job = run.jobs[jobIndex];
|
|
155
|
+
if (!job) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const stepIndex = job.steps.findIndex(
|
|
159
|
+
(step) => step.id === stepId
|
|
160
|
+
);
|
|
161
|
+
if (stepIndex === -1) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const existingStep = job.steps[stepIndex];
|
|
165
|
+
if (!existingStep) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const draft = clone(existingStep);
|
|
169
|
+
const result = mutator(draft);
|
|
170
|
+
const nextStep = result ?? draft;
|
|
171
|
+
job.steps[stepIndex] = nextStep;
|
|
172
|
+
updatedStep = nextStep;
|
|
173
|
+
});
|
|
174
|
+
return updatedStep;
|
|
175
|
+
}
|
|
176
|
+
async releaseBlockedJobs(runId, completedJobName) {
|
|
177
|
+
const released = [];
|
|
178
|
+
await this.updateRun(runId, (run) => {
|
|
179
|
+
for (const job of run.jobs) {
|
|
180
|
+
if (job.status !== "queued" || !job.blocked) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!job.pendingDependencies || job.pendingDependencies.length === 0) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!job.needs?.includes(completedJobName)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const remaining = job.pendingDependencies.filter(
|
|
190
|
+
(dependency) => dependency !== completedJobName
|
|
191
|
+
);
|
|
192
|
+
if (remaining.length === job.pendingDependencies.length) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
job.pendingDependencies = remaining;
|
|
196
|
+
if (remaining.length === 0) {
|
|
197
|
+
job.blocked = false;
|
|
198
|
+
released.push(clone(job));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
return released;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
function clone(value) {
|
|
206
|
+
return JSON.parse(JSON.stringify(value));
|
|
207
|
+
}
|
|
208
|
+
var DEFAULT_IDEMPOTENCY_TTL_MS = 1e3 * 60 * 60 * 24;
|
|
209
|
+
function resolveIdempotencyTtlMs(explicit) {
|
|
210
|
+
if (typeof explicit === "number" && explicit > 0) {
|
|
211
|
+
return explicit;
|
|
212
|
+
}
|
|
213
|
+
const envValue = process.env[IDEMPOTENCY_TTL_ENV];
|
|
214
|
+
const parsed = envValue ? Number(envValue) : void 0;
|
|
215
|
+
if (parsed && Number.isFinite(parsed) && parsed > 0) {
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
return DEFAULT_IDEMPOTENCY_TTL_MS;
|
|
219
|
+
}
|
|
220
|
+
var RunCoordinator = class {
|
|
221
|
+
constructor(cache, stateStore, concurrencyManager, logger, options = {}) {
|
|
222
|
+
this.stateStore = stateStore;
|
|
223
|
+
this.concurrencyManager = concurrencyManager;
|
|
224
|
+
this.logger = logger;
|
|
225
|
+
this.cache = cache;
|
|
226
|
+
this.idempotencyTtlMs = resolveIdempotencyTtlMs(options.idempotencyTtlMs);
|
|
227
|
+
}
|
|
228
|
+
cache;
|
|
229
|
+
idempotencyTtlMs;
|
|
230
|
+
async ensureRun(input) {
|
|
231
|
+
if (input.idempotencyKey) {
|
|
232
|
+
const existing = await this.loadByIdempotencyKey(input.idempotencyKey);
|
|
233
|
+
if (existing) {
|
|
234
|
+
this.logger.info("Idempotent workflow run reused", {
|
|
235
|
+
runId: existing.id,
|
|
236
|
+
idempotencyKey: input.idempotencyKey
|
|
237
|
+
});
|
|
238
|
+
return existing;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const runId = randomUUID();
|
|
242
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
243
|
+
if (input.concurrencyGroup) {
|
|
244
|
+
const acquired = await this.concurrencyManager.acquire(
|
|
245
|
+
input.concurrencyGroup,
|
|
246
|
+
runId
|
|
247
|
+
);
|
|
248
|
+
if (!acquired) {
|
|
249
|
+
const active = await this.concurrencyManager.getActiveRun(
|
|
250
|
+
input.concurrencyGroup
|
|
251
|
+
);
|
|
252
|
+
const message = active ? `Concurrency group ${input.concurrencyGroup} already locked by run ${active}` : `Failed to acquire concurrency lock for ${input.concurrencyGroup}`;
|
|
253
|
+
this.logger.warn(message, {
|
|
254
|
+
concurrencyGroup: input.concurrencyGroup,
|
|
255
|
+
activeRunId: active
|
|
256
|
+
});
|
|
257
|
+
throw new Error(message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const run = this.buildInitialRun(input, runId, now);
|
|
261
|
+
await this.stateStore.saveRun(run);
|
|
262
|
+
if (input.idempotencyKey) {
|
|
263
|
+
await this.registerIdempotencyKey(input.idempotencyKey, run.id);
|
|
264
|
+
}
|
|
265
|
+
this.logger.info("Workflow run created", {
|
|
266
|
+
runId: run.id,
|
|
267
|
+
name: run.name,
|
|
268
|
+
version: run.version
|
|
269
|
+
});
|
|
270
|
+
return run;
|
|
271
|
+
}
|
|
272
|
+
buildInitialRun(input, runId, timestamp) {
|
|
273
|
+
const jobs = [];
|
|
274
|
+
for (const [jobName, jobSpec] of Object.entries(
|
|
275
|
+
input.spec.jobs
|
|
276
|
+
)) {
|
|
277
|
+
const jobId = `${runId}:${jobName}`;
|
|
278
|
+
const needs = Array.isArray(jobSpec.needs) ? [...jobSpec.needs] : [];
|
|
279
|
+
const priority = jobSpec.priority ?? "normal";
|
|
280
|
+
const effectiveTarget = jobSpec.target ?? input.spec.target;
|
|
281
|
+
const effectiveIsolation = jobSpec.isolation ?? input.spec.isolation;
|
|
282
|
+
const stepRuns = jobSpec.steps.map((step, index) => ({
|
|
283
|
+
id: `${jobId}:${index}`,
|
|
284
|
+
runId,
|
|
285
|
+
jobId,
|
|
286
|
+
name: step.name,
|
|
287
|
+
index,
|
|
288
|
+
status: "queued",
|
|
289
|
+
queuedAt: timestamp,
|
|
290
|
+
attempt: 0,
|
|
291
|
+
timeoutMs: step.timeoutMs,
|
|
292
|
+
continueOnError: step.continueOnError,
|
|
293
|
+
spec: step
|
|
294
|
+
}));
|
|
295
|
+
jobs.push({
|
|
296
|
+
id: jobId,
|
|
297
|
+
runId,
|
|
298
|
+
jobName,
|
|
299
|
+
status: "queued",
|
|
300
|
+
runsOn: jobSpec.runsOn,
|
|
301
|
+
queuedAt: timestamp,
|
|
302
|
+
attempt: 0,
|
|
303
|
+
steps: stepRuns,
|
|
304
|
+
concurrency: jobSpec.concurrency,
|
|
305
|
+
retries: jobSpec.retries,
|
|
306
|
+
timeoutMs: jobSpec.timeoutMs,
|
|
307
|
+
target: effectiveTarget,
|
|
308
|
+
isolation: effectiveIsolation,
|
|
309
|
+
artifacts: jobSpec.artifacts,
|
|
310
|
+
env: jobSpec.env,
|
|
311
|
+
secrets: jobSpec.secrets,
|
|
312
|
+
needs,
|
|
313
|
+
pendingDependencies: [...needs],
|
|
314
|
+
blocked: needs.length > 0,
|
|
315
|
+
priority
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const workflowRun = {
|
|
319
|
+
id: runId,
|
|
320
|
+
name: input.spec.name,
|
|
321
|
+
version: input.spec.version,
|
|
322
|
+
status: "queued",
|
|
323
|
+
createdAt: timestamp,
|
|
324
|
+
queuedAt: timestamp,
|
|
325
|
+
trigger: input.trigger,
|
|
326
|
+
env: input.spec.env,
|
|
327
|
+
secrets: input.spec.secrets,
|
|
328
|
+
jobs,
|
|
329
|
+
metadata: {
|
|
330
|
+
idempotencyKey: input.idempotencyKey,
|
|
331
|
+
concurrencyGroup: input.concurrencyGroup,
|
|
332
|
+
target: input.spec.target,
|
|
333
|
+
isolation: input.spec.isolation
|
|
334
|
+
},
|
|
335
|
+
artifacts: []
|
|
336
|
+
};
|
|
337
|
+
return workflowRun;
|
|
338
|
+
}
|
|
339
|
+
async registerIdempotencyKey(key, runId) {
|
|
340
|
+
const cacheKey = `kb:idempotency:${key}`;
|
|
341
|
+
const registered = await this.cache.setIfNotExists(
|
|
342
|
+
cacheKey,
|
|
343
|
+
runId,
|
|
344
|
+
this.idempotencyTtlMs
|
|
345
|
+
);
|
|
346
|
+
if (!registered) {
|
|
347
|
+
const existingRunId = await this.cache.get(cacheKey);
|
|
348
|
+
if (existingRunId) {
|
|
349
|
+
const existing = await this.stateStore.getRun(existingRunId);
|
|
350
|
+
if (existing) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Idempotency key ${key} already associated with run ${existingRunId}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
throw new Error(`Failed to register idempotency key ${key}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async loadByIdempotencyKey(key) {
|
|
360
|
+
const cacheKey = `kb:idempotency:${key}`;
|
|
361
|
+
const existingRunId = await this.cache.get(cacheKey);
|
|
362
|
+
if (!existingRunId) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return this.stateStore.getRun(existingRunId);
|
|
366
|
+
}
|
|
367
|
+
async releaseConcurrency(run) {
|
|
368
|
+
const group = run.metadata?.concurrencyGroup;
|
|
369
|
+
if (!group) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
await this.concurrencyManager.release(group, run.id);
|
|
373
|
+
this.logger.debug("Released concurrency group", {
|
|
374
|
+
runId: run.id,
|
|
375
|
+
group
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
var DEFAULT_TTL_MS = 1e3 * 60 * 30;
|
|
380
|
+
function resolveTtlMs(explicit) {
|
|
381
|
+
if (typeof explicit === "number" && explicit > 0) {
|
|
382
|
+
return explicit;
|
|
383
|
+
}
|
|
384
|
+
const envValue = process.env[CONCURRENCY_TTL_ENV];
|
|
385
|
+
const parsed = envValue ? Number(envValue) : void 0;
|
|
386
|
+
if (parsed && Number.isFinite(parsed) && parsed > 0) {
|
|
387
|
+
return parsed;
|
|
388
|
+
}
|
|
389
|
+
return DEFAULT_TTL_MS;
|
|
390
|
+
}
|
|
391
|
+
var ConcurrencyManager = class {
|
|
392
|
+
constructor(cache, logger, options = {}) {
|
|
393
|
+
this.logger = logger;
|
|
394
|
+
this.cache = cache;
|
|
395
|
+
this.ttlMs = resolveTtlMs(options.ttlMs);
|
|
396
|
+
}
|
|
397
|
+
cache;
|
|
398
|
+
ttlMs;
|
|
399
|
+
async acquire(group, runId, options = {}) {
|
|
400
|
+
const ttl = resolveTtlMs(options.ttlMs ?? this.ttlMs);
|
|
401
|
+
const key = `kb:concurrency:${group}`;
|
|
402
|
+
const acquired = await this.cache.setIfNotExists(key, runId, ttl);
|
|
403
|
+
this.logger.debug("Concurrency acquire attempt", {
|
|
404
|
+
group,
|
|
405
|
+
runId,
|
|
406
|
+
acquired
|
|
407
|
+
});
|
|
408
|
+
return acquired;
|
|
409
|
+
}
|
|
410
|
+
async release(group, runId) {
|
|
411
|
+
const key = `kb:concurrency:${group}`;
|
|
412
|
+
const current = await this.cache.get(key);
|
|
413
|
+
if (current === runId) {
|
|
414
|
+
await this.cache.delete(key);
|
|
415
|
+
this.logger.debug("Concurrency lock released", { group, runId });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
async getActiveRun(group) {
|
|
419
|
+
const key = `kb:concurrency:${group}`;
|
|
420
|
+
return this.cache.get(key);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// src/scheduler.ts
|
|
425
|
+
var Scheduler = class {
|
|
426
|
+
constructor(cache, logger, options = {}) {
|
|
427
|
+
this.logger = logger;
|
|
428
|
+
this.cache = cache;
|
|
429
|
+
this.defaultPriority = options.defaultPriority ?? "normal";
|
|
430
|
+
this.lookAheadMs = options.lookAheadMs ?? 1e3;
|
|
431
|
+
}
|
|
432
|
+
cache;
|
|
433
|
+
defaultPriority;
|
|
434
|
+
lookAheadMs;
|
|
435
|
+
priorityOrder = ["high", "normal", "low"];
|
|
436
|
+
async scheduleRun(run) {
|
|
437
|
+
const readyJobs = run.jobs.filter((job) => {
|
|
438
|
+
if (job.blocked) {
|
|
439
|
+
this.logger.debug("Job blocked by dependencies; deferring enqueue", {
|
|
440
|
+
runId: run.id,
|
|
441
|
+
jobId: job.id,
|
|
442
|
+
needs: job.needs
|
|
443
|
+
});
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
});
|
|
448
|
+
await Promise.all(
|
|
449
|
+
readyJobs.map((job) => {
|
|
450
|
+
const priority = job.priority ?? this.defaultPriority;
|
|
451
|
+
return this.enqueueJob(run.id, job, priority);
|
|
452
|
+
})
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
async enqueueJob(runId, job, priority = this.defaultPriority) {
|
|
456
|
+
if (job.blocked) {
|
|
457
|
+
this.logger.debug("Skipping enqueue for blocked job", {
|
|
458
|
+
runId,
|
|
459
|
+
jobId: job.id,
|
|
460
|
+
needs: job.pendingDependencies
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
const entryId = `${runId}:${job.id}:${now}:${Math.random().toString(36).slice(2, 10)}`;
|
|
466
|
+
const entry = {
|
|
467
|
+
id: entryId,
|
|
468
|
+
runId,
|
|
469
|
+
jobId: job.id,
|
|
470
|
+
jobName: job.jobName,
|
|
471
|
+
priority,
|
|
472
|
+
enqueuedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
473
|
+
availableAt: now
|
|
474
|
+
};
|
|
475
|
+
await this.cache.zadd(
|
|
476
|
+
`kb:jobqueue:${priority}`,
|
|
477
|
+
entry.availableAt,
|
|
478
|
+
JSON.stringify(entry)
|
|
479
|
+
);
|
|
480
|
+
this.logger.debug("Job enqueued", { runId, jobId: job.id, priority });
|
|
481
|
+
}
|
|
482
|
+
async dequeueJob() {
|
|
483
|
+
for (const priority of this.priorityOrder) {
|
|
484
|
+
const entry = await this.dequeueFromPriority(priority);
|
|
485
|
+
if (entry) {
|
|
486
|
+
return entry;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
async reschedule(entry, delayMs) {
|
|
492
|
+
const availableAt = Date.now() + delayMs;
|
|
493
|
+
const next = {
|
|
494
|
+
...entry,
|
|
495
|
+
availableAt,
|
|
496
|
+
enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
497
|
+
};
|
|
498
|
+
await this.cache.zadd(
|
|
499
|
+
`kb:jobqueue:${entry.priority}`,
|
|
500
|
+
availableAt,
|
|
501
|
+
JSON.stringify(next)
|
|
502
|
+
);
|
|
503
|
+
this.logger.debug("Job rescheduled", {
|
|
504
|
+
runId: entry.runId,
|
|
505
|
+
jobId: entry.jobId,
|
|
506
|
+
delayMs,
|
|
507
|
+
priority: entry.priority
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
async dequeueFromPriority(priority) {
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
const key = `kb:jobqueue:${priority}`;
|
|
513
|
+
const results = await this.cache.zrangebyscore(
|
|
514
|
+
key,
|
|
515
|
+
0,
|
|
516
|
+
now + this.lookAheadMs
|
|
517
|
+
);
|
|
518
|
+
if (results.length === 0) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const raw = results[0];
|
|
522
|
+
if (typeof raw !== "string") {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
const entry = JSON.parse(raw);
|
|
527
|
+
await this.cache.zrem(key, raw);
|
|
528
|
+
return entry;
|
|
529
|
+
} catch (error) {
|
|
530
|
+
this.logger.error("Failed to parse job queue entry", error instanceof Error ? error : void 0);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
getDefaultPriority() {
|
|
535
|
+
return this.defaultPriority;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// src/secrets.ts
|
|
540
|
+
var EnvSecretProvider = class {
|
|
541
|
+
prefix;
|
|
542
|
+
allowPlain;
|
|
543
|
+
constructor(options = {}) {
|
|
544
|
+
this.prefix = options.prefix ?? "KB_SECRET_";
|
|
545
|
+
this.allowPlain = options.allowPlain ?? true;
|
|
546
|
+
}
|
|
547
|
+
async resolve(names) {
|
|
548
|
+
const resolved = {};
|
|
549
|
+
for (const name of names) {
|
|
550
|
+
if (!name) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const prefixed = `${this.prefix}${name}`;
|
|
554
|
+
const candidates = this.allowPlain ? [process.env[name], process.env[prefixed]] : [process.env[prefixed]];
|
|
555
|
+
const value = candidates.find((candidate) => typeof candidate === "string");
|
|
556
|
+
if (typeof value === "string") {
|
|
557
|
+
resolved[name] = value;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return resolved;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
function createDefaultSecretProvider() {
|
|
564
|
+
return new EnvSecretProvider();
|
|
565
|
+
}
|
|
566
|
+
var EventBusBridge = class {
|
|
567
|
+
constructor(events, logger) {
|
|
568
|
+
this.logger = logger;
|
|
569
|
+
this.events = events;
|
|
570
|
+
}
|
|
571
|
+
events;
|
|
572
|
+
channel = WORKFLOW_REDIS_CHANNEL;
|
|
573
|
+
async publish(event) {
|
|
574
|
+
const payload = {
|
|
575
|
+
...event,
|
|
576
|
+
timestamp: event.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
577
|
+
};
|
|
578
|
+
await this.events.publish(this.channel, payload);
|
|
579
|
+
this.logger.debug("Workflow event published", {
|
|
580
|
+
channel: this.channel,
|
|
581
|
+
type: event.type,
|
|
582
|
+
runId: event.runId
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// src/retry.ts
|
|
588
|
+
var DEFAULT_INITIAL_INTERVAL_MS = 1e3;
|
|
589
|
+
function calculateBackoff(attempt, policy) {
|
|
590
|
+
if (!policy) {
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
const base = policy.initialIntervalMs ?? DEFAULT_INITIAL_INTERVAL_MS;
|
|
594
|
+
const backoff = policy.backoff === "lin" ? base * (attempt + 1) : base * Math.pow(2, attempt);
|
|
595
|
+
if (policy.maxIntervalMs) {
|
|
596
|
+
return Math.min(backoff, policy.maxIntervalMs);
|
|
597
|
+
}
|
|
598
|
+
return backoff;
|
|
599
|
+
}
|
|
600
|
+
function shouldRetry(attempt, policy) {
|
|
601
|
+
if (!policy) {
|
|
602
|
+
return { shouldRetry: false };
|
|
603
|
+
}
|
|
604
|
+
if (attempt >= policy.max) {
|
|
605
|
+
return { shouldRetry: false };
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
shouldRetry: true,
|
|
609
|
+
nextDelayMs: calculateBackoff(attempt, policy)
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/run-snapshot.ts
|
|
614
|
+
var SNAPSHOT_VERSION = "1.0.0";
|
|
615
|
+
var RunSnapshotStorage = class {
|
|
616
|
+
constructor(cache, logger) {
|
|
617
|
+
this.cache = cache;
|
|
618
|
+
this.logger = logger;
|
|
619
|
+
}
|
|
620
|
+
getSnapshotKey(runId) {
|
|
621
|
+
return `workflow:snapshot:${runId}`;
|
|
622
|
+
}
|
|
623
|
+
async createSnapshot(run, stepOutputs, env, refs) {
|
|
624
|
+
const snapshot = {
|
|
625
|
+
runId: run.id,
|
|
626
|
+
run,
|
|
627
|
+
stepOutputs,
|
|
628
|
+
env,
|
|
629
|
+
refs,
|
|
630
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
631
|
+
version: SNAPSHOT_VERSION
|
|
632
|
+
};
|
|
633
|
+
const key = this.getSnapshotKey(run.id);
|
|
634
|
+
await this.cache.set(
|
|
635
|
+
key,
|
|
636
|
+
JSON.stringify(snapshot),
|
|
637
|
+
7 * 24 * 60 * 60 * 1e3
|
|
638
|
+
// 7 days in ms
|
|
639
|
+
);
|
|
640
|
+
this.logger.info("Snapshot created", { runId: run.id });
|
|
641
|
+
return snapshot;
|
|
642
|
+
}
|
|
643
|
+
async getSnapshot(runId) {
|
|
644
|
+
const key = this.getSnapshotKey(runId);
|
|
645
|
+
const stored = await this.cache.get(key);
|
|
646
|
+
if (!stored) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const snapshot = JSON.parse(stored);
|
|
651
|
+
if (snapshot.version !== SNAPSHOT_VERSION) {
|
|
652
|
+
this.logger.warn("Snapshot version mismatch", {
|
|
653
|
+
runId,
|
|
654
|
+
expected: SNAPSHOT_VERSION,
|
|
655
|
+
actual: snapshot.version
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return snapshot;
|
|
659
|
+
} catch (error) {
|
|
660
|
+
this.logger.error("Failed to parse snapshot", error instanceof Error ? error : void 0, {
|
|
661
|
+
runId
|
|
662
|
+
});
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async deleteSnapshot(runId) {
|
|
667
|
+
const key = this.getSnapshotKey(runId);
|
|
668
|
+
await this.cache.delete(key);
|
|
669
|
+
this.logger.debug("Snapshot deleted", { runId });
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// src/engine.ts
|
|
674
|
+
var WorkflowEngine = class {
|
|
675
|
+
constructor(options) {
|
|
676
|
+
this.options = options;
|
|
677
|
+
this.logger = options.logger;
|
|
678
|
+
this.analytics = options.analytics;
|
|
679
|
+
this.stateStore = new StateStore(options.cache, this.logger);
|
|
680
|
+
this.concurrency = new ConcurrencyManager(
|
|
681
|
+
options.cache,
|
|
682
|
+
this.logger,
|
|
683
|
+
options.concurrency
|
|
684
|
+
);
|
|
685
|
+
this.runCoordinator = new RunCoordinator(
|
|
686
|
+
options.cache,
|
|
687
|
+
this.stateStore,
|
|
688
|
+
this.concurrency,
|
|
689
|
+
this.logger,
|
|
690
|
+
options.runCoordinator
|
|
691
|
+
);
|
|
692
|
+
this.scheduler = new Scheduler(options.cache, this.logger, options.scheduler);
|
|
693
|
+
this.events = new EventBusBridge(options.events, this.logger);
|
|
694
|
+
this.loader = new WorkflowLoader(this.logger);
|
|
695
|
+
this.maxWorkflowDepth = options.maxWorkflowDepth ?? 2;
|
|
696
|
+
this.snapshotStorage = new RunSnapshotStorage(options.cache, this.logger);
|
|
697
|
+
}
|
|
698
|
+
loader;
|
|
699
|
+
maxWorkflowDepth;
|
|
700
|
+
logger;
|
|
701
|
+
analytics;
|
|
702
|
+
stateStore;
|
|
703
|
+
concurrency;
|
|
704
|
+
runCoordinator;
|
|
705
|
+
scheduler;
|
|
706
|
+
events;
|
|
707
|
+
snapshotStorage;
|
|
708
|
+
async dispose() {
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Subscribe to real-time events for a specific workflow run.
|
|
712
|
+
* Events are filtered by runId from the shared event bus channel.
|
|
713
|
+
*/
|
|
714
|
+
subscribeToRunEvents(runId, handler) {
|
|
715
|
+
return this.options.events.subscribe(WORKFLOW_REDIS_CHANNEL, async (raw) => {
|
|
716
|
+
const event = raw;
|
|
717
|
+
if (event.runId === runId) handler(event);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
async createRun(input) {
|
|
721
|
+
const run = await this.runCoordinator.ensureRun(input);
|
|
722
|
+
this.analytics?.track("workflow.run.created", {
|
|
723
|
+
runId: run.id,
|
|
724
|
+
name: run.name,
|
|
725
|
+
version: run.version,
|
|
726
|
+
jobCount: run.jobs.length,
|
|
727
|
+
trigger: input.trigger?.type
|
|
728
|
+
}).catch(() => {
|
|
729
|
+
});
|
|
730
|
+
await this.events.publish({
|
|
731
|
+
type: EVENT_NAMES.run.created,
|
|
732
|
+
runId: run.id,
|
|
733
|
+
payload: {
|
|
734
|
+
status: run.status,
|
|
735
|
+
name: run.name,
|
|
736
|
+
version: run.version
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
await this.scheduler.scheduleRun(run);
|
|
740
|
+
return run;
|
|
741
|
+
}
|
|
742
|
+
async runFromSpec(spec, input) {
|
|
743
|
+
return this.createRun({
|
|
744
|
+
...input,
|
|
745
|
+
spec
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
async runFromFile(filePath, input) {
|
|
749
|
+
const result = await this.loader.fromFile(filePath);
|
|
750
|
+
return this.runFromSpec(result.spec, input);
|
|
751
|
+
}
|
|
752
|
+
async runFromInline(spec, input) {
|
|
753
|
+
const result = this.loader.fromInline(spec);
|
|
754
|
+
return this.runFromSpec(result.spec, input);
|
|
755
|
+
}
|
|
756
|
+
async getRun(runId) {
|
|
757
|
+
return this.stateStore.getRun(runId);
|
|
758
|
+
}
|
|
759
|
+
async cancelRun(runId) {
|
|
760
|
+
const run = await this.getRun(runId);
|
|
761
|
+
await this.stateStore.updateRun(runId, (draft) => {
|
|
762
|
+
draft.status = "cancelled";
|
|
763
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
764
|
+
return draft;
|
|
765
|
+
});
|
|
766
|
+
this.analytics?.track("workflow.run.cancelled", {
|
|
767
|
+
runId,
|
|
768
|
+
name: run?.name,
|
|
769
|
+
reason: "cancelled by parent workflow"
|
|
770
|
+
}).catch(() => {
|
|
771
|
+
});
|
|
772
|
+
await this.events.publish({
|
|
773
|
+
type: EVENT_NAMES.run.cancelled,
|
|
774
|
+
runId,
|
|
775
|
+
payload: { reason: "cancelled by parent workflow" }
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Mark job as failed and optionally schedule retry.
|
|
780
|
+
* Implements exponential/linear backoff retry logic.
|
|
781
|
+
*/
|
|
782
|
+
async markJobFailed(runId, jobId, error, shouldRetry2 = true) {
|
|
783
|
+
const run = await this.stateStore.getRun(runId);
|
|
784
|
+
if (!run) {
|
|
785
|
+
this.logger.warn("Cannot mark job as failed: run not found", { runId, jobId });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const job = run.jobs.find((j) => j.id === jobId);
|
|
789
|
+
if (!job) {
|
|
790
|
+
this.logger.warn("Cannot mark job as failed: job not found", { runId, jobId });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
await this.stateStore.updateJob(runId, jobId, (draft) => {
|
|
794
|
+
draft.status = "failed";
|
|
795
|
+
draft.error = {
|
|
796
|
+
message: error.message,
|
|
797
|
+
stack: error.stack,
|
|
798
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
799
|
+
};
|
|
800
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
801
|
+
draft.attempt = (draft.attempt || 0) + 1;
|
|
802
|
+
});
|
|
803
|
+
this.logger.error("Job failed", error, {
|
|
804
|
+
runId,
|
|
805
|
+
jobId,
|
|
806
|
+
attempt: (job.attempt || 0) + 1
|
|
807
|
+
});
|
|
808
|
+
this.analytics?.track("workflow.job.failed", {
|
|
809
|
+
runId,
|
|
810
|
+
jobId,
|
|
811
|
+
jobName: job.jobName,
|
|
812
|
+
attempt: (job.attempt || 0) + 1,
|
|
813
|
+
errorMessage: error.message,
|
|
814
|
+
willRetry: shouldRetry2 && this.shouldRetryJob(job)
|
|
815
|
+
}).catch(() => {
|
|
816
|
+
});
|
|
817
|
+
await this.events.publish({
|
|
818
|
+
type: EVENT_NAMES.job.failed,
|
|
819
|
+
runId,
|
|
820
|
+
jobId,
|
|
821
|
+
payload: { jobName: job.jobName, error: error.message, attempt: (job.attempt || 0) + 1 }
|
|
822
|
+
});
|
|
823
|
+
if (shouldRetry2 && this.shouldRetryJob(job)) {
|
|
824
|
+
const backoffMs = this.calculateBackoff(job.attempt || 0, job.retries);
|
|
825
|
+
this.logger.info("Scheduling job retry", {
|
|
826
|
+
runId,
|
|
827
|
+
jobId,
|
|
828
|
+
attempt: (job.attempt || 0) + 1,
|
|
829
|
+
backoffMs
|
|
830
|
+
});
|
|
831
|
+
setTimeout(async () => {
|
|
832
|
+
await this.stateStore.updateJob(runId, jobId, (draft) => {
|
|
833
|
+
draft.status = "queued";
|
|
834
|
+
draft.error = void 0;
|
|
835
|
+
draft.startedAt = void 0;
|
|
836
|
+
draft.finishedAt = void 0;
|
|
837
|
+
});
|
|
838
|
+
const updatedRun = await this.stateStore.getRun(runId);
|
|
839
|
+
const updatedJob = updatedRun?.jobs.find((j) => j.id === jobId);
|
|
840
|
+
if (updatedJob) {
|
|
841
|
+
await this.scheduler.enqueueJob(runId, updatedJob, updatedJob.priority ?? "normal");
|
|
842
|
+
}
|
|
843
|
+
this.logger.info("Job re-queued for retry", { runId, jobId });
|
|
844
|
+
}, backoffMs);
|
|
845
|
+
} else {
|
|
846
|
+
await this.moveToDLQ(runId, jobId, error);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Mark job as interrupted (e.g., during graceful shutdown).
|
|
851
|
+
* Interrupted jobs will be retried on next daemon startup.
|
|
852
|
+
*/
|
|
853
|
+
async markJobInterrupted(runId, jobId) {
|
|
854
|
+
await this.stateStore.updateJob(runId, jobId, (draft) => {
|
|
855
|
+
draft.status = "interrupted";
|
|
856
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
857
|
+
});
|
|
858
|
+
this.logger.warn("Job interrupted", { runId, jobId });
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Mark job as started (running).
|
|
862
|
+
*/
|
|
863
|
+
async markJobStarted(runId, jobId) {
|
|
864
|
+
await this.stateStore.updateJob(runId, jobId, (draft) => {
|
|
865
|
+
draft.status = "running";
|
|
866
|
+
draft.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
867
|
+
});
|
|
868
|
+
this.logger.debug("Job started", { runId, jobId });
|
|
869
|
+
const run = await this.getRun(runId);
|
|
870
|
+
const job = run?.jobs.find((j) => j.id === jobId);
|
|
871
|
+
this.analytics?.track("workflow.job.started", {
|
|
872
|
+
runId,
|
|
873
|
+
jobId,
|
|
874
|
+
jobName: job?.jobName,
|
|
875
|
+
stepCount: job?.steps.length ?? 0
|
|
876
|
+
}).catch(() => {
|
|
877
|
+
});
|
|
878
|
+
await this.events.publish({
|
|
879
|
+
type: EVENT_NAMES.job.started,
|
|
880
|
+
runId,
|
|
881
|
+
jobId,
|
|
882
|
+
payload: { jobName: job?.jobName }
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Mark job as completed successfully.
|
|
887
|
+
*/
|
|
888
|
+
async markJobCompleted(runId, jobId) {
|
|
889
|
+
const run = await this.getRun(runId);
|
|
890
|
+
const job = run?.jobs.find((j) => j.id === jobId);
|
|
891
|
+
const startTime = job?.startedAt ? new Date(job.startedAt).getTime() : Date.now();
|
|
892
|
+
const duration = Date.now() - startTime;
|
|
893
|
+
await this.stateStore.updateJob(runId, jobId, (draft) => {
|
|
894
|
+
draft.status = "success";
|
|
895
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
896
|
+
});
|
|
897
|
+
this.logger.info("Job completed successfully", { runId, jobId });
|
|
898
|
+
this.analytics?.track("workflow.job.completed", {
|
|
899
|
+
runId,
|
|
900
|
+
jobId,
|
|
901
|
+
jobName: job?.jobName,
|
|
902
|
+
durationMs: duration,
|
|
903
|
+
stepCount: job?.steps.length ?? 0
|
|
904
|
+
}).catch(() => {
|
|
905
|
+
});
|
|
906
|
+
await this.events.publish({
|
|
907
|
+
type: EVENT_NAMES.job.succeeded,
|
|
908
|
+
runId,
|
|
909
|
+
jobId,
|
|
910
|
+
payload: { jobName: job?.jobName, durationMs: duration }
|
|
911
|
+
});
|
|
912
|
+
await this.checkRunCompletion(runId);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Check if all jobs in a run are completed and update run status accordingly.
|
|
916
|
+
*/
|
|
917
|
+
async checkRunCompletion(runId) {
|
|
918
|
+
const run = await this.getRun(runId);
|
|
919
|
+
if (!run) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const allSuccess = run.jobs.every((j) => j.status === "success");
|
|
923
|
+
const anyFailed = run.jobs.some((j) => j.status === "failed");
|
|
924
|
+
const anyRunning = run.jobs.some((j) => j.status === "running" || j.status === "queued");
|
|
925
|
+
if (anyRunning) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (allSuccess) {
|
|
929
|
+
await this.stateStore.updateRun(runId, (draft) => {
|
|
930
|
+
draft.status = "success";
|
|
931
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
932
|
+
return draft;
|
|
933
|
+
});
|
|
934
|
+
this.logger.info("Workflow run completed successfully", { runId });
|
|
935
|
+
this.analytics?.track("workflow.run.completed", {
|
|
936
|
+
runId,
|
|
937
|
+
name: run.name,
|
|
938
|
+
jobCount: run.jobs.length,
|
|
939
|
+
status: "success"
|
|
940
|
+
}).catch(() => {
|
|
941
|
+
});
|
|
942
|
+
await this.events.publish({
|
|
943
|
+
type: EVENT_NAMES.run.finished,
|
|
944
|
+
runId,
|
|
945
|
+
payload: { status: "success", name: run.name }
|
|
946
|
+
});
|
|
947
|
+
} else if (anyFailed) {
|
|
948
|
+
await this.stateStore.updateRun(runId, (draft) => {
|
|
949
|
+
draft.status = "failed";
|
|
950
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
951
|
+
return draft;
|
|
952
|
+
});
|
|
953
|
+
this.logger.info("Workflow run failed", { runId });
|
|
954
|
+
this.analytics?.track("workflow.run.completed", {
|
|
955
|
+
runId,
|
|
956
|
+
name: run.name,
|
|
957
|
+
jobCount: run.jobs.length,
|
|
958
|
+
status: "failed"
|
|
959
|
+
}).catch(() => {
|
|
960
|
+
});
|
|
961
|
+
await this.events.publish({
|
|
962
|
+
type: EVENT_NAMES.run.failed,
|
|
963
|
+
runId,
|
|
964
|
+
payload: { status: "failed", name: run.name }
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Mark step as started (running).
|
|
970
|
+
*/
|
|
971
|
+
async markStepStarted(runId, jobId, stepId) {
|
|
972
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
973
|
+
draft.status = "running";
|
|
974
|
+
draft.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
975
|
+
});
|
|
976
|
+
this.logger.debug("Step started", { runId, jobId, stepId });
|
|
977
|
+
await this.events.publish({
|
|
978
|
+
type: EVENT_NAMES.step.started,
|
|
979
|
+
runId,
|
|
980
|
+
jobId,
|
|
981
|
+
stepId
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Mark step as completed successfully with output.
|
|
986
|
+
*/
|
|
987
|
+
async markStepCompleted(runId, jobId, stepId, output) {
|
|
988
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
989
|
+
draft.status = "success";
|
|
990
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
991
|
+
if (output !== void 0) {
|
|
992
|
+
draft.outputs = output;
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
this.logger.debug("Step completed", { runId, jobId, stepId });
|
|
996
|
+
await this.events.publish({
|
|
997
|
+
type: EVENT_NAMES.step.succeeded,
|
|
998
|
+
runId,
|
|
999
|
+
jobId,
|
|
1000
|
+
stepId,
|
|
1001
|
+
payload: output !== void 0 ? { outputs: output } : void 0
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Mark step as failed with error.
|
|
1006
|
+
*/
|
|
1007
|
+
async markStepFailed(runId, jobId, stepId, error, outputs) {
|
|
1008
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
1009
|
+
draft.status = "failed";
|
|
1010
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1011
|
+
draft.error = {
|
|
1012
|
+
message: error.message,
|
|
1013
|
+
stack: error.stack
|
|
1014
|
+
};
|
|
1015
|
+
if (outputs) draft.outputs = outputs;
|
|
1016
|
+
});
|
|
1017
|
+
this.logger.debug("Step failed", { runId, jobId, stepId, error: error.message });
|
|
1018
|
+
await this.events.publish({
|
|
1019
|
+
type: EVENT_NAMES.step.failed,
|
|
1020
|
+
runId,
|
|
1021
|
+
jobId,
|
|
1022
|
+
stepId,
|
|
1023
|
+
payload: { error: error.message }
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Mark step as waiting for human approval.
|
|
1028
|
+
*/
|
|
1029
|
+
async markStepWaitingApproval(runId, jobId, stepId) {
|
|
1030
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
1031
|
+
draft.status = "waiting_approval";
|
|
1032
|
+
draft.startedAt = draft.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1033
|
+
});
|
|
1034
|
+
await this.events.publish({
|
|
1035
|
+
type: EVENT_NAMES.step.waitingApproval,
|
|
1036
|
+
runId,
|
|
1037
|
+
payload: { jobId, stepId }
|
|
1038
|
+
});
|
|
1039
|
+
this.logger.info("Step waiting for approval", { runId, jobId, stepId });
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Resolve a pending approval — approve or reject.
|
|
1043
|
+
* On approve: marks step as success with approval outputs.
|
|
1044
|
+
* On reject: marks step as failed with rejection error.
|
|
1045
|
+
*/
|
|
1046
|
+
async resolveApproval(runId, jobId, stepId, action, data, comment) {
|
|
1047
|
+
if (action === "approve") {
|
|
1048
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
1049
|
+
draft.status = "success";
|
|
1050
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1051
|
+
draft.outputs = {
|
|
1052
|
+
approved: true,
|
|
1053
|
+
action,
|
|
1054
|
+
...comment ? { comment } : {},
|
|
1055
|
+
...data ?? {}
|
|
1056
|
+
};
|
|
1057
|
+
});
|
|
1058
|
+
this.logger.info("Approval granted", { runId, jobId, stepId, comment });
|
|
1059
|
+
} else {
|
|
1060
|
+
await this.stateStore.updateStep(runId, jobId, stepId, (draft) => {
|
|
1061
|
+
draft.status = "failed";
|
|
1062
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1063
|
+
draft.error = {
|
|
1064
|
+
message: comment || "Approval rejected",
|
|
1065
|
+
code: "APPROVAL_REJECTED"
|
|
1066
|
+
};
|
|
1067
|
+
draft.outputs = {
|
|
1068
|
+
approved: false,
|
|
1069
|
+
action,
|
|
1070
|
+
...comment ? { comment } : {},
|
|
1071
|
+
...data ?? {}
|
|
1072
|
+
};
|
|
1073
|
+
});
|
|
1074
|
+
this.logger.info("Approval rejected", { runId, jobId, stepId, comment });
|
|
1075
|
+
}
|
|
1076
|
+
await this.events.publish({
|
|
1077
|
+
type: EVENT_NAMES.step.updated,
|
|
1078
|
+
runId,
|
|
1079
|
+
payload: { jobId, stepId, action }
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Get the state store for direct access (used by worker for gate restart-from).
|
|
1084
|
+
*/
|
|
1085
|
+
getStateStore() {
|
|
1086
|
+
return this.stateStore;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Get the scheduler for direct access (used by worker for gate re-enqueue).
|
|
1090
|
+
*/
|
|
1091
|
+
getScheduler() {
|
|
1092
|
+
return this.scheduler;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Mark stale running/queued runs as failed on daemon startup.
|
|
1096
|
+
* Runs that were in-flight when the daemon crashed are unrecoverable —
|
|
1097
|
+
* their executor process is gone, so we mark them failed immediately.
|
|
1098
|
+
*/
|
|
1099
|
+
async cleanupStaleRuns() {
|
|
1100
|
+
const runIds = await this.stateStore.getAllRunIds();
|
|
1101
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1102
|
+
let count = 0;
|
|
1103
|
+
await Promise.all(
|
|
1104
|
+
runIds.map(async (runId) => {
|
|
1105
|
+
const run = await this.stateStore.getRun(runId);
|
|
1106
|
+
if (!run) {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (run.status !== "running" && run.status !== "queued") {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
await this.stateStore.updateRun(runId, (draft) => {
|
|
1113
|
+
draft.status = "failed";
|
|
1114
|
+
draft.finishedAt = now;
|
|
1115
|
+
if (draft.startedAt) {
|
|
1116
|
+
draft.durationMs = new Date(now).getTime() - new Date(draft.startedAt).getTime();
|
|
1117
|
+
}
|
|
1118
|
+
for (const job of draft.jobs) {
|
|
1119
|
+
if (job.status === "running" || job.status === "queued") {
|
|
1120
|
+
job.status = "failed";
|
|
1121
|
+
job.error = { message: "Daemon restarted \u2014 run was abandoned" };
|
|
1122
|
+
job.finishedAt = now;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return draft;
|
|
1126
|
+
});
|
|
1127
|
+
count++;
|
|
1128
|
+
})
|
|
1129
|
+
);
|
|
1130
|
+
if (count > 0) {
|
|
1131
|
+
this.logger.warn("Cleaned up stale runs from previous daemon process", { count });
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Resume interrupted jobs on daemon startup.
|
|
1136
|
+
* Re-queues jobs that were interrupted during previous shutdown.
|
|
1137
|
+
*/
|
|
1138
|
+
async resumeInterruptedJobs() {
|
|
1139
|
+
const runIds = await this.stateStore.getAllRunIds();
|
|
1140
|
+
const results = await Promise.all(
|
|
1141
|
+
runIds.map(async (runId) => {
|
|
1142
|
+
const run = await this.stateStore.getRun(runId);
|
|
1143
|
+
if (!run) {
|
|
1144
|
+
return 0;
|
|
1145
|
+
}
|
|
1146
|
+
const interruptedJobs = run.jobs.filter((job) => job.status === "interrupted");
|
|
1147
|
+
await Promise.all(
|
|
1148
|
+
interruptedJobs.map(async (job) => {
|
|
1149
|
+
this.logger.info("Resuming interrupted job", { runId, jobId: job.id });
|
|
1150
|
+
await this.stateStore.updateJob(runId, job.id, (draft) => {
|
|
1151
|
+
draft.status = "queued";
|
|
1152
|
+
draft.startedAt = void 0;
|
|
1153
|
+
draft.finishedAt = void 0;
|
|
1154
|
+
});
|
|
1155
|
+
const queuedJob = { ...job, status: "queued" };
|
|
1156
|
+
await this.scheduler.enqueueJob(runId, queuedJob, queuedJob.priority ?? "normal");
|
|
1157
|
+
})
|
|
1158
|
+
);
|
|
1159
|
+
return interruptedJobs.length;
|
|
1160
|
+
})
|
|
1161
|
+
);
|
|
1162
|
+
const resumedCount = results.reduce((sum, count) => sum + count, 0);
|
|
1163
|
+
if (resumedCount > 0) {
|
|
1164
|
+
this.logger.info("Resumed interrupted jobs", { count: resumedCount });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Determine if job should be retried based on retry policy.
|
|
1169
|
+
*/
|
|
1170
|
+
shouldRetryJob(job) {
|
|
1171
|
+
const retryPolicy = job.retries || { max: 3};
|
|
1172
|
+
const attempt = job.attempt || 0;
|
|
1173
|
+
return attempt < retryPolicy.max;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Calculate backoff delay using exponential or linear strategy.
|
|
1177
|
+
*/
|
|
1178
|
+
calculateBackoff(attempt, policy) {
|
|
1179
|
+
const config = {
|
|
1180
|
+
backoff: policy?.backoff || "exp",
|
|
1181
|
+
initialIntervalMs: policy?.initialIntervalMs || 1e3,
|
|
1182
|
+
maxIntervalMs: policy?.maxIntervalMs || 6e4
|
|
1183
|
+
};
|
|
1184
|
+
let backoffMs;
|
|
1185
|
+
if (config.backoff === "exp") {
|
|
1186
|
+
backoffMs = config.initialIntervalMs * Math.pow(2, attempt);
|
|
1187
|
+
} else {
|
|
1188
|
+
backoffMs = config.initialIntervalMs * (attempt + 1);
|
|
1189
|
+
}
|
|
1190
|
+
return Math.min(backoffMs, config.maxIntervalMs);
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Move permanently failed job to Dead Letter Queue.
|
|
1194
|
+
*/
|
|
1195
|
+
async moveToDLQ(runId, jobId, error) {
|
|
1196
|
+
this.logger.warn("Job moved to DLQ after max retries", { runId, jobId });
|
|
1197
|
+
await this.stateStore.updateRun(runId, (draft) => {
|
|
1198
|
+
draft.status = "dlq";
|
|
1199
|
+
draft.result = {
|
|
1200
|
+
status: "dlq",
|
|
1201
|
+
summary: `Job ${jobId} failed after max retries`,
|
|
1202
|
+
error: {
|
|
1203
|
+
message: error.message,
|
|
1204
|
+
details: {
|
|
1205
|
+
stack: error.stack
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1210
|
+
});
|
|
1211
|
+
const dlqKey = `workflow:dlq:${runId}:${jobId}`;
|
|
1212
|
+
const run = await this.stateStore.getRun(runId);
|
|
1213
|
+
const job = run?.jobs.find((j) => j.id === jobId);
|
|
1214
|
+
await this.options.cache.set(
|
|
1215
|
+
dlqKey,
|
|
1216
|
+
JSON.stringify({
|
|
1217
|
+
runId,
|
|
1218
|
+
jobId,
|
|
1219
|
+
jobName: job?.jobName,
|
|
1220
|
+
error: {
|
|
1221
|
+
message: error.message,
|
|
1222
|
+
stack: error.stack
|
|
1223
|
+
},
|
|
1224
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1225
|
+
attempts: job?.attempt || 0
|
|
1226
|
+
}),
|
|
1227
|
+
7 * 24 * 60 * 60 * 1e3
|
|
1228
|
+
// TTL 7 days
|
|
1229
|
+
);
|
|
1230
|
+
const updatedRun = await this.stateStore.getRun(runId);
|
|
1231
|
+
if (updatedRun) {
|
|
1232
|
+
await this.publishRunEvent(EVENT_NAMES.run.failed, updatedRun);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
async updateRun(runId, mutator) {
|
|
1236
|
+
const updated = await this.stateStore.updateRun(runId, mutator);
|
|
1237
|
+
if (updated) {
|
|
1238
|
+
await this.publishRunEvent(EVENT_NAMES.run.updated, updated);
|
|
1239
|
+
}
|
|
1240
|
+
return updated;
|
|
1241
|
+
}
|
|
1242
|
+
async finalizeRun(runId, status, context = {}) {
|
|
1243
|
+
const updated = await this.stateStore.updateRun(runId, (run) => {
|
|
1244
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1245
|
+
run.status = status;
|
|
1246
|
+
run.finishedAt = now;
|
|
1247
|
+
run.durationMs = computeDurationMs(run.startedAt ?? run.queuedAt, now);
|
|
1248
|
+
if (context.jobs) {
|
|
1249
|
+
run.jobs = context.jobs;
|
|
1250
|
+
}
|
|
1251
|
+
if (context.steps) ;
|
|
1252
|
+
return run;
|
|
1253
|
+
});
|
|
1254
|
+
if (updated) {
|
|
1255
|
+
await this.runCoordinator.releaseConcurrency(updated);
|
|
1256
|
+
await this.publishRunEvent(
|
|
1257
|
+
status === "failed" ? EVENT_NAMES.run.failed : status === "cancelled" ? EVENT_NAMES.run.cancelled : EVENT_NAMES.run.finished,
|
|
1258
|
+
updated
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
return updated;
|
|
1262
|
+
}
|
|
1263
|
+
async nextJob() {
|
|
1264
|
+
return this.scheduler.dequeueJob();
|
|
1265
|
+
}
|
|
1266
|
+
async rescheduleJob(entry, delayMs) {
|
|
1267
|
+
await this.scheduler.reschedule(entry, delayMs);
|
|
1268
|
+
}
|
|
1269
|
+
async publishRunEvent(type, run) {
|
|
1270
|
+
await this.events.publish({
|
|
1271
|
+
type,
|
|
1272
|
+
runId: run.id,
|
|
1273
|
+
payload: {
|
|
1274
|
+
status: run.status,
|
|
1275
|
+
name: run.name,
|
|
1276
|
+
version: run.version
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Publish a log entry for real-time streaming to Studio UI.
|
|
1282
|
+
*/
|
|
1283
|
+
async publishLog(runId, jobId, stepId, entry) {
|
|
1284
|
+
await this.events.publish({
|
|
1285
|
+
type: EVENT_NAMES.log.appended,
|
|
1286
|
+
runId,
|
|
1287
|
+
jobId,
|
|
1288
|
+
stepId,
|
|
1289
|
+
payload: entry
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Create a snapshot of the current run state
|
|
1294
|
+
*/
|
|
1295
|
+
async createSnapshot(runId, stepOutputs, env, refs) {
|
|
1296
|
+
const run = await this.getRun(runId);
|
|
1297
|
+
if (!run) {
|
|
1298
|
+
this.logger.warn("Cannot create snapshot: run not found", { runId });
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
return this.snapshotStorage.createSnapshot(run, stepOutputs, env, refs);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Get a snapshot for a run
|
|
1305
|
+
*/
|
|
1306
|
+
async getSnapshot(runId) {
|
|
1307
|
+
return this.snapshotStorage.getSnapshot(runId);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Replay a run from a snapshot, optionally starting from a specific step
|
|
1311
|
+
*/
|
|
1312
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity -- Replay orchestration: handles snapshot loading, step state transitions (before/at/after fromStep), env merging, job traversal, and conditional status reset logic
|
|
1313
|
+
async replayRun(runId, options = {}) {
|
|
1314
|
+
const snapshot = await this.snapshotStorage.getSnapshot(runId);
|
|
1315
|
+
if (!snapshot) {
|
|
1316
|
+
this.logger.warn("Cannot replay: snapshot not found", { runId });
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
if (snapshot.refs?.workspaceSnapshotId || snapshot.refs?.environmentSnapshotId) {
|
|
1320
|
+
if (!this.options.snapshotManager) {
|
|
1321
|
+
throw new Error("SnapshotManager is required for replay with infra snapshot references");
|
|
1322
|
+
}
|
|
1323
|
+
if (snapshot.refs.workspaceSnapshotId) {
|
|
1324
|
+
await this.options.snapshotManager.restoreSnapshot({
|
|
1325
|
+
snapshotId: snapshot.refs.workspaceSnapshotId,
|
|
1326
|
+
metadata: { source: "workflow.replay", runId }
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
if (snapshot.refs.environmentSnapshotId) {
|
|
1330
|
+
await this.options.snapshotManager.restoreSnapshot({
|
|
1331
|
+
snapshotId: snapshot.refs.environmentSnapshotId,
|
|
1332
|
+
metadata: { source: "workflow.replay", runId }
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const restoredRun = snapshot.run;
|
|
1337
|
+
if (options.env) {
|
|
1338
|
+
restoredRun.env = { ...snapshot.env, ...options.env };
|
|
1339
|
+
} else {
|
|
1340
|
+
restoredRun.env = snapshot.env;
|
|
1341
|
+
}
|
|
1342
|
+
if (options.fromStepId) {
|
|
1343
|
+
for (const job of restoredRun.jobs) {
|
|
1344
|
+
let foundStep = false;
|
|
1345
|
+
for (const step of job.steps) {
|
|
1346
|
+
if (step.id === options.fromStepId) {
|
|
1347
|
+
foundStep = true;
|
|
1348
|
+
if (step.status !== "queued") {
|
|
1349
|
+
step.status = "queued";
|
|
1350
|
+
step.startedAt = void 0;
|
|
1351
|
+
step.finishedAt = void 0;
|
|
1352
|
+
}
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
if (!foundStep) {
|
|
1356
|
+
if (step.status === "running" || step.status === "queued") {
|
|
1357
|
+
step.status = "success";
|
|
1358
|
+
step.finishedAt = step.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1359
|
+
}
|
|
1360
|
+
} else {
|
|
1361
|
+
step.status = "queued";
|
|
1362
|
+
step.startedAt = void 0;
|
|
1363
|
+
step.finishedAt = void 0;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
} else {
|
|
1368
|
+
for (const job of restoredRun.jobs) {
|
|
1369
|
+
for (const step of job.steps) {
|
|
1370
|
+
step.status = "queued";
|
|
1371
|
+
step.startedAt = void 0;
|
|
1372
|
+
step.finishedAt = void 0;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
restoredRun.status = "running";
|
|
1377
|
+
restoredRun.startedAt = restoredRun.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1378
|
+
restoredRun.finishedAt = void 0;
|
|
1379
|
+
await this.stateStore.saveRun(restoredRun);
|
|
1380
|
+
await this.scheduler.scheduleRun(restoredRun);
|
|
1381
|
+
this.logger.info("Run replayed from snapshot", {
|
|
1382
|
+
runId,
|
|
1383
|
+
fromStepId: options.fromStepId
|
|
1384
|
+
});
|
|
1385
|
+
return restoredRun;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Delete a snapshot
|
|
1389
|
+
*/
|
|
1390
|
+
async deleteSnapshot(runId) {
|
|
1391
|
+
await this.snapshotStorage.deleteSnapshot(runId);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Get all active workflow executions (running or queued).
|
|
1395
|
+
* Returns array of WorkflowRun objects with status 'running' or 'queued'.
|
|
1396
|
+
*/
|
|
1397
|
+
async getActiveExecutions() {
|
|
1398
|
+
const runIds = await this.stateStore.getAllRunIds();
|
|
1399
|
+
const runs = await Promise.all(runIds.map((id) => this.stateStore.getRun(id)));
|
|
1400
|
+
return runs.filter(
|
|
1401
|
+
(run) => run !== null && (run.status === "running" || run.status === "queued")
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Get all workflow runs (all statuses).
|
|
1406
|
+
* Returns array of all WorkflowRun objects ordered by creation time.
|
|
1407
|
+
*/
|
|
1408
|
+
async getAllRuns() {
|
|
1409
|
+
const runIds = await this.stateStore.getAllRunIds();
|
|
1410
|
+
const runs = await Promise.all(runIds.map((id) => this.stateStore.getRun(id)));
|
|
1411
|
+
return runs.filter((run) => run !== null);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* List all workflow runs.
|
|
1415
|
+
* Returns array of all runs in the system.
|
|
1416
|
+
* Alias for getAllRuns() - maintained for backward compatibility.
|
|
1417
|
+
*/
|
|
1418
|
+
async listRuns() {
|
|
1419
|
+
return this.getAllRuns();
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Get workflow engine metrics.
|
|
1423
|
+
* Returns statistics about runs, jobs, and system health.
|
|
1424
|
+
*/
|
|
1425
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity -- Metrics aggregation: iterates all runs and jobs, counts by status (6 run statuses + 4 job statuses), handles nulls
|
|
1426
|
+
async getMetrics() {
|
|
1427
|
+
const runIds = await this.stateStore.getAllRunIds();
|
|
1428
|
+
const metrics = {
|
|
1429
|
+
runs: {
|
|
1430
|
+
total: 0,
|
|
1431
|
+
queued: 0,
|
|
1432
|
+
running: 0,
|
|
1433
|
+
completed: 0,
|
|
1434
|
+
failed: 0,
|
|
1435
|
+
cancelled: 0,
|
|
1436
|
+
dlq: 0
|
|
1437
|
+
},
|
|
1438
|
+
jobs: {
|
|
1439
|
+
total: 0,
|
|
1440
|
+
queued: 0,
|
|
1441
|
+
running: 0,
|
|
1442
|
+
completed: 0,
|
|
1443
|
+
failed: 0
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
const runs = await Promise.all(runIds.map((id) => this.stateStore.getRun(id)));
|
|
1447
|
+
for (const run of runs) {
|
|
1448
|
+
if (!run) {
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
metrics.runs.total++;
|
|
1452
|
+
if (run.status === "queued") {
|
|
1453
|
+
metrics.runs.queued++;
|
|
1454
|
+
} else if (run.status === "running") {
|
|
1455
|
+
metrics.runs.running++;
|
|
1456
|
+
} else if (run.status === "success") {
|
|
1457
|
+
metrics.runs.completed++;
|
|
1458
|
+
} else if (run.status === "failed") {
|
|
1459
|
+
metrics.runs.failed++;
|
|
1460
|
+
} else if (run.status === "cancelled") {
|
|
1461
|
+
metrics.runs.cancelled++;
|
|
1462
|
+
} else if (run.status === "dlq") {
|
|
1463
|
+
metrics.runs.dlq++;
|
|
1464
|
+
}
|
|
1465
|
+
for (const job of run.jobs) {
|
|
1466
|
+
metrics.jobs.total++;
|
|
1467
|
+
if (job.status === "queued") {
|
|
1468
|
+
metrics.jobs.queued++;
|
|
1469
|
+
} else if (job.status === "running") {
|
|
1470
|
+
metrics.jobs.running++;
|
|
1471
|
+
} else if (job.status === "success") {
|
|
1472
|
+
metrics.jobs.completed++;
|
|
1473
|
+
} else if (job.status === "failed") {
|
|
1474
|
+
metrics.jobs.failed++;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return metrics;
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
function computeDurationMs(startedAt, finishedAt) {
|
|
1482
|
+
if (!startedAt) {
|
|
1483
|
+
return void 0;
|
|
1484
|
+
}
|
|
1485
|
+
const start = Date.parse(startedAt);
|
|
1486
|
+
const end = Date.parse(finishedAt);
|
|
1487
|
+
if (Number.isNaN(start) || Number.isNaN(end)) {
|
|
1488
|
+
return void 0;
|
|
1489
|
+
}
|
|
1490
|
+
return Math.max(0, end - start);
|
|
1491
|
+
}
|
|
1492
|
+
var ArtifactMerger = class {
|
|
1493
|
+
constructor(options) {
|
|
1494
|
+
this.options = options;
|
|
1495
|
+
}
|
|
1496
|
+
async mergeArtifacts(config, targetArtifacts, currentRunId) {
|
|
1497
|
+
const { strategy, from } = config;
|
|
1498
|
+
this.options.logger.debug("Starting artifact merge", {
|
|
1499
|
+
strategy,
|
|
1500
|
+
sources: from.length,
|
|
1501
|
+
currentRunId
|
|
1502
|
+
});
|
|
1503
|
+
const results = await Promise.allSettled(
|
|
1504
|
+
from.map(
|
|
1505
|
+
(source) => this.loadArtifactsFromRun(source.runId, source.jobId).then(
|
|
1506
|
+
(artifacts) => ({ source, artifacts, success: true }),
|
|
1507
|
+
(error) => ({ source, error, success: false })
|
|
1508
|
+
)
|
|
1509
|
+
)
|
|
1510
|
+
);
|
|
1511
|
+
const sourceArtifacts = [];
|
|
1512
|
+
for (const result of results) {
|
|
1513
|
+
if (result.status === "fulfilled" && result.value.success) {
|
|
1514
|
+
sourceArtifacts.push(...result.value.artifacts);
|
|
1515
|
+
} else if (result.status === "fulfilled" && !result.value.success) {
|
|
1516
|
+
this.options.logger.warn("Failed to load artifacts from source", {
|
|
1517
|
+
runId: result.value.source.runId,
|
|
1518
|
+
jobId: result.value.source.jobId,
|
|
1519
|
+
error: result.value.error instanceof Error ? result.value.error.message : String(result.value.error)
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (sourceArtifacts.length === 0) {
|
|
1524
|
+
this.options.logger.warn("No artifacts found to merge", {
|
|
1525
|
+
sources: from.length
|
|
1526
|
+
});
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
await this.applyMergeStrategy(strategy, sourceArtifacts, targetArtifacts);
|
|
1530
|
+
this.options.logger.info("Artifact merge completed", {
|
|
1531
|
+
strategy,
|
|
1532
|
+
mergedCount: sourceArtifacts.length
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
async loadArtifactsFromRun(runId, jobId) {
|
|
1536
|
+
const run = await this.options.stateStore.getRun(runId);
|
|
1537
|
+
if (!run) {
|
|
1538
|
+
throw new Error(`Run ${runId} not found`);
|
|
1539
|
+
}
|
|
1540
|
+
const job = jobId ? run.jobs.find((j) => j.id === jobId) : run.jobs[0];
|
|
1541
|
+
if (!job) {
|
|
1542
|
+
throw new Error(`Job ${jobId ?? "first"} not found in run ${runId}`);
|
|
1543
|
+
}
|
|
1544
|
+
const artifactPaths = job.artifacts?.produce ?? [];
|
|
1545
|
+
if (artifactPaths.length === 0) {
|
|
1546
|
+
return [];
|
|
1547
|
+
}
|
|
1548
|
+
const sourceArtifacts = createFileSystemArtifactClient(join(
|
|
1549
|
+
this.options.artifactsRoot,
|
|
1550
|
+
runId,
|
|
1551
|
+
job.jobName
|
|
1552
|
+
));
|
|
1553
|
+
const results = await Promise.allSettled(
|
|
1554
|
+
artifactPaths.map((artifactPath) => {
|
|
1555
|
+
return this.loadArtifactContent(sourceArtifacts, artifactPath).then(
|
|
1556
|
+
(content) => ({ artifactPath, content, success: true }),
|
|
1557
|
+
(error) => ({ artifactPath, error, success: false })
|
|
1558
|
+
);
|
|
1559
|
+
})
|
|
1560
|
+
);
|
|
1561
|
+
const artifacts = [];
|
|
1562
|
+
for (const result of results) {
|
|
1563
|
+
if (result.status === "fulfilled" && result.value.success) {
|
|
1564
|
+
artifacts.push({ path: result.value.artifactPath, content: result.value.content });
|
|
1565
|
+
} else if (result.status === "fulfilled" && !result.value.success) {
|
|
1566
|
+
this.options.logger.warn("Failed to load artifact", {
|
|
1567
|
+
runId,
|
|
1568
|
+
jobId: job.id,
|
|
1569
|
+
artifactPath: result.value.artifactPath,
|
|
1570
|
+
error: result.value.error instanceof Error ? result.value.error.message : String(result.value.error)
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return artifacts;
|
|
1575
|
+
}
|
|
1576
|
+
async loadArtifactContent(artifacts, artifactPath) {
|
|
1577
|
+
try {
|
|
1578
|
+
const content = (await artifacts.consume(artifactPath)).toString("utf8");
|
|
1579
|
+
try {
|
|
1580
|
+
return JSON.parse(content);
|
|
1581
|
+
} catch {
|
|
1582
|
+
return content;
|
|
1583
|
+
}
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
throw new Error(
|
|
1586
|
+
`Failed to read artifact '${artifactPath}': ${error instanceof Error ? error.message : String(error)}`
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
async applyMergeStrategy(strategy, sourceArtifacts, targetArtifacts) {
|
|
1591
|
+
const artifactsByPath = /* @__PURE__ */ new Map();
|
|
1592
|
+
for (const artifact of sourceArtifacts) {
|
|
1593
|
+
if (!artifactsByPath.has(artifact.path)) {
|
|
1594
|
+
artifactsByPath.set(artifact.path, []);
|
|
1595
|
+
}
|
|
1596
|
+
artifactsByPath.get(artifact.path).push(artifact.content);
|
|
1597
|
+
}
|
|
1598
|
+
await Promise.all(
|
|
1599
|
+
Array.from(artifactsByPath.entries()).map(async ([path, contents]) => {
|
|
1600
|
+
let merged;
|
|
1601
|
+
switch (strategy) {
|
|
1602
|
+
case "append": {
|
|
1603
|
+
merged = this.mergeAppend(contents);
|
|
1604
|
+
break;
|
|
1605
|
+
}
|
|
1606
|
+
case "overwrite": {
|
|
1607
|
+
merged = contents[contents.length - 1];
|
|
1608
|
+
break;
|
|
1609
|
+
}
|
|
1610
|
+
case "json-merge": {
|
|
1611
|
+
merged = this.mergeJson(contents);
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
default: {
|
|
1615
|
+
this.options.logger.warn("Unknown merge strategy, using overwrite", {
|
|
1616
|
+
strategy,
|
|
1617
|
+
path
|
|
1618
|
+
});
|
|
1619
|
+
merged = contents[contents.length - 1];
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
await this.saveMergedArtifact(targetArtifacts, path, merged);
|
|
1623
|
+
})
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
mergeAppend(contents) {
|
|
1627
|
+
if (contents.every((c) => Array.isArray(c))) {
|
|
1628
|
+
return contents.flat();
|
|
1629
|
+
}
|
|
1630
|
+
if (contents.every((c) => typeof c === "string")) {
|
|
1631
|
+
return contents.join("\n");
|
|
1632
|
+
}
|
|
1633
|
+
return contents;
|
|
1634
|
+
}
|
|
1635
|
+
mergeJson(contents) {
|
|
1636
|
+
let merged = {};
|
|
1637
|
+
for (const content of contents) {
|
|
1638
|
+
if (typeof content === "object" && content !== null && !Array.isArray(content)) {
|
|
1639
|
+
merged = this.deepMerge(merged, content);
|
|
1640
|
+
} else {
|
|
1641
|
+
merged = content;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return merged;
|
|
1645
|
+
}
|
|
1646
|
+
deepMerge(target, source) {
|
|
1647
|
+
const result = { ...target };
|
|
1648
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1649
|
+
if (key in result && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1650
|
+
result[key] = this.deepMerge(
|
|
1651
|
+
result[key],
|
|
1652
|
+
value
|
|
1653
|
+
);
|
|
1654
|
+
} else {
|
|
1655
|
+
result[key] = value;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return result;
|
|
1659
|
+
}
|
|
1660
|
+
async saveMergedArtifact(artifacts, path, content) {
|
|
1661
|
+
const contentString = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
|
1662
|
+
await artifacts.produce(path, contentString);
|
|
1663
|
+
this.options.logger.debug("Saved merged artifact", { path });
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
// src/manifest-scanner.ts
|
|
1668
|
+
var ManifestScanner = class {
|
|
1669
|
+
cliApi;
|
|
1670
|
+
platform;
|
|
1671
|
+
cacheTtlMs;
|
|
1672
|
+
constructor(options) {
|
|
1673
|
+
this.cliApi = options.cliApi;
|
|
1674
|
+
this.platform = options.platform;
|
|
1675
|
+
this.cacheTtlMs = options.cacheTtlMs ?? 6e4;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Scan all installed plugins for workflows and jobs.
|
|
1679
|
+
*
|
|
1680
|
+
* Returns unified WorkflowRuntime representations.
|
|
1681
|
+
*/
|
|
1682
|
+
async scanPlugins() {
|
|
1683
|
+
const cacheKey = "manifest-scanner:workflows";
|
|
1684
|
+
if (this.platform.cache) {
|
|
1685
|
+
const cached = await this.platform.cache.get(cacheKey);
|
|
1686
|
+
if (cached) {
|
|
1687
|
+
this.platform.logger?.debug("ManifestScanner: Using cached workflows", { count: cached.length });
|
|
1688
|
+
return cached;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
this.platform.logger?.debug("ManifestScanner: Querying entity registry");
|
|
1692
|
+
const workflows = [];
|
|
1693
|
+
const workflowEntities = this.cliApi.queryEntities({ kind: "workflow" });
|
|
1694
|
+
const jobEntities = this.cliApi.queryEntities({ kind: "job" });
|
|
1695
|
+
const cronEntities = this.cliApi.queryEntities({ kind: "cron" });
|
|
1696
|
+
const pluginRoots = /* @__PURE__ */ new Map();
|
|
1697
|
+
for (const plugin of this.cliApi.listPlugins()) {
|
|
1698
|
+
pluginRoots.set(plugin.id, plugin.source.path);
|
|
1699
|
+
}
|
|
1700
|
+
const allEntities = [
|
|
1701
|
+
...workflowEntities.map((e) => ({ entity: e, converter: "workflow" })),
|
|
1702
|
+
...jobEntities.map((e) => ({ entity: e, converter: "job" })),
|
|
1703
|
+
...cronEntities.map((e) => ({ entity: e, converter: "cron" }))
|
|
1704
|
+
];
|
|
1705
|
+
for (const { entity, converter } of allEntities) {
|
|
1706
|
+
const root = pluginRoots.get(entity.ref.pluginId);
|
|
1707
|
+
if (!root) {
|
|
1708
|
+
this.platform.logger?.warn("ManifestScanner: Plugin root not found, skipping entity", {
|
|
1709
|
+
pluginId: entity.ref.pluginId,
|
|
1710
|
+
entityId: entity.ref.entityId,
|
|
1711
|
+
kind: entity.ref.kind
|
|
1712
|
+
});
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
try {
|
|
1716
|
+
if (converter === "workflow") {
|
|
1717
|
+
workflows.push(this.convertWorkflowHandler(entity.ref.pluginId, entity.declaration, root));
|
|
1718
|
+
} else if (converter === "job") {
|
|
1719
|
+
workflows.push(this.convertJobHandler(entity.ref.pluginId, entity.declaration, root));
|
|
1720
|
+
} else {
|
|
1721
|
+
workflows.push(this.convertCronSchedule(entity.ref.pluginId, entity.declaration, root));
|
|
1722
|
+
}
|
|
1723
|
+
} catch (err) {
|
|
1724
|
+
this.platform.logger?.warn("ManifestScanner: Failed to convert entity", {
|
|
1725
|
+
pluginId: entity.ref.pluginId,
|
|
1726
|
+
entityId: entity.ref.entityId,
|
|
1727
|
+
kind: entity.ref.kind,
|
|
1728
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
this.platform.logger?.info("ManifestScanner: Discovered workflows via registry", {
|
|
1733
|
+
count: workflows.length,
|
|
1734
|
+
workflows: workflowEntities.length,
|
|
1735
|
+
jobs: jobEntities.length,
|
|
1736
|
+
crons: cronEntities.length
|
|
1737
|
+
});
|
|
1738
|
+
if (this.platform.cache) {
|
|
1739
|
+
await this.platform.cache.set(cacheKey, workflows, this.cacheTtlMs);
|
|
1740
|
+
}
|
|
1741
|
+
return workflows;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Convert workflow handler declaration to WorkflowRuntime.
|
|
1745
|
+
*/
|
|
1746
|
+
convertWorkflowHandler(pluginId, handler, pluginRoot) {
|
|
1747
|
+
const id = `${pluginId}/${handler.id}`;
|
|
1748
|
+
return {
|
|
1749
|
+
id,
|
|
1750
|
+
source: "manifest",
|
|
1751
|
+
pluginId,
|
|
1752
|
+
manifestPath: pluginRoot,
|
|
1753
|
+
name: handler.describe ?? handler.id,
|
|
1754
|
+
description: handler.describe,
|
|
1755
|
+
tags: ["plugin", pluginId],
|
|
1756
|
+
triggers: [
|
|
1757
|
+
{ type: "manual" }
|
|
1758
|
+
// Workflow handlers can always be triggered manually
|
|
1759
|
+
],
|
|
1760
|
+
handler: handler.handler,
|
|
1761
|
+
status: "active",
|
|
1762
|
+
permissions: handler.permissions,
|
|
1763
|
+
input: handler.input,
|
|
1764
|
+
output: handler.output
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Convert job handler declaration to WorkflowRuntime.
|
|
1769
|
+
*/
|
|
1770
|
+
convertJobHandler(pluginId, handler, pluginRoot) {
|
|
1771
|
+
const id = `${pluginId}:job:${handler.id}`;
|
|
1772
|
+
return {
|
|
1773
|
+
id,
|
|
1774
|
+
source: "manifest",
|
|
1775
|
+
pluginId,
|
|
1776
|
+
manifestPath: pluginRoot,
|
|
1777
|
+
name: handler.describe ?? handler.id,
|
|
1778
|
+
description: handler.describe,
|
|
1779
|
+
tags: ["plugin", "job", pluginId],
|
|
1780
|
+
triggers: [
|
|
1781
|
+
{ type: "manual" }
|
|
1782
|
+
// Job handlers are invoked on-demand via ctx.api.jobs.submit()
|
|
1783
|
+
],
|
|
1784
|
+
handler: handler.handler,
|
|
1785
|
+
status: "active",
|
|
1786
|
+
permissions: handler.permissions,
|
|
1787
|
+
input: handler.input,
|
|
1788
|
+
output: handler.output
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Convert cron schedule declaration to WorkflowRuntime.
|
|
1793
|
+
*/
|
|
1794
|
+
convertCronSchedule(pluginId, cronDecl, pluginRoot) {
|
|
1795
|
+
const id = `${pluginId}:cron:${cronDecl.id}`;
|
|
1796
|
+
const schedule = {
|
|
1797
|
+
cron: cronDecl.schedule,
|
|
1798
|
+
enabled: cronDecl.enabled ?? true
|
|
1799
|
+
};
|
|
1800
|
+
return {
|
|
1801
|
+
id,
|
|
1802
|
+
source: "manifest",
|
|
1803
|
+
pluginId,
|
|
1804
|
+
manifestPath: pluginRoot,
|
|
1805
|
+
name: cronDecl.describe ?? cronDecl.id,
|
|
1806
|
+
description: cronDecl.describe,
|
|
1807
|
+
tags: ["plugin", "cron", pluginId],
|
|
1808
|
+
triggers: [
|
|
1809
|
+
{
|
|
1810
|
+
type: "schedule",
|
|
1811
|
+
config: { cron: cronDecl.schedule, timezone: cronDecl.timezone }
|
|
1812
|
+
}
|
|
1813
|
+
],
|
|
1814
|
+
// Note: Cron schedules reference a job type to execute
|
|
1815
|
+
// The actual handler path comes from the job declaration
|
|
1816
|
+
handler: void 0,
|
|
1817
|
+
// Will be resolved at execution time via job type
|
|
1818
|
+
schedule,
|
|
1819
|
+
status: cronDecl.enabled ?? true ? "active" : "disabled",
|
|
1820
|
+
permissions: cronDecl.permissions
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
// Legacy convertLegacyJob method removed - use cron schedules instead
|
|
1824
|
+
/**
|
|
1825
|
+
* Scan all installed plugins for job handlers only.
|
|
1826
|
+
*
|
|
1827
|
+
* Returns information needed to register handlers in JobManager.
|
|
1828
|
+
*/
|
|
1829
|
+
async scanJobHandlers() {
|
|
1830
|
+
const snapshot = this.cliApi.snapshot();
|
|
1831
|
+
const jobHandlers = [];
|
|
1832
|
+
for (const entry of snapshot.manifests ?? []) {
|
|
1833
|
+
const handlers = entry.manifest.jobs?.handlers ?? [];
|
|
1834
|
+
for (const handler of handlers) {
|
|
1835
|
+
jobHandlers.push({
|
|
1836
|
+
pluginId: entry.pluginId,
|
|
1837
|
+
pluginVersion: entry.manifest.version,
|
|
1838
|
+
pluginRoot: entry.pluginRoot,
|
|
1839
|
+
handler
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
this.platform.logger?.debug("ManifestScanner: Discovered job handlers", {
|
|
1844
|
+
count: jobHandlers.length
|
|
1845
|
+
});
|
|
1846
|
+
return jobHandlers;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Clear cache (useful for testing or force refresh).
|
|
1850
|
+
*/
|
|
1851
|
+
async clearCache() {
|
|
1852
|
+
if (this.platform.cache) {
|
|
1853
|
+
await this.platform.cache.delete("manifest-scanner:workflows");
|
|
1854
|
+
this.platform.logger?.debug("ManifestScanner: Cache cleared");
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Watch for plugin changes and invalidate cache.
|
|
1859
|
+
*
|
|
1860
|
+
* @param callback Optional callback when workflows change
|
|
1861
|
+
* @returns Unsubscribe function
|
|
1862
|
+
*/
|
|
1863
|
+
watchPlugins(callback) {
|
|
1864
|
+
return this.cliApi.onChange(async () => {
|
|
1865
|
+
await this.clearCache();
|
|
1866
|
+
if (callback) {
|
|
1867
|
+
const workflows = await this.scanPlugins();
|
|
1868
|
+
callback(workflows);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
var WorkflowRepository = class {
|
|
1874
|
+
platform;
|
|
1875
|
+
storageDir;
|
|
1876
|
+
workspaceRoot;
|
|
1877
|
+
absoluteStorageDir;
|
|
1878
|
+
constructor(options) {
|
|
1879
|
+
this.platform = options.platform;
|
|
1880
|
+
this.storageDir = options.storageDir ?? ".kb/workflows";
|
|
1881
|
+
this.workspaceRoot = options.workspaceRoot ?? process.cwd();
|
|
1882
|
+
this.absoluteStorageDir = resolve(this.workspaceRoot, this.storageDir);
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Create a new standalone workflow.
|
|
1886
|
+
*/
|
|
1887
|
+
async create(spec) {
|
|
1888
|
+
const validated = WorkflowSpecSchema.parse(spec);
|
|
1889
|
+
const id = `wf-${randomUUID().slice(0, 8)}`;
|
|
1890
|
+
const stored = {
|
|
1891
|
+
id,
|
|
1892
|
+
spec: validated,
|
|
1893
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1894
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1895
|
+
status: "active"
|
|
1896
|
+
};
|
|
1897
|
+
await this.saveWorkflow(id, stored);
|
|
1898
|
+
this.platform.logger?.info("WorkflowRepository: Created workflow", {
|
|
1899
|
+
id,
|
|
1900
|
+
name: spec.name
|
|
1901
|
+
});
|
|
1902
|
+
return this.toRuntime(stored);
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Get workflow by ID.
|
|
1906
|
+
*/
|
|
1907
|
+
async get(id) {
|
|
1908
|
+
const stored = await this.loadWorkflow(id);
|
|
1909
|
+
return stored ? this.toRuntime(stored) : null;
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* List all workflows with optional filtering.
|
|
1913
|
+
*/
|
|
1914
|
+
async list(options) {
|
|
1915
|
+
const allFiles = await this.listWorkflowFiles();
|
|
1916
|
+
const allWorkflows = await Promise.all(
|
|
1917
|
+
allFiles.map(async (filename) => {
|
|
1918
|
+
const id = filename.replace(/\.(yaml|yml)$/, "");
|
|
1919
|
+
const stored = await this.loadWorkflow(id);
|
|
1920
|
+
return stored ? this.toRuntime(stored) : null;
|
|
1921
|
+
})
|
|
1922
|
+
);
|
|
1923
|
+
const workflows = allWorkflows.filter((workflow) => {
|
|
1924
|
+
if (!workflow) {
|
|
1925
|
+
return false;
|
|
1926
|
+
}
|
|
1927
|
+
if (options?.status && workflow.status !== options.status) {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
return true;
|
|
1931
|
+
});
|
|
1932
|
+
if (options?.offset !== void 0 || options?.limit !== void 0) {
|
|
1933
|
+
const start = options.offset ?? 0;
|
|
1934
|
+
const end = options.limit ? start + options.limit : void 0;
|
|
1935
|
+
return workflows.slice(start, end);
|
|
1936
|
+
}
|
|
1937
|
+
return workflows;
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Update existing workflow.
|
|
1941
|
+
*/
|
|
1942
|
+
async update(id, spec) {
|
|
1943
|
+
const stored = await this.loadWorkflow(id);
|
|
1944
|
+
if (!stored) {
|
|
1945
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1946
|
+
}
|
|
1947
|
+
const updatedSpec = { ...stored.spec, ...spec };
|
|
1948
|
+
const validated = WorkflowSpecSchema.parse(updatedSpec);
|
|
1949
|
+
const updated = {
|
|
1950
|
+
...stored,
|
|
1951
|
+
spec: validated,
|
|
1952
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1953
|
+
};
|
|
1954
|
+
await this.saveWorkflow(id, updated);
|
|
1955
|
+
this.platform.logger?.info("WorkflowRepository: Updated workflow", {
|
|
1956
|
+
id,
|
|
1957
|
+
name: validated.name
|
|
1958
|
+
});
|
|
1959
|
+
return this.toRuntime(updated);
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Delete workflow.
|
|
1963
|
+
*/
|
|
1964
|
+
async delete(id) {
|
|
1965
|
+
const path = this.getWorkflowPath(id);
|
|
1966
|
+
try {
|
|
1967
|
+
await this.platform.storage.delete(path);
|
|
1968
|
+
this.platform.logger?.info("WorkflowRepository: Deleted workflow", { id });
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
this.platform.logger?.error(
|
|
1971
|
+
"WorkflowRepository: Delete failed",
|
|
1972
|
+
error instanceof Error ? error : void 0,
|
|
1973
|
+
{ id }
|
|
1974
|
+
);
|
|
1975
|
+
throw error;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Enable workflow (set status to active).
|
|
1980
|
+
*/
|
|
1981
|
+
async enable(id) {
|
|
1982
|
+
await this.updateStatus(id, "active");
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Disable workflow.
|
|
1986
|
+
*/
|
|
1987
|
+
async disable(id) {
|
|
1988
|
+
await this.updateStatus(id, "disabled");
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* Pause workflow.
|
|
1992
|
+
*/
|
|
1993
|
+
async pause(id) {
|
|
1994
|
+
await this.updateStatus(id, "paused");
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Resume workflow (unpause).
|
|
1998
|
+
*/
|
|
1999
|
+
async resume(id) {
|
|
2000
|
+
await this.updateStatus(id, "active");
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Update workflow statistics.
|
|
2004
|
+
*/
|
|
2005
|
+
async updateStats(id, stats) {
|
|
2006
|
+
const stored = await this.loadWorkflow(id);
|
|
2007
|
+
if (!stored) {
|
|
2008
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2009
|
+
}
|
|
2010
|
+
const updated = {
|
|
2011
|
+
...stored,
|
|
2012
|
+
stats: { ...stored.stats, ...stats },
|
|
2013
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2014
|
+
};
|
|
2015
|
+
await this.saveWorkflow(id, updated);
|
|
2016
|
+
}
|
|
2017
|
+
// ============================================================================
|
|
2018
|
+
// Private helpers
|
|
2019
|
+
// ============================================================================
|
|
2020
|
+
async updateStatus(id, status) {
|
|
2021
|
+
const stored = await this.loadWorkflow(id);
|
|
2022
|
+
if (!stored) {
|
|
2023
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2024
|
+
}
|
|
2025
|
+
const updated = {
|
|
2026
|
+
...stored,
|
|
2027
|
+
status,
|
|
2028
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2029
|
+
};
|
|
2030
|
+
await this.saveWorkflow(id, updated);
|
|
2031
|
+
this.platform.logger?.info("WorkflowRepository: Updated workflow status", {
|
|
2032
|
+
id,
|
|
2033
|
+
status
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
getWorkflowPath(id) {
|
|
2037
|
+
const ymlPath = join(this.absoluteStorageDir, `${id}.yml`);
|
|
2038
|
+
const yamlPath = join(this.absoluteStorageDir, `${id}.yaml`);
|
|
2039
|
+
if (existsSync(ymlPath)) {
|
|
2040
|
+
return ymlPath;
|
|
2041
|
+
}
|
|
2042
|
+
return yamlPath;
|
|
2043
|
+
}
|
|
2044
|
+
async saveWorkflow(id, workflow) {
|
|
2045
|
+
const path = this.getWorkflowPath(id);
|
|
2046
|
+
const yaml = stringify(workflow, { indent: 2 });
|
|
2047
|
+
try {
|
|
2048
|
+
if (!existsSync(this.absoluteStorageDir)) {
|
|
2049
|
+
await mkdir(this.absoluteStorageDir, { recursive: true });
|
|
2050
|
+
}
|
|
2051
|
+
await writeFile(path, yaml, "utf-8");
|
|
2052
|
+
} catch (error) {
|
|
2053
|
+
this.platform.logger?.error(
|
|
2054
|
+
"WorkflowRepository: Save failed",
|
|
2055
|
+
error instanceof Error ? error : void 0,
|
|
2056
|
+
{ path }
|
|
2057
|
+
);
|
|
2058
|
+
throw error;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
async loadWorkflow(id) {
|
|
2062
|
+
const path = this.getWorkflowPath(id);
|
|
2063
|
+
try {
|
|
2064
|
+
if (!existsSync(path)) {
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
const content = await readFile(path, "utf-8");
|
|
2068
|
+
const parsed = parse(content);
|
|
2069
|
+
if (parsed.id && parsed.spec && parsed.createdAt) {
|
|
2070
|
+
return parsed;
|
|
2071
|
+
}
|
|
2072
|
+
const spec = {
|
|
2073
|
+
name: parsed.name,
|
|
2074
|
+
version: parsed.version || "1.0.0",
|
|
2075
|
+
description: parsed.description,
|
|
2076
|
+
on: parsed.on || { manual: true },
|
|
2077
|
+
isolation: parsed.isolation,
|
|
2078
|
+
inputs: parsed.inputs,
|
|
2079
|
+
jobs: parsed.jobs,
|
|
2080
|
+
env: parsed.env,
|
|
2081
|
+
secrets: parsed.secrets
|
|
2082
|
+
};
|
|
2083
|
+
const stored = {
|
|
2084
|
+
id: parsed.id || id,
|
|
2085
|
+
spec,
|
|
2086
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2087
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2088
|
+
status: "active"
|
|
2089
|
+
};
|
|
2090
|
+
return stored;
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
this.platform.logger?.error(
|
|
2096
|
+
"WorkflowRepository: Load failed",
|
|
2097
|
+
error instanceof Error ? error : void 0,
|
|
2098
|
+
{ path }
|
|
2099
|
+
);
|
|
2100
|
+
return null;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
async listWorkflowFiles() {
|
|
2104
|
+
try {
|
|
2105
|
+
if (!existsSync(this.absoluteStorageDir)) {
|
|
2106
|
+
return [];
|
|
2107
|
+
}
|
|
2108
|
+
const files = await readdir(this.absoluteStorageDir);
|
|
2109
|
+
return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
2112
|
+
return [];
|
|
2113
|
+
}
|
|
2114
|
+
this.platform.logger?.error(
|
|
2115
|
+
"WorkflowRepository: List failed",
|
|
2116
|
+
error instanceof Error ? error : void 0
|
|
2117
|
+
);
|
|
2118
|
+
return [];
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Convert stored workflow to WorkflowRuntime format.
|
|
2123
|
+
*/
|
|
2124
|
+
toRuntime(stored) {
|
|
2125
|
+
const { id, spec, status, stats } = stored;
|
|
2126
|
+
const triggers = [];
|
|
2127
|
+
if (spec.on.manual) {
|
|
2128
|
+
triggers.push({ type: "manual" });
|
|
2129
|
+
}
|
|
2130
|
+
if (spec.on.push) {
|
|
2131
|
+
triggers.push({ type: "push" });
|
|
2132
|
+
}
|
|
2133
|
+
if (spec.on.webhook) {
|
|
2134
|
+
triggers.push({
|
|
2135
|
+
type: "webhook",
|
|
2136
|
+
config: typeof spec.on.webhook === "object" ? spec.on.webhook : void 0
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
let schedule;
|
|
2140
|
+
if (spec.on.schedule) {
|
|
2141
|
+
triggers.push({
|
|
2142
|
+
type: "schedule",
|
|
2143
|
+
config: spec.on.schedule
|
|
2144
|
+
});
|
|
2145
|
+
schedule = {
|
|
2146
|
+
cron: spec.on.schedule.cron,
|
|
2147
|
+
enabled: status === "active"
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
const tags = ["standalone"];
|
|
2151
|
+
if (spec.description) {
|
|
2152
|
+
const hashtagMatches = spec.description.match(/#\w+/g);
|
|
2153
|
+
if (hashtagMatches) {
|
|
2154
|
+
tags.push(...hashtagMatches.map((t) => t.slice(1)));
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return {
|
|
2158
|
+
id,
|
|
2159
|
+
source: "standalone",
|
|
2160
|
+
name: spec.name,
|
|
2161
|
+
description: spec.description,
|
|
2162
|
+
tags,
|
|
2163
|
+
triggers,
|
|
2164
|
+
schedule,
|
|
2165
|
+
status,
|
|
2166
|
+
stats,
|
|
2167
|
+
// Store full spec for execution
|
|
2168
|
+
input: spec,
|
|
2169
|
+
// Expose declared input schema for REST API / Studio UI
|
|
2170
|
+
inputSchema: spec.inputs
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
var WorkflowService = class {
|
|
2175
|
+
scanner;
|
|
2176
|
+
repository;
|
|
2177
|
+
platform;
|
|
2178
|
+
constructor(options) {
|
|
2179
|
+
this.platform = options.platform;
|
|
2180
|
+
this.scanner = new ManifestScanner({
|
|
2181
|
+
cliApi: options.cliApi,
|
|
2182
|
+
platform: options.platform,
|
|
2183
|
+
cacheTtlMs: options.manifestCacheTtlMs
|
|
2184
|
+
});
|
|
2185
|
+
this.repository = new WorkflowRepository({
|
|
2186
|
+
platform: options.platform,
|
|
2187
|
+
storageDir: options.workflowStorageDir,
|
|
2188
|
+
workspaceRoot: options.workspaceRoot
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* List all workflows (manifest + standalone).
|
|
2193
|
+
*/
|
|
2194
|
+
async listAll(options) {
|
|
2195
|
+
const workflows = [];
|
|
2196
|
+
if (!options?.source || options.source === "manifest") {
|
|
2197
|
+
const manifestWorkflows = await this.scanner.scanPlugins();
|
|
2198
|
+
workflows.push(...manifestWorkflows);
|
|
2199
|
+
}
|
|
2200
|
+
if (!options?.source || options.source === "standalone") {
|
|
2201
|
+
const standaloneWorkflows = await this.repository.list(options);
|
|
2202
|
+
workflows.push(...standaloneWorkflows);
|
|
2203
|
+
}
|
|
2204
|
+
let filtered = workflows;
|
|
2205
|
+
if (options?.status) {
|
|
2206
|
+
filtered = filtered.filter((w) => w.status === options.status);
|
|
2207
|
+
}
|
|
2208
|
+
if (options?.tags && options.tags.length > 0) {
|
|
2209
|
+
filtered = filtered.filter(
|
|
2210
|
+
(w) => options.tags.some((tag) => w.tags?.includes(tag))
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
2214
|
+
this.platform.logger?.debug("WorkflowService: Listed workflows", {
|
|
2215
|
+
total: filtered.length,
|
|
2216
|
+
manifest: filtered.filter((w) => w.source === "manifest").length,
|
|
2217
|
+
standalone: filtered.filter((w) => w.source === "standalone").length
|
|
2218
|
+
});
|
|
2219
|
+
return filtered;
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Get workflow by ID (from either source).
|
|
2223
|
+
*/
|
|
2224
|
+
async get(id) {
|
|
2225
|
+
const standalone = await this.repository.get(id);
|
|
2226
|
+
if (standalone) {
|
|
2227
|
+
return standalone;
|
|
2228
|
+
}
|
|
2229
|
+
const manifestWorkflows = await this.scanner.scanPlugins();
|
|
2230
|
+
const manifest = manifestWorkflows.find((w) => w.id === id);
|
|
2231
|
+
return manifest ?? null;
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Create standalone workflow.
|
|
2235
|
+
*/
|
|
2236
|
+
async create(spec) {
|
|
2237
|
+
this.platform.logger?.info("WorkflowService: Creating workflow", {
|
|
2238
|
+
name: spec.name
|
|
2239
|
+
});
|
|
2240
|
+
return this.repository.create(spec);
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Update standalone workflow.
|
|
2244
|
+
*/
|
|
2245
|
+
async update(id, spec) {
|
|
2246
|
+
const workflow = await this.get(id);
|
|
2247
|
+
if (!workflow) {
|
|
2248
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2249
|
+
}
|
|
2250
|
+
if (workflow.source !== "standalone") {
|
|
2251
|
+
throw new Error(`Cannot update manifest-based workflow: ${id}`);
|
|
2252
|
+
}
|
|
2253
|
+
this.platform.logger?.info("WorkflowService: Updating workflow", { id });
|
|
2254
|
+
return this.repository.update(id, spec);
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Delete standalone workflow.
|
|
2258
|
+
*/
|
|
2259
|
+
async delete(id) {
|
|
2260
|
+
const workflow = await this.get(id);
|
|
2261
|
+
if (!workflow) {
|
|
2262
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2263
|
+
}
|
|
2264
|
+
if (workflow.source !== "standalone") {
|
|
2265
|
+
throw new Error(`Cannot delete manifest-based workflow: ${id}`);
|
|
2266
|
+
}
|
|
2267
|
+
this.platform.logger?.info("WorkflowService: Deleting workflow", { id });
|
|
2268
|
+
await this.repository.delete(id);
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Enable workflow (set status to active).
|
|
2272
|
+
*/
|
|
2273
|
+
async enable(id) {
|
|
2274
|
+
const workflow = await this.get(id);
|
|
2275
|
+
if (!workflow) {
|
|
2276
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2277
|
+
}
|
|
2278
|
+
if (workflow.source !== "standalone") {
|
|
2279
|
+
throw new Error(`Cannot enable manifest-based workflow: ${id}`);
|
|
2280
|
+
}
|
|
2281
|
+
await this.repository.enable(id);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Disable workflow.
|
|
2285
|
+
*/
|
|
2286
|
+
async disable(id) {
|
|
2287
|
+
const workflow = await this.get(id);
|
|
2288
|
+
if (!workflow) {
|
|
2289
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2290
|
+
}
|
|
2291
|
+
if (workflow.source !== "standalone") {
|
|
2292
|
+
throw new Error(`Cannot disable manifest-based workflow: ${id}`);
|
|
2293
|
+
}
|
|
2294
|
+
await this.repository.disable(id);
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Pause workflow.
|
|
2298
|
+
*/
|
|
2299
|
+
async pause(id) {
|
|
2300
|
+
const workflow = await this.get(id);
|
|
2301
|
+
if (!workflow) {
|
|
2302
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2303
|
+
}
|
|
2304
|
+
if (workflow.source !== "standalone") {
|
|
2305
|
+
throw new Error(`Cannot pause manifest-based workflow: ${id}`);
|
|
2306
|
+
}
|
|
2307
|
+
await this.repository.pause(id);
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Resume workflow (unpause).
|
|
2311
|
+
*/
|
|
2312
|
+
async resume(id) {
|
|
2313
|
+
const workflow = await this.get(id);
|
|
2314
|
+
if (!workflow) {
|
|
2315
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
2316
|
+
}
|
|
2317
|
+
if (workflow.source !== "standalone") {
|
|
2318
|
+
throw new Error(`Cannot resume manifest-based workflow: ${id}`);
|
|
2319
|
+
}
|
|
2320
|
+
await this.repository.resume(id);
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Get available workflow handlers (for UI autocomplete).
|
|
2324
|
+
*
|
|
2325
|
+
* Returns manifest-based handlers that can be used in standalone workflows
|
|
2326
|
+
* (via `uses: "plugin:id/handler"`).
|
|
2327
|
+
*/
|
|
2328
|
+
async getAvailableHandlers() {
|
|
2329
|
+
const manifestWorkflows = await this.scanner.scanPlugins();
|
|
2330
|
+
const handlers = manifestWorkflows.filter((w) => w.source === "manifest" && w.handler).map((w) => ({
|
|
2331
|
+
id: w.id,
|
|
2332
|
+
pluginId: w.pluginId,
|
|
2333
|
+
name: w.name,
|
|
2334
|
+
description: w.description,
|
|
2335
|
+
inputSchema: w.input,
|
|
2336
|
+
outputSchema: w.output
|
|
2337
|
+
}));
|
|
2338
|
+
this.platform.logger?.debug("WorkflowService: Listed handlers", {
|
|
2339
|
+
count: handlers.length
|
|
2340
|
+
});
|
|
2341
|
+
return handlers;
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Validate workflow spec.
|
|
2345
|
+
*/
|
|
2346
|
+
validate(spec) {
|
|
2347
|
+
try {
|
|
2348
|
+
WorkflowSpecSchema.parse(spec);
|
|
2349
|
+
return { valid: true };
|
|
2350
|
+
} catch (error) {
|
|
2351
|
+
if (error && typeof error === "object" && "errors" in error) {
|
|
2352
|
+
const zodError = error;
|
|
2353
|
+
return {
|
|
2354
|
+
valid: false,
|
|
2355
|
+
errors: zodError.errors.map((e) => ({
|
|
2356
|
+
path: e.path.join("."),
|
|
2357
|
+
message: e.message
|
|
2358
|
+
}))
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
return {
|
|
2362
|
+
valid: false,
|
|
2363
|
+
errors: [{ path: "", message: "Validation failed" }]
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Refresh manifest scanner cache (force re-scan).
|
|
2369
|
+
*/
|
|
2370
|
+
async refreshManifests() {
|
|
2371
|
+
await this.scanner.clearCache();
|
|
2372
|
+
this.platform.logger?.info("WorkflowService: Manifest cache cleared");
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
|
|
2376
|
+
// src/workflow-schedule-manager.ts
|
|
2377
|
+
var WorkflowScheduleManager = class {
|
|
2378
|
+
cronManager;
|
|
2379
|
+
workflowService;
|
|
2380
|
+
executor;
|
|
2381
|
+
platform;
|
|
2382
|
+
constructor(options) {
|
|
2383
|
+
this.cronManager = options.cronManager;
|
|
2384
|
+
this.workflowService = options.workflowService;
|
|
2385
|
+
this.executor = options.executor;
|
|
2386
|
+
this.platform = options.platform;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Register all scheduled workflows with CronManager.
|
|
2390
|
+
*
|
|
2391
|
+
* Scans both manifest-based jobs and standalone workflows with schedules.
|
|
2392
|
+
*/
|
|
2393
|
+
async registerAll() {
|
|
2394
|
+
const workflows = await this.workflowService.listAll();
|
|
2395
|
+
const scheduled = workflows.filter(
|
|
2396
|
+
(w) => w.schedule && w.schedule.enabled && w.status === "active"
|
|
2397
|
+
);
|
|
2398
|
+
this.platform.logger?.info("WorkflowScheduleManager: Registering scheduled workflows", {
|
|
2399
|
+
total: workflows.length,
|
|
2400
|
+
scheduled: scheduled.length
|
|
2401
|
+
});
|
|
2402
|
+
await Promise.all(scheduled.map((workflow) => this.register(workflow)));
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Register single workflow schedule.
|
|
2406
|
+
*/
|
|
2407
|
+
async register(workflow) {
|
|
2408
|
+
if (!workflow.schedule || !workflow.schedule.enabled) {
|
|
2409
|
+
this.platform.logger?.warn("WorkflowScheduleManager: Cannot register workflow without schedule", {
|
|
2410
|
+
id: workflow.id
|
|
2411
|
+
});
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
const cronId = this.getCronId(workflow.id);
|
|
2415
|
+
const schedule = workflow.schedule.cron;
|
|
2416
|
+
this.cronManager.register(cronId, schedule, async (context) => {
|
|
2417
|
+
this.platform.logger?.info("WorkflowScheduleManager: Executing scheduled workflow", {
|
|
2418
|
+
workflowId: workflow.id,
|
|
2419
|
+
workflowName: workflow.name,
|
|
2420
|
+
runCount: context.runCount
|
|
2421
|
+
});
|
|
2422
|
+
try {
|
|
2423
|
+
const result = await this.executor.execute({
|
|
2424
|
+
workflowId: workflow.id,
|
|
2425
|
+
trigger: "schedule",
|
|
2426
|
+
input: {}
|
|
2427
|
+
});
|
|
2428
|
+
this.platform.logger?.info("WorkflowScheduleManager: Workflow execution started", {
|
|
2429
|
+
workflowId: workflow.id,
|
|
2430
|
+
runId: result.runId
|
|
2431
|
+
});
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
this.platform.logger?.error(
|
|
2434
|
+
"WorkflowScheduleManager: Workflow execution failed",
|
|
2435
|
+
error instanceof Error ? error : void 0,
|
|
2436
|
+
{
|
|
2437
|
+
workflowId: workflow.id,
|
|
2438
|
+
workflowName: workflow.name
|
|
2439
|
+
}
|
|
2440
|
+
);
|
|
2441
|
+
}
|
|
2442
|
+
});
|
|
2443
|
+
this.platform.logger?.debug("WorkflowScheduleManager: Registered workflow", {
|
|
2444
|
+
workflowId: workflow.id,
|
|
2445
|
+
schedule
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Unregister workflow schedule.
|
|
2450
|
+
*/
|
|
2451
|
+
async unregister(workflowId) {
|
|
2452
|
+
const cronId = this.getCronId(workflowId);
|
|
2453
|
+
this.cronManager.unregister(cronId);
|
|
2454
|
+
this.platform.logger?.debug("WorkflowScheduleManager: Unregistered workflow", {
|
|
2455
|
+
workflowId
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Re-register all schedules (refresh).
|
|
2460
|
+
*
|
|
2461
|
+
* Useful after workflow changes or service restart.
|
|
2462
|
+
*/
|
|
2463
|
+
async refresh() {
|
|
2464
|
+
const allJobs = this.cronManager.list();
|
|
2465
|
+
for (const job of allJobs) {
|
|
2466
|
+
if (job.id.startsWith("workflow:")) {
|
|
2467
|
+
this.cronManager.unregister(job.id);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
await this.registerAll();
|
|
2471
|
+
this.platform.logger?.info("WorkflowScheduleManager: Refreshed all schedules");
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Get next run time for scheduled workflow.
|
|
2475
|
+
*/
|
|
2476
|
+
getNextRun(workflowId) {
|
|
2477
|
+
const cronId = this.getCronId(workflowId);
|
|
2478
|
+
const job = this.cronManager.list().find((j) => j.id === cronId);
|
|
2479
|
+
return job?.nextRun ?? null;
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* Get last run time for scheduled workflow.
|
|
2483
|
+
*/
|
|
2484
|
+
getLastRun(workflowId) {
|
|
2485
|
+
const cronId = this.getCronId(workflowId);
|
|
2486
|
+
const job = this.cronManager.list().find((j) => j.id === cronId);
|
|
2487
|
+
return job?.lastRun ?? null;
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Pause scheduled workflow.
|
|
2491
|
+
*/
|
|
2492
|
+
pause(workflowId) {
|
|
2493
|
+
const cronId = this.getCronId(workflowId);
|
|
2494
|
+
this.cronManager.pause(cronId);
|
|
2495
|
+
this.platform.logger?.info("WorkflowScheduleManager: Paused workflow schedule", {
|
|
2496
|
+
workflowId
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Resume paused workflow schedule.
|
|
2501
|
+
*/
|
|
2502
|
+
resume(workflowId) {
|
|
2503
|
+
const cronId = this.getCronId(workflowId);
|
|
2504
|
+
this.cronManager.resume(cronId);
|
|
2505
|
+
this.platform.logger?.info("WorkflowScheduleManager: Resumed workflow schedule", {
|
|
2506
|
+
workflowId
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* List all scheduled workflows.
|
|
2511
|
+
*/
|
|
2512
|
+
listScheduled() {
|
|
2513
|
+
return this.cronManager.list().filter((job) => job.id.startsWith("workflow:")).map((job) => ({
|
|
2514
|
+
workflowId: this.getWorkflowId(job.id),
|
|
2515
|
+
schedule: job.schedule,
|
|
2516
|
+
status: job.status,
|
|
2517
|
+
lastRun: job.lastRun,
|
|
2518
|
+
nextRun: job.nextRun,
|
|
2519
|
+
runCount: job.runCount
|
|
2520
|
+
}));
|
|
2521
|
+
}
|
|
2522
|
+
// ============================================================================
|
|
2523
|
+
// Private helpers
|
|
2524
|
+
// ============================================================================
|
|
2525
|
+
getCronId(workflowId) {
|
|
2526
|
+
return `workflow:${workflowId}`;
|
|
2527
|
+
}
|
|
2528
|
+
getWorkflowId(cronId) {
|
|
2529
|
+
return cronId.replace("workflow:", "");
|
|
2530
|
+
}
|
|
2531
|
+
};
|
|
2532
|
+
var WorkflowRegistry = class {
|
|
2533
|
+
constructor(options) {
|
|
2534
|
+
this.options = options;
|
|
2535
|
+
this.loader = new WorkflowLoader(options.logger);
|
|
2536
|
+
}
|
|
2537
|
+
entries = /* @__PURE__ */ new Map();
|
|
2538
|
+
loader;
|
|
2539
|
+
/**
|
|
2540
|
+
* Scan configured directories and index all workflow files
|
|
2541
|
+
*/
|
|
2542
|
+
async scan() {
|
|
2543
|
+
this.entries.clear();
|
|
2544
|
+
const cwd = this.options.cwd ?? process.cwd();
|
|
2545
|
+
this.options.logger.info("Starting workflow discovery", {
|
|
2546
|
+
scanDirs: this.options.scanDirs,
|
|
2547
|
+
cwd
|
|
2548
|
+
});
|
|
2549
|
+
for (const dir of this.options.scanDirs) {
|
|
2550
|
+
const absoluteDir = resolve(cwd, dir);
|
|
2551
|
+
if (!existsSync(absoluteDir)) {
|
|
2552
|
+
this.options.logger.debug(`Directory not found, skipping`, { dir: absoluteDir });
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
try {
|
|
2556
|
+
const files = await readdir(absoluteDir);
|
|
2557
|
+
const yamlFiles = files.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
2558
|
+
this.options.logger.debug(`Found workflow files`, {
|
|
2559
|
+
dir: absoluteDir,
|
|
2560
|
+
count: yamlFiles.length
|
|
2561
|
+
});
|
|
2562
|
+
await Promise.all(
|
|
2563
|
+
// eslint-disable-line no-await-in-loop
|
|
2564
|
+
yamlFiles.map((file) => {
|
|
2565
|
+
const filePath = join(absoluteDir, file);
|
|
2566
|
+
return this.indexFile(filePath);
|
|
2567
|
+
})
|
|
2568
|
+
);
|
|
2569
|
+
} catch (error) {
|
|
2570
|
+
this.options.logger.warn(`Failed to scan directory`, {
|
|
2571
|
+
dir: absoluteDir,
|
|
2572
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
this.options.logger.info("Workflow discovery complete", {
|
|
2577
|
+
totalWorkflows: this.entries.size,
|
|
2578
|
+
ids: Array.from(this.entries.keys())
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Index a single workflow file
|
|
2583
|
+
*/
|
|
2584
|
+
async indexFile(filePath) {
|
|
2585
|
+
try {
|
|
2586
|
+
const raw = await readFile(filePath, "utf8");
|
|
2587
|
+
const parsed = this.parseWorkflowFile(raw);
|
|
2588
|
+
const id = parsed.id || basename(filePath, ".yml").replace(".yaml", "");
|
|
2589
|
+
const spec = {
|
|
2590
|
+
name: parsed.name,
|
|
2591
|
+
version: "1.0.0",
|
|
2592
|
+
description: parsed.description,
|
|
2593
|
+
on: parsed.on || { manual: true },
|
|
2594
|
+
// Default to manual trigger if not specified
|
|
2595
|
+
isolation: parsed.isolation,
|
|
2596
|
+
jobs: parsed.jobs,
|
|
2597
|
+
env: parsed.env,
|
|
2598
|
+
secrets: parsed.secrets
|
|
2599
|
+
};
|
|
2600
|
+
const entry = {
|
|
2601
|
+
id,
|
|
2602
|
+
name: parsed.name,
|
|
2603
|
+
description: parsed.description,
|
|
2604
|
+
filePath,
|
|
2605
|
+
spec,
|
|
2606
|
+
metadata: parsed.metadata
|
|
2607
|
+
};
|
|
2608
|
+
this.entries.set(id, entry);
|
|
2609
|
+
this.options.logger.debug(`Indexed workflow`, {
|
|
2610
|
+
id,
|
|
2611
|
+
name: parsed.name,
|
|
2612
|
+
filePath
|
|
2613
|
+
});
|
|
2614
|
+
} catch (error) {
|
|
2615
|
+
this.options.logger.warn(`Failed to index workflow file`, {
|
|
2616
|
+
filePath,
|
|
2617
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Parse workflow file (YAML format with additional fields)
|
|
2623
|
+
*/
|
|
2624
|
+
parseWorkflowFile(raw) {
|
|
2625
|
+
const trimmed = raw.trim();
|
|
2626
|
+
if (!trimmed) {
|
|
2627
|
+
throw new Error("Workflow file is empty");
|
|
2628
|
+
}
|
|
2629
|
+
const { parse } = __require("yaml");
|
|
2630
|
+
return parse(trimmed);
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Get workflow by ID
|
|
2634
|
+
*/
|
|
2635
|
+
get(id) {
|
|
2636
|
+
return this.entries.get(id);
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* List all registered workflows
|
|
2640
|
+
*/
|
|
2641
|
+
list() {
|
|
2642
|
+
return Array.from(this.entries.values());
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* Check if workflow exists
|
|
2646
|
+
*/
|
|
2647
|
+
has(id) {
|
|
2648
|
+
return this.entries.has(id);
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Get workflow spec by ID
|
|
2652
|
+
*/
|
|
2653
|
+
getSpec(id) {
|
|
2654
|
+
const entry = this.entries.get(id);
|
|
2655
|
+
return entry?.spec;
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Clear all indexed workflows
|
|
2659
|
+
*/
|
|
2660
|
+
clear() {
|
|
2661
|
+
this.entries.clear();
|
|
2662
|
+
}
|
|
2663
|
+
};
|
|
2664
|
+
var JobManager = class {
|
|
2665
|
+
constructor(cache, events, logger, config = {}) {
|
|
2666
|
+
this.cache = cache;
|
|
2667
|
+
this.events = events;
|
|
2668
|
+
this.logger = logger;
|
|
2669
|
+
this.config = config;
|
|
2670
|
+
this.defaultTimeout = config.defaultTimeout ?? 3e5;
|
|
2671
|
+
this.defaultMaxRetries = config.defaultMaxRetries ?? 3;
|
|
2672
|
+
this.defaultPriority = config.defaultPriority ?? 50;
|
|
2673
|
+
this.workspaceRoot = config.workspaceRoot ?? process.cwd();
|
|
2674
|
+
}
|
|
2675
|
+
handlerRegistry = /* @__PURE__ */ new Map();
|
|
2676
|
+
defaultTimeout;
|
|
2677
|
+
defaultMaxRetries;
|
|
2678
|
+
defaultPriority;
|
|
2679
|
+
workspaceRoot;
|
|
2680
|
+
/**
|
|
2681
|
+
* Register job handler from plugin manifest.
|
|
2682
|
+
*
|
|
2683
|
+
* Called by plugin-runtime during plugin initialization.
|
|
2684
|
+
*
|
|
2685
|
+
* @param pluginId - Plugin identifier
|
|
2686
|
+
* @param pluginVersion - Plugin version
|
|
2687
|
+
* @param pluginRoot - Plugin root directory
|
|
2688
|
+
* @param handlerDecl - Job handler declaration from manifest
|
|
2689
|
+
*/
|
|
2690
|
+
registerJobHandler(pluginId, pluginVersion, pluginRoot, handlerDecl) {
|
|
2691
|
+
const jobType = `${pluginId}:${handlerDecl.id}`;
|
|
2692
|
+
if (this.handlerRegistry.has(jobType)) {
|
|
2693
|
+
this.logger.warn("Job handler already registered, overwriting", {
|
|
2694
|
+
jobType,
|
|
2695
|
+
pluginId,
|
|
2696
|
+
jobId: handlerDecl.id
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
this.handlerRegistry.set(jobType, {
|
|
2700
|
+
pluginId,
|
|
2701
|
+
jobId: handlerDecl.id,
|
|
2702
|
+
handlerPath: handlerDecl.handler,
|
|
2703
|
+
config: handlerDecl,
|
|
2704
|
+
pluginRoot,
|
|
2705
|
+
pluginVersion
|
|
2706
|
+
});
|
|
2707
|
+
this.logger.info("Registered job handler", {
|
|
2708
|
+
jobType,
|
|
2709
|
+
handler: handlerDecl.handler
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Submit a job for immediate execution.
|
|
2714
|
+
*/
|
|
2715
|
+
async submit(job) {
|
|
2716
|
+
const handlerEntry = this.handlerRegistry.get(job.type);
|
|
2717
|
+
if (!handlerEntry) {
|
|
2718
|
+
throw new Error(`Job handler not found: ${job.type}`);
|
|
2719
|
+
}
|
|
2720
|
+
if (job.idempotencyKey) {
|
|
2721
|
+
const existing = await this.findByIdempotencyKey(job.idempotencyKey);
|
|
2722
|
+
if (existing) {
|
|
2723
|
+
this.logger.info("Job already submitted (idempotent)", {
|
|
2724
|
+
idempotencyKey: job.idempotencyKey,
|
|
2725
|
+
existingJobId: existing.id
|
|
2726
|
+
});
|
|
2727
|
+
return this.jobRecordToHandle(existing);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
const jobId = nanoid();
|
|
2731
|
+
const now = /* @__PURE__ */ new Date();
|
|
2732
|
+
const record = {
|
|
2733
|
+
id: jobId,
|
|
2734
|
+
type: job.type,
|
|
2735
|
+
payload: job.payload,
|
|
2736
|
+
tenantId: job.tenantId ?? "default",
|
|
2737
|
+
status: "pending",
|
|
2738
|
+
priority: job.priority ?? this.defaultPriority,
|
|
2739
|
+
maxRetries: job.maxRetries ?? handlerEntry.config.maxRetries ?? this.defaultMaxRetries,
|
|
2740
|
+
timeout: job.timeout ?? handlerEntry.config.timeout ?? this.defaultTimeout,
|
|
2741
|
+
attempt: 0,
|
|
2742
|
+
progress: 0,
|
|
2743
|
+
createdAt: now.toISOString(),
|
|
2744
|
+
runAt: job.runAt?.toISOString(),
|
|
2745
|
+
idempotencyKey: job.idempotencyKey
|
|
2746
|
+
};
|
|
2747
|
+
await this.saveJobRecord(record);
|
|
2748
|
+
const availableAt = job.runAt ? job.runAt.getTime() : Date.now();
|
|
2749
|
+
await this.enqueueJob(jobId, record.priority, availableAt);
|
|
2750
|
+
this.logger.info("Job submitted", {
|
|
2751
|
+
jobId,
|
|
2752
|
+
type: job.type,
|
|
2753
|
+
tenantId: record.tenantId,
|
|
2754
|
+
priority: record.priority
|
|
2755
|
+
});
|
|
2756
|
+
await this.events.publish("job.submitted", {
|
|
2757
|
+
jobId,
|
|
2758
|
+
type: job.type,
|
|
2759
|
+
tenantId: record.tenantId
|
|
2760
|
+
});
|
|
2761
|
+
return this.jobRecordToHandle(record);
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Schedule a job for future/recurring execution.
|
|
2765
|
+
*/
|
|
2766
|
+
async schedule(job, schedule) {
|
|
2767
|
+
if (schedule instanceof Date) {
|
|
2768
|
+
return this.submit({ ...job, runAt: schedule });
|
|
2769
|
+
}
|
|
2770
|
+
throw new Error("Cron scheduling not yet implemented");
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Cancel a pending/running job.
|
|
2774
|
+
*/
|
|
2775
|
+
async cancel(jobId) {
|
|
2776
|
+
const record = await this.getJobRecord(jobId);
|
|
2777
|
+
if (!record) {
|
|
2778
|
+
return false;
|
|
2779
|
+
}
|
|
2780
|
+
if (record.status === "completed" || record.status === "failed" || record.status === "cancelled") {
|
|
2781
|
+
this.logger.warn("Cannot cancel job in terminal state", {
|
|
2782
|
+
jobId,
|
|
2783
|
+
status: record.status
|
|
2784
|
+
});
|
|
2785
|
+
return false;
|
|
2786
|
+
}
|
|
2787
|
+
record.status = "cancelled";
|
|
2788
|
+
record.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2789
|
+
await this.saveJobRecord(record);
|
|
2790
|
+
await this.removeFromQueue(jobId);
|
|
2791
|
+
this.logger.info("Job cancelled", { jobId });
|
|
2792
|
+
await this.events.publish("job.cancelled", {
|
|
2793
|
+
jobId,
|
|
2794
|
+
type: record.type,
|
|
2795
|
+
tenantId: record.tenantId
|
|
2796
|
+
});
|
|
2797
|
+
return true;
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* Get job status.
|
|
2801
|
+
*/
|
|
2802
|
+
async getStatus(jobId) {
|
|
2803
|
+
const record = await this.getJobRecord(jobId);
|
|
2804
|
+
if (!record) {
|
|
2805
|
+
return null;
|
|
2806
|
+
}
|
|
2807
|
+
return this.jobRecordToHandle(record);
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* List jobs.
|
|
2811
|
+
*/
|
|
2812
|
+
async list(filter = {}) {
|
|
2813
|
+
const pattern = "kb:job:*";
|
|
2814
|
+
const keys = await this.getAllJobKeys(pattern);
|
|
2815
|
+
const allRecords = await Promise.all(keys.map((key) => this.cache.get(key)));
|
|
2816
|
+
const records = allRecords.filter((data) => {
|
|
2817
|
+
if (!data) {
|
|
2818
|
+
return false;
|
|
2819
|
+
}
|
|
2820
|
+
if (filter.type && data.type !== filter.type) {
|
|
2821
|
+
return false;
|
|
2822
|
+
}
|
|
2823
|
+
if (filter.tenantId && data.tenantId !== filter.tenantId) {
|
|
2824
|
+
return false;
|
|
2825
|
+
}
|
|
2826
|
+
if (filter.status && data.status !== filter.status) {
|
|
2827
|
+
return false;
|
|
2828
|
+
}
|
|
2829
|
+
return true;
|
|
2830
|
+
});
|
|
2831
|
+
records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
2832
|
+
const offset = filter.offset ?? 0;
|
|
2833
|
+
const limit = filter.limit ?? 100;
|
|
2834
|
+
const page = records.slice(offset, offset + limit);
|
|
2835
|
+
return page.map((r) => this.jobRecordToHandle(r));
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Execute a job (called by worker).
|
|
2839
|
+
*
|
|
2840
|
+
* @internal
|
|
2841
|
+
*/
|
|
2842
|
+
async executeJob(jobId) {
|
|
2843
|
+
const record = await this.getJobRecord(jobId);
|
|
2844
|
+
if (!record) {
|
|
2845
|
+
this.logger.error("Job not found for execution", void 0, { jobId });
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const handlerEntry = this.handlerRegistry.get(record.type);
|
|
2849
|
+
if (!handlerEntry) {
|
|
2850
|
+
this.logger.error("Job handler not found", void 0, { jobId, type: record.type });
|
|
2851
|
+
await this.markJobFailed(record, "Handler not found");
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
if (!this.config.executionBackend) {
|
|
2855
|
+
this.logger.error("ExecutionBackend not configured", void 0, { jobId });
|
|
2856
|
+
await this.markJobFailed(record, "ExecutionBackend not configured");
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
record.status = "running";
|
|
2860
|
+
record.attempt += 1;
|
|
2861
|
+
record.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2862
|
+
await this.saveJobRecord(record);
|
|
2863
|
+
await this.events.publish("job.started", {
|
|
2864
|
+
jobId,
|
|
2865
|
+
type: record.type,
|
|
2866
|
+
tenantId: record.tenantId,
|
|
2867
|
+
attempt: record.attempt
|
|
2868
|
+
});
|
|
2869
|
+
try {
|
|
2870
|
+
const executionId = `job-${jobId}-${record.attempt}`;
|
|
2871
|
+
const traceId = executionId;
|
|
2872
|
+
const spanId = executionId;
|
|
2873
|
+
const invocationId = executionId;
|
|
2874
|
+
const descriptor = {
|
|
2875
|
+
hostType: "cron",
|
|
2876
|
+
hostContext: {
|
|
2877
|
+
host: "cron",
|
|
2878
|
+
cronId: record.type,
|
|
2879
|
+
schedule: "",
|
|
2880
|
+
// Not available in job context
|
|
2881
|
+
scheduledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2882
|
+
},
|
|
2883
|
+
permissions: handlerEntry.config.permissions ?? {},
|
|
2884
|
+
pluginId: handlerEntry.pluginId,
|
|
2885
|
+
pluginVersion: "0.0.0",
|
|
2886
|
+
// TODO: Get from manifest
|
|
2887
|
+
requestId: executionId,
|
|
2888
|
+
tenantId: record.tenantId
|
|
2889
|
+
};
|
|
2890
|
+
Object.assign(descriptor, {
|
|
2891
|
+
traceId,
|
|
2892
|
+
spanId,
|
|
2893
|
+
invocationId,
|
|
2894
|
+
executionId
|
|
2895
|
+
});
|
|
2896
|
+
const request = {
|
|
2897
|
+
executionId,
|
|
2898
|
+
descriptor,
|
|
2899
|
+
pluginRoot: handlerEntry.pluginRoot,
|
|
2900
|
+
handlerRef: handlerEntry.handlerPath,
|
|
2901
|
+
input: {
|
|
2902
|
+
jobId,
|
|
2903
|
+
type: record.type,
|
|
2904
|
+
input: record.payload,
|
|
2905
|
+
tenantId: record.tenantId,
|
|
2906
|
+
attempt: record.attempt
|
|
2907
|
+
},
|
|
2908
|
+
timeoutMs: record.timeout
|
|
2909
|
+
};
|
|
2910
|
+
this.logger.debug("Executing job handler", {
|
|
2911
|
+
jobId,
|
|
2912
|
+
handler: handlerEntry.handlerPath,
|
|
2913
|
+
attempt: record.attempt
|
|
2914
|
+
});
|
|
2915
|
+
const result = await this.config.executionBackend.execute(request, {
|
|
2916
|
+
signal: void 0
|
|
2917
|
+
// TODO: Support cancellation
|
|
2918
|
+
});
|
|
2919
|
+
if (result.ok) {
|
|
2920
|
+
record.status = "completed";
|
|
2921
|
+
record.result = result.data;
|
|
2922
|
+
record.progress = 100;
|
|
2923
|
+
record.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2924
|
+
await this.saveJobRecord(record);
|
|
2925
|
+
this.logger.info("Job completed", {
|
|
2926
|
+
jobId,
|
|
2927
|
+
type: record.type,
|
|
2928
|
+
duration: result.executionTimeMs
|
|
2929
|
+
});
|
|
2930
|
+
await this.events.publish("job.completed", {
|
|
2931
|
+
jobId,
|
|
2932
|
+
type: record.type,
|
|
2933
|
+
tenantId: record.tenantId,
|
|
2934
|
+
result: result.data
|
|
2935
|
+
});
|
|
2936
|
+
} else {
|
|
2937
|
+
const errorMsg = result.error?.message ?? "Unknown error";
|
|
2938
|
+
await this.handleJobFailure(record, errorMsg);
|
|
2939
|
+
}
|
|
2940
|
+
} catch (error) {
|
|
2941
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2942
|
+
this.logger.error("Job execution failed", void 0, { jobId, error: errorMsg });
|
|
2943
|
+
await this.handleJobFailure(record, errorMsg);
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Update job progress.
|
|
2948
|
+
*
|
|
2949
|
+
* Called by job handler via ctx.updateProgress()
|
|
2950
|
+
*/
|
|
2951
|
+
async updateProgress(jobId, percent, message) {
|
|
2952
|
+
const record = await this.getJobRecord(jobId);
|
|
2953
|
+
if (!record) {
|
|
2954
|
+
this.logger.warn("Cannot update progress: job not found", { jobId });
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
record.progress = Math.max(0, Math.min(100, percent));
|
|
2958
|
+
await this.saveJobRecord(record);
|
|
2959
|
+
await this.events.publish("job.progress", {
|
|
2960
|
+
jobId,
|
|
2961
|
+
type: record.type,
|
|
2962
|
+
tenantId: record.tenantId,
|
|
2963
|
+
progress: record.progress,
|
|
2964
|
+
message
|
|
2965
|
+
});
|
|
2966
|
+
}
|
|
2967
|
+
// ============================================================================
|
|
2968
|
+
// Private helpers
|
|
2969
|
+
// ============================================================================
|
|
2970
|
+
async handleJobFailure(record, errorMsg) {
|
|
2971
|
+
if (record.attempt < record.maxRetries) {
|
|
2972
|
+
const backoffMs = this.calculateBackoff(record.attempt, record.type);
|
|
2973
|
+
const availableAt = Date.now() + backoffMs;
|
|
2974
|
+
record.status = "pending";
|
|
2975
|
+
await this.saveJobRecord(record);
|
|
2976
|
+
await this.enqueueJob(record.id, record.priority, availableAt);
|
|
2977
|
+
this.logger.info("Job retry scheduled", {
|
|
2978
|
+
jobId: record.id,
|
|
2979
|
+
attempt: record.attempt,
|
|
2980
|
+
maxRetries: record.maxRetries,
|
|
2981
|
+
backoffMs
|
|
2982
|
+
});
|
|
2983
|
+
await this.events.publish("job.retry", {
|
|
2984
|
+
jobId: record.id,
|
|
2985
|
+
type: record.type,
|
|
2986
|
+
tenantId: record.tenantId,
|
|
2987
|
+
attempt: record.attempt,
|
|
2988
|
+
backoffMs
|
|
2989
|
+
});
|
|
2990
|
+
} else {
|
|
2991
|
+
await this.markJobFailed(record, errorMsg);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
async markJobFailed(record, errorMsg) {
|
|
2995
|
+
record.status = "failed";
|
|
2996
|
+
record.error = errorMsg;
|
|
2997
|
+
record.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2998
|
+
await this.saveJobRecord(record);
|
|
2999
|
+
this.logger.error("Job failed", void 0, {
|
|
3000
|
+
jobId: record.id,
|
|
3001
|
+
type: record.type,
|
|
3002
|
+
error: errorMsg,
|
|
3003
|
+
attempt: record.attempt
|
|
3004
|
+
});
|
|
3005
|
+
await this.events.publish("job.failed", {
|
|
3006
|
+
jobId: record.id,
|
|
3007
|
+
type: record.type,
|
|
3008
|
+
tenantId: record.tenantId,
|
|
3009
|
+
error: errorMsg
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
calculateBackoff(attempt, jobType) {
|
|
3013
|
+
const handlerEntry = this.handlerRegistry.get(jobType);
|
|
3014
|
+
const strategy = handlerEntry?.config.retryBackoff ?? "exp";
|
|
3015
|
+
if (strategy === "exp") {
|
|
3016
|
+
return Math.min(1e3 * Math.pow(2, attempt), 6e4);
|
|
3017
|
+
} else {
|
|
3018
|
+
return Math.min(5e3 * (attempt + 1), 6e4);
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
async saveJobRecord(record) {
|
|
3022
|
+
const key = `kb:job:${record.id}`;
|
|
3023
|
+
await this.cache.set(key, record, 7 * 24 * 60 * 60 * 1e3);
|
|
3024
|
+
}
|
|
3025
|
+
async getJobRecord(jobId) {
|
|
3026
|
+
const key = `kb:job:${jobId}`;
|
|
3027
|
+
return this.cache.get(key);
|
|
3028
|
+
}
|
|
3029
|
+
async enqueueJob(jobId, priority, availableAt) {
|
|
3030
|
+
const entry = {
|
|
3031
|
+
jobId,
|
|
3032
|
+
priority,
|
|
3033
|
+
availableAt
|
|
3034
|
+
};
|
|
3035
|
+
await this.cache.zadd("kb:jobqueue", availableAt, JSON.stringify(entry));
|
|
3036
|
+
}
|
|
3037
|
+
async removeFromQueue(jobId) {
|
|
3038
|
+
const results = await this.cache.zrangebyscore("kb:jobqueue", 0, Date.now() + 1e6);
|
|
3039
|
+
for (const raw of results) {
|
|
3040
|
+
try {
|
|
3041
|
+
const entry = JSON.parse(raw);
|
|
3042
|
+
if (entry.jobId === jobId) {
|
|
3043
|
+
await this.cache.zrem("kb:jobqueue", raw);
|
|
3044
|
+
break;
|
|
3045
|
+
}
|
|
3046
|
+
} catch {
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
async findByIdempotencyKey(key) {
|
|
3051
|
+
const pattern = "kb:job:*";
|
|
3052
|
+
const keys = await this.getAllJobKeys(pattern);
|
|
3053
|
+
const records = await Promise.all(keys.map((k) => this.cache.get(k)));
|
|
3054
|
+
return records.find((record) => record?.idempotencyKey === key) ?? null;
|
|
3055
|
+
}
|
|
3056
|
+
async getAllJobKeys(_pattern) {
|
|
3057
|
+
return [];
|
|
3058
|
+
}
|
|
3059
|
+
jobRecordToHandle(record) {
|
|
3060
|
+
return {
|
|
3061
|
+
id: record.id,
|
|
3062
|
+
type: record.type,
|
|
3063
|
+
tenantId: record.tenantId,
|
|
3064
|
+
status: record.status,
|
|
3065
|
+
progress: record.progress,
|
|
3066
|
+
result: record.result,
|
|
3067
|
+
error: record.error,
|
|
3068
|
+
createdAt: new Date(record.createdAt),
|
|
3069
|
+
startedAt: record.startedAt ? new Date(record.startedAt) : void 0,
|
|
3070
|
+
completedAt: record.completedAt ? new Date(record.completedAt) : void 0
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
};
|
|
3074
|
+
|
|
3075
|
+
export { ArtifactMerger, ConcurrencyManager, EnvSecretProvider, EventBusBridge, JobManager, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowScheduleManager, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
|
|
3076
|
+
//# sourceMappingURL=index.js.map
|
|
3077
|
+
//# sourceMappingURL=index.js.map
|