@kb-labs/workflow-engine 2.94.0 → 2.98.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 CHANGED
@@ -1,7 +1,7 @@
1
- import { readFile, mkdir, writeFile, readdir } from 'fs/promises';
1
+ import { readFile, access, mkdir, writeFile, readdir } from 'fs/promises';
2
2
  import { resolve, join, basename } from 'path';
3
3
  import { parse, stringify } from 'yaml';
4
- import { WorkflowSpecSchema } from '@kb-labs/workflow-contracts';
4
+ import { WorkflowSpecSchema, evaluateExpression } from '@kb-labs/workflow-contracts';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { WORKFLOW_REDIS_CHANNEL, EVENT_NAMES, IDEMPOTENCY_TTL_ENV, CONCURRENCY_TTL_ENV } from '@kb-labs/workflow-constants';
7
7
  import { createFileSystemArtifactClient } from '@kb-labs/workflow-artifacts';
@@ -317,7 +317,8 @@ var RunCoordinator = class {
317
317
  needs,
318
318
  pendingDependencies: [...needs],
319
319
  blocked: needs.length > 0,
320
- priority
320
+ priority,
321
+ if: jobSpec.if
321
322
  });
322
323
  }
323
324
  const workflowRun = {
@@ -857,7 +858,38 @@ var WorkflowEngine = class {
857
858
  this.logger.info("Job re-queued for retry", { runId, jobId });
858
859
  }, backoffMs);
859
860
  } else {
860
- await this.moveToDLQ(runId, jobId, error);
861
+ await this.deadLetterJob(runId, jobId, error);
862
+ await this.cancelDownstreamJobs(runId, job.jobName, `upstream job "${job.jobName}" failed`);
863
+ await this.checkRunCompletion(runId);
864
+ }
865
+ }
866
+ /**
867
+ * Recursively cancel jobs blocked on a failed/cancelled upstream job, so the
868
+ * DAG does not leave dependents stuck in 'queued'. Cancellation cascades:
869
+ * a cancelled job also cancels its own dependents.
870
+ */
871
+ async cancelDownstreamJobs(runId, failedJobName, reason) {
872
+ const released = await this.stateStore.releaseBlockedJobs(runId, failedJobName);
873
+ for (const downstreamJob of released) {
874
+ const now = (/* @__PURE__ */ new Date()).toISOString();
875
+ await this.stateStore.updateJob(runId, downstreamJob.id, (draft) => {
876
+ draft.status = "cancelled";
877
+ draft.blocked = false;
878
+ draft.finishedAt = now;
879
+ draft.error = { message: reason, timestamp: now };
880
+ });
881
+ await this.events.publish({
882
+ type: EVENT_NAMES.job.cancelled,
883
+ runId,
884
+ jobId: downstreamJob.id,
885
+ payload: { jobName: downstreamJob.jobName, reason }
886
+ });
887
+ this.logger.info("Cancelled dependent job after upstream failure", {
888
+ runId,
889
+ jobId: downstreamJob.id,
890
+ unlockedBy: failedJobName
891
+ });
892
+ await this.cancelDownstreamJobs(runId, downstreamJob.jobName, reason);
861
893
  }
862
894
  }
863
895
  /**
@@ -879,7 +911,15 @@ var WorkflowEngine = class {
879
911
  draft.status = "running";
880
912
  draft.startedAt = (/* @__PURE__ */ new Date()).toISOString();
881
913
  });
882
- this.logger.debug("Job started", { runId, jobId });
914
+ await this.stateStore.updateRun(runId, (draft) => {
915
+ if (draft.status === "queued") {
916
+ draft.status = "running";
917
+ if (!draft.startedAt) {
918
+ draft.startedAt = (/* @__PURE__ */ new Date()).toISOString();
919
+ }
920
+ }
921
+ });
922
+ this.logger.info("Job started", { runId, jobId });
883
923
  const run = await this.getRun(runId);
884
924
  const job = run?.jobs.find((j) => j.id === jobId);
885
925
  this.analytics?.track("workflow.job.started", {
@@ -924,14 +964,65 @@ var WorkflowEngine = class {
924
964
  payload: { jobName: job?.jobName, durationMs: duration }
925
965
  });
926
966
  if (job?.jobName) {
967
+ const run2 = await this.stateStore.getRun(runId);
968
+ const exprCtx = this.buildExpressionContext(run2);
927
969
  const released = await this.stateStore.releaseBlockedJobs(runId, job.jobName);
928
970
  for (const releasedJob of released) {
929
- await this.scheduler.enqueueJob(runId, releasedJob, releasedJob.priority ?? "normal");
930
- this.logger.info("Unblocked dependent job", { runId, jobId: releasedJob.id, unlockedBy: job.jobName });
971
+ if (releasedJob.if && !this.evaluateJobIf(releasedJob.if, exprCtx)) {
972
+ await this.skipJob(runId, releasedJob);
973
+ } else {
974
+ await this.scheduler.enqueueJob(runId, releasedJob, releasedJob.priority ?? "normal");
975
+ this.logger.info("Unblocked dependent job", { runId, jobId: releasedJob.id, unlockedBy: job.jobName });
976
+ }
931
977
  }
932
978
  }
933
979
  await this.checkRunCompletion(runId);
934
980
  }
