@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/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