981
+ /** Build a minimal ExpressionContext from run state (for job-level if evaluation). */
982
+ buildExpressionContext(run) {
983
+ const ctx = {
984
+ env: run?.env ?? {},
985
+ trigger: run?.trigger ?? { type: "manual" },
986
+ inputs: run?.inputs ?? {},
987
+ steps: {}
988
+ };
989
+ if (run) {
990
+ for (const j of run.jobs) {
991
+ for (const s of j.steps) {
992
+ if (s.status === "success" && s.spec.id) {
993
+ ctx.steps[s.spec.id] = { outputs: s.outputs ?? {} };
994
+ }
995
+ }
996
+ }
997
+ }
998
+ return ctx;
999
+ }
1000
+ /** Evaluate a job-level `if:` expression. Strips ${{ }} wrapper if present. */
1001
+ evaluateJobIf(condition, ctx) {
1002
+ const raw = condition.trim().replace(/^\$\{\{\s*/, "").replace(/\s*\}\}$/, "");
1003
+ return evaluateExpression(raw, ctx);
1004
+ }
1005
+ /** Mark a job as skipped (success) and release any jobs blocked on it. */
1006
+ async skipJob(runId, job) {
1007
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1008
+ await this.stateStore.updateJob(runId, job.id, (draft) => {
1009
+ draft.status = "success";
1010
+ draft.startedAt = now;
1011
+ draft.finishedAt = now;
1012
+ });
1013
+ this.logger.info("Job skipped: if condition false", { runId, jobId: job.id, condition: job.if });
1014
+ const downstream = await this.stateStore.releaseBlockedJobs(runId, job.jobName);
1015
+ const run = await this.stateStore.getRun(runId);
1016
+ const exprCtx = this.buildExpressionContext(run);
1017
+ for (const downstreamJob of downstream) {
1018
+ if (downstreamJob.if && !this.evaluateJobIf(downstreamJob.if, exprCtx)) {
1019
+ await this.skipJob(runId, downstreamJob);
1020
+ } else {
1021
+ await this.scheduler.enqueueJob(runId, downstreamJob, downstreamJob.priority ?? "normal");
1022
+ this.logger.info("Unblocked dependent job", { runId, jobId: downstreamJob.id, unlockedBy: job.jobName });
1023
+ }
1024
+ }
1025
+ }
935
1026
  /**
936
1027
  * Check if all jobs in a run are completed and update run status accordingly.
937
1028
  */
@@ -947,7 +1038,7 @@ var WorkflowEngine = class {
947
1038
  return;
948
1039
  }
949
1040
  if (allSuccess) {
950
- await this.stateStore.updateRun(runId, (draft) => {
1041
+ const updated = await this.stateStore.updateRun(runId, (draft) => {
951
1042
  draft.status = "success";
952
1043
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
953
1044
  return draft;
@@ -965,10 +1056,24 @@ var WorkflowEngine = class {
965
1056
  runId,
966
1057
  payload: { status: "success", name: run.name }
967
1058
  });
1059
+ if (updated) {
1060
+ await this.snapshotTerminalRun(updated);
1061
+ }
968
1062
  } else if (anyFailed) {
969
- await this.stateStore.updateRun(runId, (draft) => {
1063
+ const failedJob = run.jobs.find((j) => j.status === "failed");
1064
+ const updated = await this.stateStore.updateRun(runId, (draft) => {
970
1065
  draft.status = "failed";
971
1066
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1067
+ if (failedJob?.error) {
1068
+ draft.result = {
1069
+ status: "failed",
1070
+ summary: `Job ${failedJob.jobName} failed`,
1071
+ error: {
1072
+ message: failedJob.error.message,
1073
+ details: failedJob.error.stack ? { stack: failedJob.error.stack } : void 0
1074
+ }
1075
+ };
1076
+ }
972
1077
  return draft;
973
1078
  });
974
1079
  this.logger.info("Workflow run failed", { runId });
@@ -984,6 +1089,9 @@ var WorkflowEngine = class {
984
1089
  runId,
985
1090
  payload: { status: "failed", name: run.name }
986
1091
  });
1092
+ if (updated) {
1093
+ await this.snapshotTerminalRun(updated);
1094
+ }
987
1095
  }
988
1096
  }
989
1097
  /**
@@ -1215,22 +1323,14 @@ var WorkflowEngine = class {
1215
1323
  /**
1216
1324
  * Move permanently failed job to Dead Letter Queue.
1217
1325
  */
1218
- async moveToDLQ(runId, jobId, error) {
1219
- this.logger.warn("Job moved to DLQ after max retries", { runId, jobId });
1220
- await this.stateStore.updateRun(runId, (draft) => {
1221
- draft.status = "dlq";
1222
- draft.result = {
1223
- status: "dlq",
1224
- summary: `Job ${jobId} failed after max retries`,
1225
- error: {
1226
- message: error.message,
1227
- details: {
1228
- stack: error.stack
1229
- }
1230
- }
1231
- };
1232
- draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1233
- });
1326
+ /**
1327
+ * Record a dead-letter entry for a job that exhausted its retries. This is an
1328
+ * observability artifact only — it does NOT change the run status. The run
1329
+ * outcome is decided by checkRunCompletion (→ 'failed'). 'dlq' as a run status
1330
+ * is reserved for infrastructure failures and is no longer set here (B-029).
1331
+ */
1332
+ async deadLetterJob(runId, jobId, error) {
1333
+ this.logger.warn("Job dead-lettered after max retries", { runId, jobId });
1234
1334
  const dlqKey = `workflow:dlq:${runId}:${jobId}`;
1235
1335
  const run = await this.stateStore.getRun(runId);
1236
1336
  const job = run?.jobs.find((j) => j.id === jobId);
@@ -1250,10 +1350,6 @@ var WorkflowEngine = class {
1250
1350
  7 * 24 * 60 * 60 * 1e3
1251
1351
  // TTL 7 days
1252
1352
  );
1253
- const updatedRun = await this.stateStore.getRun(runId);
1254
- if (updatedRun) {
1255
- await this.publishRunEvent(EVENT_NAMES.run.failed, updatedRun);
1256
- }
1257
1353
  }
1258
1354
  async updateRun(runId, mutator) {
1259
1355
  const updated = await this.stateStore.updateRun(runId, mutator);
@@ -1280,9 +1376,41 @@ var WorkflowEngine = class {
1280
1376
  status === "failed" ? EVENT_NAMES.run.failed : status === "cancelled" ? EVENT_NAMES.run.cancelled : EVENT_NAMES.run.finished,
1281
1377
  updated
1282
1378
  );
1379
+ await this.snapshotTerminalRun(updated);
1283
1380
  }
1284
1381
  return updated;
1285
1382
  }
1383
+ /**
1384
+ * Persist a snapshot for a run that just reached a terminal state, so it
1385
+ * can be replayed from any step later (`replayRun`, used by both
1386
+ * `workflow runs restart --from-step` and `rerun --failed-only`). Only
1387
+ * 'success'/'failed' carry meaningful step output data worth snapshotting.
1388
+ *
1389
+ * Shared by finalizeRun() and checkRunCompletion() — checkRunCompletion is
1390
+ * the run-completion path actually driven by the worker in production, so
1391
+ * without this call here, no snapshot is ever created outside of tests
1392
+ * that call finalizeRun() directly (issue #263).
1393
+ */
1394
+ async snapshotTerminalRun(run) {
1395
+ if (run.status !== "success" && run.status !== "failed") {
1396
+ return;
1397
+ }
1398
+ const stepOutputs = {};
1399
+ for (const job of run.jobs) {
1400
+ for (const step of job.steps) {
1401
+ if (step.outputs && Object.keys(step.outputs).length > 0) {
1402
+ stepOutputs[step.id] = step.outputs;
1403
+ }
1404
+ }
1405
+ }
1406
+ await this.snapshotStorage.createSnapshot(run, stepOutputs, run.env ?? {}).catch((err) => {
1407
+ this.logger.error(
1408
+ "Failed to create run snapshot \u2014 restart --from-step and rerun --failed-only will not work for this run",
1409
+ err instanceof Error ? err : new Error(String(err)),
1410
+ { runId: run.id, status: run.status }
1411
+ );
1412
+ });
1413
+ }
1286
1414
  async nextJob() {
1287
1415
  return this.scheduler.dequeueJob();
1288
1416
  }
@@ -1303,13 +1431,13 @@ var WorkflowEngine = class {
1303
1431
  /**
1304
1432
  * Publish a log entry for real-time streaming to Studio UI.
1305
1433
  */
1306
- async publishLog(runId, jobId, stepId, entry) {
1434
+ async publishLog(runId, jobId, stepId, entry, stepName) {
1307
1435
  await this.events.publish({
1308
1436
  type: EVENT_NAMES.log.appended,
1309
1437
  runId,
1310
1438
  jobId,
1311
1439
  stepId,
1312
- payload: entry
1440
+ payload: stepName ? { ...entry, stepName } : entry
1313
1441
  });
1314
1442
  }
1315
1443
  /**
@@ -1363,16 +1491,22 @@ var WorkflowEngine = class {
1363
1491
  restoredRun.env = snapshot.env;
1364
1492
  }
1365
1493
  if (options.fromStepId) {
1494
+ const stepExists = restoredRun.jobs.some(
1495
+ (job) => job.steps.some((s) => s.id === options.fromStepId)
1496
+ );
1497
+ if (!stepExists) {
1498
+ throw new Error(`Step not found: ${options.fromStepId}`);
1499
+ }
1500
+ }
1501
+ if (options.fromStepId) {
1502
+ let foundStep = false;
1366
1503
  for (const job of restoredRun.jobs) {
1367
- let foundStep = false;
1368
1504
  for (const step of job.steps) {
1369
1505
  if (step.id === options.fromStepId) {
1370
1506
  foundStep = true;
1371
- if (step.status !== "queued") {
1372
- step.status = "queued";
1373
- step.startedAt = void 0;
1374
- step.finishedAt = void 0;
1375
- }
1507
+ step.status = "queued";
1508
+ step.startedAt = void 0;
1509
+ step.finishedAt = void 0;
1376
1510
  continue;
1377
1511
  }
1378
1512
  if (!foundStep) {
@@ -1396,16 +1530,41 @@ var WorkflowEngine = class {
1396
1530
  }
1397
1531
  }
1398
1532
  }
1399
- restoredRun.status = "running";
1400
- restoredRun.startedAt = restoredRun.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1401
- restoredRun.finishedAt = void 0;
1402
- await this.stateStore.saveRun(restoredRun);
1403
- await this.scheduler.scheduleRun(restoredRun);
1404
- this.logger.info("Run replayed from snapshot", {
1405
- runId,
1533
+ const isJobComplete = (job) => job.steps.every((s) => s.status === "success" || s.status === "skipped");
1534
+ for (const job of restoredRun.jobs) {
1535
+ if (!job.needs || job.needs.length === 0) {
1536
+ job.blocked = false;
1537
+ job.pendingDependencies = [];
1538
+ continue;
1539
+ }
1540
+ const pendingDeps = job.needs.filter((depName) => {
1541
+ const depJob = restoredRun.jobs.find((j) => j.jobName === depName);
1542
+ return depJob !== void 0 && !isJobComplete(depJob);
1543
+ });
1544
+ job.pendingDependencies = pendingDeps;
1545
+ job.blocked = pendingDeps.length > 0;
1546
+ }
1547
+ const newRunId = randomUUID();
1548
+ const newNow = (/* @__PURE__ */ new Date()).toISOString();
1549
+ const newRun = {
1550
+ ...restoredRun,
1551
+ id: newRunId,
1552
+ status: "queued",
1553
+ createdAt: newNow,
1554
+ queuedAt: newNow,
1555
+ startedAt: void 0,
1556
+ finishedAt: void 0,
1557
+ durationMs: void 0,
1558
+ result: void 0
1559
+ };
1560
+ await this.stateStore.saveRun(newRun);
1561
+ await this.scheduler.scheduleRun(newRun);
1562
+ this.logger.info("Run replayed as new run from snapshot", {
1563
+ originalRunId: runId,
1564
+ newRunId,
1406
1565
  fromStepId: options.fromStepId
1407
1566
  });
1408
- return restoredRun;
1567
+ return newRun;
1409
1568
  }
1410
1569
  /**
1411
1570
  * Delete a snapshot
@@ -1688,11 +1847,82 @@ var ArtifactMerger = class {
1688
1847
  }
1689
1848
  };
1690
1849
 
1850
+ // src/manifest-converter.ts
1851
+ var ManifestConverter = class {
1852
+ convertWorkflowHandler(pluginId, handler, pluginRoot) {
1853
+ const id = `${pluginId}/${handler.id}`;
1854
+ return {
1855
+ kind: "workflow",
1856
+ id,
1857
+ source: "manifest",
1858
+ pluginId,
1859
+ manifestPath: pluginRoot,
1860
+ name: handler.describe ?? handler.id,
1861
+ description: handler.describe,
1862
+ tags: ["plugin", pluginId],
1863
+ triggers: [{ type: "manual" }],
1864
+ handler: handler.handler,
1865
+ status: "active",
1866
+ permissions: handler.permissions,
1867
+ input: handler.input,
1868
+ output: handler.output
1869
+ };
1870
+ }
1871
+ convertJobHandler(pluginId, handler, pluginRoot) {
1872
+ const id = `${pluginId}:job:${handler.id}`;
1873
+ return {
1874
+ kind: "job",
1875
+ id,
1876
+ source: "manifest",
1877
+ pluginId,
1878
+ manifestPath: pluginRoot,
1879
+ name: handler.describe ?? handler.id,
1880
+ description: handler.describe,
1881
+ tags: ["plugin", "job", pluginId],
1882
+ triggers: [{ type: "manual" }],
1883
+ handler: handler.handler,
1884
+ status: "active",
1885
+ permissions: handler.permissions,
1886
+ input: handler.input,
1887
+ output: handler.output
1888
+ };
1889
+ }
1890
+ convertCronSchedule(pluginId, cronDecl, pluginRoot) {
1891
+ const id = `${pluginId}:cron:${cronDecl.id}`;
1892
+ const schedule = {
1893
+ cron: cronDecl.schedule,
1894
+ enabled: cronDecl.enabled ?? true
1895
+ };
1896
+ return {
1897
+ kind: "cron",
1898
+ id,
1899
+ source: "manifest",
1900
+ pluginId,
1901
+ manifestPath: pluginRoot,
1902
+ name: cronDecl.describe ?? cronDecl.id,
1903
+ description: cronDecl.describe,
1904
+ tags: ["plugin", "cron", pluginId],
1905
+ triggers: [
1906
+ {
1907
+ type: "schedule",
1908
+ config: { cron: cronDecl.schedule, timezone: cronDecl.timezone }
1909
+ }
1910
+ ],
1911
+ handler: void 0,
1912
+ schedule,
1913
+ status: cronDecl.enabled ?? true ? "active" : "disabled",
1914
+ permissions: cronDecl.permissions
1915
+ };
1916
+ }
1917
+ };
1918
+
1691
1919
  // src/manifest-scanner.ts
1920
+ var CACHE_KEY_WORKFLOWS = "manifest-scanner:v1:workflows";
1692
1921
  var ManifestScanner = class {
1693
1922
  cliApi;
1694
1923
  platform;
1695
1924
  cacheTtlMs;
1925
+ converter = new ManifestConverter();
1696
1926
  constructor(options) {
1697
1927
  this.cliApi = options.cliApi;
1698
1928
  this.platform = options.platform;
@@ -1700,13 +1930,14 @@ var ManifestScanner = class {
1700
1930
  }
1701
1931
  /**
1702
1932
  * Scan all installed plugins for workflows and jobs.
1933
+ * Purpose: listing — populates the REST API GET /api/v1/workflows catalog.
1934
+ * (CronDiscovery in daemon handles scheduling — different output, different consumer.)
1703
1935
  *
1704
1936
  * Returns unified WorkflowRuntime representations.
1705
1937
  */
1706
1938
  async scanPlugins() {
1707
- const cacheKey = "manifest-scanner:workflows";
1708
1939
  if (this.platform.cache) {
1709
- const cached = await this.platform.cache.get(cacheKey);
1940
+ const cached = await this.platform.cache.get(CACHE_KEY_WORKFLOWS);
1710
1941
  if (cached) {
1711
1942
  this.platform.logger?.debug("ManifestScanner: Using cached workflows", { count: cached.length });
1712
1943
  return cached;
@@ -1738,11 +1969,11 @@ var ManifestScanner = class {
1738
1969
  }
1739
1970
  try {
1740
1971
  if (converter === "workflow") {
1741
- workflows.push(this.convertWorkflowHandler(entity.ref.pluginId, entity.declaration, root));
1972
+ workflows.push(this.converter.convertWorkflowHandler(entity.ref.pluginId, entity.declaration, root));
1742
1973
  } else if (converter === "job") {
1743
- workflows.push(this.convertJobHandler(entity.ref.pluginId, entity.declaration, root));
1974
+ workflows.push(this.converter.convertJobHandler(entity.ref.pluginId, entity.declaration, root));
1744
1975
  } else {
1745
- workflows.push(this.convertCronSchedule(entity.ref.pluginId, entity.declaration, root));
1976
+ workflows.push(this.converter.convertCronSchedule(entity.ref.pluginId, entity.declaration, root));
1746
1977
  }
1747
1978
  } catch (err) {
1748
1979
  this.platform.logger?.warn("ManifestScanner: Failed to convert entity", {
@@ -1781,91 +2012,10 @@ var ManifestScanner = class {
1781
2012
  templates: templatesCount
1782
2013
  });
1783
2014
  if (this.platform.cache) {
1784
- await this.platform.cache.set(cacheKey, workflows, this.cacheTtlMs);
2015
+ await this.platform.cache.set(CACHE_KEY_WORKFLOWS, workflows, this.cacheTtlMs);
1785
2016
  }
1786
2017
  return workflows;
1787
2018
  }
1788
- /**
1789
- * Convert workflow handler declaration to WorkflowRuntime.
1790
- */
1791
- convertWorkflowHandler(pluginId, handler, pluginRoot) {
1792
- const id = `${pluginId}/${handler.id}`;
1793
- return {
1794
- id,
1795
- source: "manifest",
1796
- pluginId,
1797
- manifestPath: pluginRoot,
1798
- name: handler.describe ?? handler.id,
1799
- description: handler.describe,
1800
- tags: ["plugin", pluginId],
1801
- triggers: [
1802
- { type: "manual" }
1803
- // Workflow handlers can always be triggered manually
1804
- ],
1805
- handler: handler.handler,
1806
- status: "active",
1807
- permissions: handler.permissions,
1808
- input: handler.input,
1809
- output: handler.output
1810
- };
1811
- }
1812
- /**
1813
- * Convert job handler declaration to WorkflowRuntime.
1814
- */
1815
- convertJobHandler(pluginId, handler, pluginRoot) {
1816
- const id = `${pluginId}:job:${handler.id}`;
1817
- return {
1818
- id,
1819
- source: "manifest",
1820
- pluginId,
1821
- manifestPath: pluginRoot,
1822
- name: handler.describe ?? handler.id,
1823
- description: handler.describe,
1824
- tags: ["plugin", "job", pluginId],
1825
- triggers: [
1826
- { type: "manual" }
1827
- // Job handlers are invoked on-demand via ctx.api.jobs.submit()
1828
- ],
1829
- handler: handler.handler,
1830
- status: "active",
1831
- permissions: handler.permissions,
1832
- input: handler.input,
1833
- output: handler.output
1834
- };
1835
- }
1836
- /**
1837
- * Convert cron schedule declaration to WorkflowRuntime.
1838
- */
1839
- convertCronSchedule(pluginId, cronDecl, pluginRoot) {
1840
- const id = `${pluginId}:cron:${cronDecl.id}`;
1841
- const schedule = {
1842
- cron: cronDecl.schedule,
1843
- enabled: cronDecl.enabled ?? true
1844
- };
1845
- return {
1846
- id,
1847
- source: "manifest",
1848
- pluginId,
1849
- manifestPath: pluginRoot,
1850
- name: cronDecl.describe ?? cronDecl.id,
1851
- description: cronDecl.describe,
1852
- tags: ["plugin", "cron", pluginId],
1853
- triggers: [
1854
- {
1855
- type: "schedule",
1856
- config: { cron: cronDecl.schedule, timezone: cronDecl.timezone }
1857
- }
1858
- ],
1859
- // Note: Cron schedules reference a job type to execute
1860
- // The actual handler path comes from the job declaration
1861
- handler: void 0,
1862
- // Will be resolved at execution time via job type
1863
- schedule,
1864
- status: cronDecl.enabled ?? true ? "active" : "disabled",
1865
- permissions: cronDecl.permissions
1866
- };
1867
- }
1868
- // Legacy convertLegacyJob method removed - use cron schedules instead
1869
2019
  /**
1870
2020
  * Scan all installed plugins for job handlers only.
1871
2021
  *
@@ -1895,7 +2045,7 @@ var ManifestScanner = class {
1895
2045
  */
1896
2046
  async clearCache() {
1897
2047
  if (this.platform.cache) {
1898
- await this.platform.cache.delete("manifest-scanner:workflows");
2048
+ await this.platform.cache.delete(CACHE_KEY_WORKFLOWS);
1899
2049
  this.platform.logger?.debug("ManifestScanner: Cache cleared");
1900
2050
  }
1901
2051
  }
@@ -2007,7 +2157,10 @@ var WorkflowRepository = class {
2007
2157
  * Delete workflow.
2008
2158
  */
2009
2159
  async delete(id) {
2010
- const path = this.getWorkflowPath(id);
2160
+ const path = await this.getWorkflowPath(id);
2161
+ if (!path) {
2162
+ throw new Error(`Invalid workflow ID for deletion: ${id}`);
2163
+ }
2011
2164
  try {
2012
2165
  await this.platform.storage.delete(path);
2013
2166
  this.platform.logger?.info("WorkflowRepository: Deleted workflow", { id });
@@ -2078,21 +2231,22 @@ var WorkflowRepository = class {
2078
2231
  status
2079
2232
  });
2080
2233
  }
2081
- getWorkflowPath(id) {
2234
+ async getWorkflowPath(id) {
2235
+ if (id.includes("/") || id.includes("\\") || id.includes("..") || id.startsWith(".")) {
2236
+ return null;
2237
+ }
2082
2238
  const ymlPath = join(this.absoluteStorageDir, `${id}.yml`);
2083
2239
  const yamlPath = join(this.absoluteStorageDir, `${id}.yaml`);
2084
- if (existsSync(ymlPath)) {
2085
- return ymlPath;
2086
- }
2087
- return yamlPath;
2240
+ return access(ymlPath).then(() => ymlPath).catch(() => yamlPath);
2088
2241
  }
2089
2242
  async saveWorkflow(id, workflow) {
2090
- const path = this.getWorkflowPath(id);
2243
+ const path = await this.getWorkflowPath(id);
2244
+ if (!path) {
2245
+ throw new Error(`Invalid workflow ID for storage: ${id}`);
2246
+ }
2091
2247
  const yaml = stringify(workflow, { indent: 2 });
2092
2248
  try {
2093
- if (!existsSync(this.absoluteStorageDir)) {
2094
- await mkdir(this.absoluteStorageDir, { recursive: true });
2095
- }
2249
+ await mkdir(this.absoluteStorageDir, { recursive: true });
2096
2250
  await writeFile(path, yaml, "utf-8");
2097
2251
  } catch (error) {
2098
2252
  this.platform.logger?.error(
@@ -2104,17 +2258,17 @@ var WorkflowRepository = class {
2104
2258
  }
2105
2259
  }
2106
2260
  async loadWorkflow(id) {
2107
- const path = this.getWorkflowPath(id);
2261
+ const path = await this.getWorkflowPath(id);
2262
+ if (!path) {
2263
+ return null;
2264
+ }
2108
2265
  try {
2109
- if (!existsSync(path)) {
2110
- return null;
2111
- }
2112
2266
  const content = await readFile(path, "utf-8");
2113
2267
  const parsed = parse(content);
2114
2268
  if (parsed.id && parsed.spec && parsed.createdAt) {
2115
- return parsed;
2269
+ return { ...parsed, id };
2116
2270
  }
2117
- const spec = {
2271
+ const candidateSpec = {
2118
2272
  name: parsed.name,
2119
2273
  version: parsed.version || "1.0.0",
2120
2274
  description: parsed.description,
@@ -2125,8 +2279,20 @@ var WorkflowRepository = class {
2125
2279
  env: parsed.env,
2126
2280
  secrets: parsed.secrets
2127
2281
  };
2282
+ const validation = WorkflowSpecSchema.safeParse(candidateSpec);
2283
+ if (!validation.success) {
2284
+ this.platform.logger?.warn(
2285
+ `WorkflowRepository: skipping invalid workflow "${id}" \u2014 ${validation.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`,
2286
+ { id, path }
2287
+ );
2288
+ return null;
2289
+ }
2290
+ const spec = validation.data;
2128
2291
  const stored = {
2129
- id: parsed.id || id,
2292
+ // The filename is the canonical id for file-based workflows. Using the
2293
+ // inner `id:` here made list() report an id that get()/getWorkflowPath()
2294
+ // (which resolve by filename) could not find → POST /runs 404 (B-016).
2295
+ id,
2130
2296
  spec,
2131
2297
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2132
2298
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2147,9 +2313,6 @@ var WorkflowRepository = class {
2147
2313
  }
2148
2314
  async listWorkflowFiles() {
2149
2315
  try {
2150
- if (!existsSync(this.absoluteStorageDir)) {
2151
- return [];
2152
- }
2153
2316
  const files = await readdir(this.absoluteStorageDir);
2154
2317
  return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
2155
2318
  } catch (error) {
@@ -2212,7 +2375,9 @@ var WorkflowRepository = class {
2212
2375
  // Store full spec for execution
2213
2376
  input: spec,
2214
2377
  // Expose declared input schema for REST API / Studio UI
2215
- inputSchema: spec.inputs
2378
+ inputSchema: spec.inputs,
2379
+ version: spec.version,
2380
+ updatedAt: stored.updatedAt
2216
2381
  };
2217
2382
  }
2218
2383
  };
@@ -2265,15 +2430,30 @@ var WorkflowService = class {
2265
2430
  }
2266
2431
  /**
2267
2432
  * Get workflow by ID (from either source).
2433
+ *
2434
+ * Lookup order:
2435
+ * 1. Standalone by filename-derived id (canonical, fast path)
2436
+ * 2. Standalone by `name:` field — allows `--workflow-id=dev-cycle` even
2437
+ * when the file is named `03-dev-cycle.yml` (F8 ergonomics fix)
2438
+ * 3. Manifest-based by id
2439
+ * 4. Manifest-based by name field
2268
2440
  */
2269
2441
  async get(id) {
2270
2442
  const standalone = await this.repository.get(id);
2271
2443
  if (standalone) {
2272
2444
  return standalone;
2273
2445
  }
2446
+ const allStandalone = await this.repository.list();
2447
+ const byName = allStandalone.find((w) => w.name === id);
2448
+ if (byName) {
2449
+ return byName;
2450
+ }
2274
2451
  const manifestWorkflows = await this.scanner.scanPlugins();
2275
2452
  const manifest = manifestWorkflows.find((w) => w.id === id);
2276
- return manifest ?? null;
2453
+ if (manifest) {
2454
+ return manifest;
2455
+ }
2456
+ return manifestWorkflows.find((w) => w.name === id) ?? null;
2277
2457
  }
2278
2458
  /**
2279
2459
  * Create standalone workflow.
@@ -2417,163 +2597,6 @@ var WorkflowService = class {
2417
2597
  this.platform.logger?.info("WorkflowService: Manifest cache cleared");
2418
2598
  }
2419
2599
  };
2420
-
2421
- // src/workflow-schedule-manager.ts
2422
- var WorkflowScheduleManager = class {
2423
- cronManager;
2424
- workflowService;
2425
- executor;
2426
- platform;
2427
- constructor(options) {
2428
- this.cronManager = options.cronManager;
2429
- this.workflowService = options.workflowService;
2430
- this.executor = options.executor;
2431
- this.platform = options.platform;
2432
- }
2433
- /**
2434
- * Register all scheduled workflows with CronManager.
2435
- *
2436
- * Scans both manifest-based jobs and standalone workflows with schedules.
2437
- */
2438
- async registerAll() {
2439
- const workflows = await this.workflowService.listAll();
2440
- const scheduled = workflows.filter(
2441
- (w) => w.schedule && w.schedule.enabled && w.status === "active"
2442
- );
2443
- this.platform.logger?.info("WorkflowScheduleManager: Registering scheduled workflows", {
2444
- total: workflows.length,
2445
- scheduled: scheduled.length
2446
- });
2447
- await Promise.all(scheduled.map((workflow) => this.register(workflow)));
2448
- }
2449
- /**
2450
- * Register single workflow schedule.
2451
- */
2452
- async register(workflow) {
2453
- if (!workflow.schedule || !workflow.schedule.enabled) {
2454
- this.platform.logger?.warn("WorkflowScheduleManager: Cannot register workflow without schedule", {
2455
- id: workflow.id
2456
- });
2457
- return;
2458
- }
2459
- const cronId = this.getCronId(workflow.id);
2460
- const schedule = workflow.schedule.cron;
2461
- this.cronManager.register(cronId, schedule, async (context) => {
2462
- this.platform.logger?.info("WorkflowScheduleManager: Executing scheduled workflow", {
2463
- workflowId: workflow.id,
2464
- workflowName: workflow.name,
2465
- runCount: context.runCount
2466
- });
2467
- try {
2468
- const result = await this.executor.execute({
2469
- workflowId: workflow.id,
2470
- trigger: "schedule",
2471
- input: {}
2472
- });
2473
- this.platform.logger?.info("WorkflowScheduleManager: Workflow execution started", {
2474
- workflowId: workflow.id,
2475
- runId: result.runId
2476
- });
2477
- } catch (error) {
2478
- this.platform.logger?.error(
2479
- "WorkflowScheduleManager: Workflow execution failed",
2480
- error instanceof Error ? error : void 0,
2481
- {
2482
- workflowId: workflow.id,
2483
- workflowName: workflow.name
2484
- }
2485
- );
2486
- }
2487
- });
2488
- this.platform.logger?.debug("WorkflowScheduleManager: Registered workflow", {
2489
- workflowId: workflow.id,
2490
- schedule
2491
- });
2492
- }
2493
- /**
2494
- * Unregister workflow schedule.
2495
- */
2496
- async unregister(workflowId) {
2497
- const cronId = this.getCronId(workflowId);
2498
- this.cronManager.unregister(cronId);
2499
- this.platform.logger?.debug("WorkflowScheduleManager: Unregistered workflow", {
2500
- workflowId
2501
- });
2502
- }
2503
- /**
2504
- * Re-register all schedules (refresh).
2505
- *
2506
- * Useful after workflow changes or service restart.
2507
- */
2508
- async refresh() {
2509
- const allJobs = this.cronManager.list();
2510
- for (const job of allJobs) {
2511
- if (job.id.startsWith("workflow:")) {
2512
- this.cronManager.unregister(job.id);
2513
- }
2514
- }
2515
- await this.registerAll();
2516
- this.platform.logger?.info("WorkflowScheduleManager: Refreshed all schedules");
2517
- }
2518
- /**
2519
- * Get next run time for scheduled workflow.
2520
- */
2521
- getNextRun(workflowId) {
2522
- const cronId = this.getCronId(workflowId);
2523
- const job = this.cronManager.list().find((j) => j.id === cronId);
2524
- return job?.nextRun ?? null;
2525
- }
2526
- /**
2527
- * Get last run time for scheduled workflow.
2528
- */
2529
- getLastRun(workflowId) {
2530
- const cronId = this.getCronId(workflowId);
2531
- const job = this.cronManager.list().find((j) => j.id === cronId);
2532
- return job?.lastRun ?? null;
2533
- }
2534
- /**
2535
- * Pause scheduled workflow.
2536
- */
2537
- pause(workflowId) {
2538
- const cronId = this.getCronId(workflowId);
2539
- this.cronManager.pause(cronId);
2540
- this.platform.logger?.info("WorkflowScheduleManager: Paused workflow schedule", {
2541
- workflowId
2542
- });
2543
- }
2544
- /**
2545
- * Resume paused workflow schedule.
2546
- */
2547
- resume(workflowId) {
2548
- const cronId = this.getCronId(workflowId);
2549
- this.cronManager.resume(cronId);
2550
- this.platform.logger?.info("WorkflowScheduleManager: Resumed workflow schedule", {
2551
- workflowId
2552
- });
2553
- }
2554
- /**
2555
- * List all scheduled workflows.
2556
- */
2557
- listScheduled() {
2558
- return this.cronManager.list().filter((job) => job.id.startsWith("workflow:")).map((job) => ({
2559
- workflowId: this.getWorkflowId(job.id),
2560
- schedule: job.schedule,
2561
- status: job.status,
2562
- lastRun: job.lastRun,
2563
- nextRun: job.nextRun,
2564
- runCount: job.runCount
2565
- }));
2566
- }
2567
- // ============================================================================
2568
- // Private helpers
2569
- // ============================================================================
2570
- getCronId(workflowId) {
2571
- return `workflow:${workflowId}`;
2572
- }
2573
- getWorkflowId(cronId) {
2574
- return cronId.replace("workflow:", "");
2575
- }
2576
- };
2577
2600
  var WorkflowRegistry = class {
2578
2601
  constructor(options) {
2579
2602
  this.options = options;
@@ -3121,6 +3144,6 @@ var JobManager = class {
3121
3144
  }
3122
3145
  };
3123
3146
 
3124
- export { ArtifactMerger, ConcurrencyManager, EnvSecretProvider, EventBusBridge, JobManager, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowScheduleManager, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
3147
+ export { ArtifactMerger, ConcurrencyManager, EnvSecretProvider, EventBusBridge, JobManager, ManifestConverter, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
3125
3148
  //# sourceMappingURL=index.js.map
3126
3149
  //# sourceMappingURL=index.js.map