@kb-labs/workflow-engine 2.93.0 → 2.96.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
  */
@@ -966,9 +1057,20 @@ var WorkflowEngine = class {
966
1057
  payload: { status: "success", name: run.name }
967
1058
  });
968
1059
  } else if (anyFailed) {
1060
+ const failedJob = run.jobs.find((j) => j.status === "failed");
969
1061
  await this.stateStore.updateRun(runId, (draft) => {
970
1062
  draft.status = "failed";
971
1063
  draft.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
1064
+ if (failedJob?.error) {
1065
+ draft.result = {
1066
+ status: "failed",
1067
+ summary: `Job ${failedJob.jobName} failed`,
1068
+ error: {
1069
+ message: failedJob.error.message,
1070
+ details: failedJob.error.stack ? { stack: failedJob.error.stack } : void 0
1071
+ }
1072
+ };
1073
+ }
972
1074
  return draft;
973
1075
  });
974
1076
  this.logger.info("Workflow run failed", { runId });
@@ -1215,22 +1317,14 @@ var WorkflowEngine = class {
1215
1317
  /**
1216
1318
  * Move permanently failed job to Dead Letter Queue.
1217
1319
  */
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
- });
1320
+ /**
1321
+ * Record a dead-letter entry for a job that exhausted its retries. This is an
1322
+ * observability artifact only — it does NOT change the run status. The run
1323
+ * outcome is decided by checkRunCompletion (→ 'failed'). 'dlq' as a run status
1324
+ * is reserved for infrastructure failures and is no longer set here (B-029).
1325
+ */
1326
+ async deadLetterJob(runId, jobId, error) {
1327
+ this.logger.warn("Job dead-lettered after max retries", { runId, jobId });
1234
1328
  const dlqKey = `workflow:dlq:${runId}:${jobId}`;
1235
1329
  const run = await this.stateStore.getRun(runId);
1236
1330
  const job = run?.jobs.find((j) => j.id === jobId);
@@ -1250,10 +1344,6 @@ var WorkflowEngine = class {
1250
1344
  7 * 24 * 60 * 60 * 1e3
1251
1345
  // TTL 7 days
1252
1346
  );
1253
- const updatedRun = await this.stateStore.getRun(runId);
1254
- if (updatedRun) {
1255
- await this.publishRunEvent(EVENT_NAMES.run.failed, updatedRun);
1256
- }
1257
1347
  }
1258
1348
  async updateRun(runId, mutator) {
1259
1349
  const updated = await this.stateStore.updateRun(runId, mutator);
@@ -1280,6 +1370,19 @@ var WorkflowEngine = class {
1280
1370
  status === "failed" ? EVENT_NAMES.run.failed : status === "cancelled" ? EVENT_NAMES.run.cancelled : EVENT_NAMES.run.finished,
1281
1371
  updated
1282
1372
  );
1373
+ if (status === "success" || status === "failed") {
1374
+ const stepOutputs = {};
1375
+ for (const job of updated.jobs) {
1376
+ for (const step of job.steps) {
1377
+ if (step.outputs && Object.keys(step.outputs).length > 0) {
1378
+ stepOutputs[step.id] = step.outputs;
1379
+ }
1380
+ }
1381
+ }
1382
+ await this.snapshotStorage.createSnapshot(updated, stepOutputs, updated.env ?? {}).catch((err) => {
1383
+ this.logger.warn("Failed to create run snapshot", { runId, error: err instanceof Error ? err.message : String(err) });
1384
+ });
1385
+ }
1283
1386
  }
1284
1387
  return updated;
1285
1388
  }
@@ -1303,13 +1406,13 @@ var WorkflowEngine = class {
1303
1406
  /**
1304
1407
  * Publish a log entry for real-time streaming to Studio UI.
1305
1408
  */
1306
- async publishLog(runId, jobId, stepId, entry) {
1409
+ async publishLog(runId, jobId, stepId, entry, stepName) {
1307
1410
  await this.events.publish({
1308
1411
  type: EVENT_NAMES.log.appended,
1309
1412
  runId,
1310
1413
  jobId,
1311
1414
  stepId,
1312
- payload: entry
1415
+ payload: stepName ? { ...entry, stepName } : entry
1313
1416
  });
1314
1417
  }
1315
1418
  /**
@@ -1363,16 +1466,22 @@ var WorkflowEngine = class {
1363
1466
  restoredRun.env = snapshot.env;
1364
1467
  }
1365
1468
  if (options.fromStepId) {
1469
+ const stepExists = restoredRun.jobs.some(
1470
+ (job) => job.steps.some((s) => s.id === options.fromStepId)
1471
+ );
1472
+ if (!stepExists) {
1473
+ throw new Error(`Step not found: ${options.fromStepId}`);
1474
+ }
1475
+ }
1476
+ if (options.fromStepId) {
1477
+ let foundStep = false;
1366
1478
  for (const job of restoredRun.jobs) {
1367
- let foundStep = false;
1368
1479
  for (const step of job.steps) {
1369
1480
  if (step.id === options.fromStepId) {
1370
1481
  foundStep = true;
1371
- if (step.status !== "queued") {
1372
- step.status = "queued";
1373
- step.startedAt = void 0;
1374
- step.finishedAt = void 0;
1375
- }
1482
+ step.status = "queued";
1483
+ step.startedAt = void 0;
1484
+ step.finishedAt = void 0;
1376
1485
  continue;
1377
1486
  }
1378
1487
  if (!foundStep) {
@@ -1396,16 +1505,41 @@ var WorkflowEngine = class {
1396
1505
  }
1397
1506
  }
1398
1507
  }
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,
1508
+ const isJobComplete = (job) => job.steps.every((s) => s.status === "success" || s.status === "skipped");
1509
+ for (const job of restoredRun.jobs) {
1510
+ if (!job.needs || job.needs.length === 0) {
1511
+ job.blocked = false;
1512
+ job.pendingDependencies = [];
1513
+ continue;
1514
+ }
1515
+ const pendingDeps = job.needs.filter((depName) => {
1516
+ const depJob = restoredRun.jobs.find((j) => j.jobName === depName);
1517
+ return depJob !== void 0 && !isJobComplete(depJob);
1518
+ });
1519
+ job.pendingDependencies = pendingDeps;
1520
+ job.blocked = pendingDeps.length > 0;
1521
+ }
1522
+ const newRunId = randomUUID();
1523
+ const newNow = (/* @__PURE__ */ new Date()).toISOString();
1524
+ const newRun = {
1525
+ ...restoredRun,
1526
+ id: newRunId,
1527
+ status: "queued",
1528
+ createdAt: newNow,
1529
+ queuedAt: newNow,
1530
+ startedAt: void 0,
1531
+ finishedAt: void 0,
1532
+ durationMs: void 0,
1533
+ result: void 0
1534
+ };
1535
+ await this.stateStore.saveRun(newRun);
1536
+ await this.scheduler.scheduleRun(newRun);
1537
+ this.logger.info("Run replayed as new run from snapshot", {
1538
+ originalRunId: runId,
1539
+ newRunId,
1406
1540
  fromStepId: options.fromStepId
1407
1541
  });
1408
- return restoredRun;
1542
+ return newRun;
1409
1543
  }
1410
1544
  /**
1411
1545
  * Delete a snapshot
@@ -1688,11 +1822,82 @@ var ArtifactMerger = class {
1688
1822
  }
1689
1823
  };
1690
1824
 
1825
+ // src/manifest-converter.ts
1826
+ var ManifestConverter = class {
1827
+ convertWorkflowHandler(pluginId, handler, pluginRoot) {
1828
+ const id = `${pluginId}/${handler.id}`;
1829
+ return {
1830
+ kind: "workflow",
1831
+ id,
1832
+ source: "manifest",
1833
+ pluginId,
1834
+ manifestPath: pluginRoot,
1835
+ name: handler.describe ?? handler.id,
1836
+ description: handler.describe,
1837
+ tags: ["plugin", pluginId],
1838
+ triggers: [{ type: "manual" }],
1839
+ handler: handler.handler,
1840
+ status: "active",
1841
+ permissions: handler.permissions,
1842
+ input: handler.input,
1843
+ output: handler.output
1844
+ };
1845
+ }
1846
+ convertJobHandler(pluginId, handler, pluginRoot) {
1847
+ const id = `${pluginId}:job:${handler.id}`;
1848
+ return {
1849
+ kind: "job",
1850
+ id,
1851
+ source: "manifest",
1852
+ pluginId,
1853
+ manifestPath: pluginRoot,
1854
+ name: handler.describe ?? handler.id,
1855
+ description: handler.describe,
1856
+ tags: ["plugin", "job", pluginId],
1857
+ triggers: [{ type: "manual" }],
1858
+ handler: handler.handler,
1859
+ status: "active",
1860
+ permissions: handler.permissions,
1861
+ input: handler.input,
1862
+ output: handler.output
1863
+ };
1864
+ }
1865
+ convertCronSchedule(pluginId, cronDecl, pluginRoot) {
1866
+ const id = `${pluginId}:cron:${cronDecl.id}`;
1867
+ const schedule = {
1868
+ cron: cronDecl.schedule,
1869
+ enabled: cronDecl.enabled ?? true
1870
+ };
1871
+ return {
1872
+ kind: "cron",
1873
+ id,
1874
+ source: "manifest",
1875
+ pluginId,
1876
+ manifestPath: pluginRoot,
1877
+ name: cronDecl.describe ?? cronDecl.id,
1878
+ description: cronDecl.describe,
1879
+ tags: ["plugin", "cron", pluginId],
1880
+ triggers: [
1881
+ {
1882
+ type: "schedule",
1883
+ config: { cron: cronDecl.schedule, timezone: cronDecl.timezone }
1884
+ }
1885
+ ],
1886
+ handler: void 0,
1887
+ schedule,
1888
+ status: cronDecl.enabled ?? true ? "active" : "disabled",
1889
+ permissions: cronDecl.permissions
1890
+ };
1891
+ }
1892
+ };
1893
+
1691
1894
  // src/manifest-scanner.ts
1895
+ var CACHE_KEY_WORKFLOWS = "manifest-scanner:v1:workflows";
1692
1896
  var ManifestScanner = class {
1693
1897
  cliApi;
1694
1898
  platform;
1695
1899
  cacheTtlMs;
1900
+ converter = new ManifestConverter();
1696
1901
  constructor(options) {
1697
1902
  this.cliApi = options.cliApi;
1698
1903
  this.platform = options.platform;
@@ -1700,13 +1905,14 @@ var ManifestScanner = class {
1700
1905
  }
1701
1906
  /**
1702
1907
  * Scan all installed plugins for workflows and jobs.
1908
+ * Purpose: listing — populates the REST API GET /api/v1/workflows catalog.
1909
+ * (CronDiscovery in daemon handles scheduling — different output, different consumer.)
1703
1910
  *
1704
1911
  * Returns unified WorkflowRuntime representations.
1705
1912
  */
1706
1913
  async scanPlugins() {
1707
- const cacheKey = "manifest-scanner:workflows";
1708
1914
  if (this.platform.cache) {
1709
- const cached = await this.platform.cache.get(cacheKey);
1915
+ const cached = await this.platform.cache.get(CACHE_KEY_WORKFLOWS);
1710
1916
  if (cached) {
1711
1917
  this.platform.logger?.debug("ManifestScanner: Using cached workflows", { count: cached.length });
1712
1918
  return cached;
@@ -1738,11 +1944,11 @@ var ManifestScanner = class {
1738
1944
  }
1739
1945
  try {
1740
1946
  if (converter === "workflow") {
1741
- workflows.push(this.convertWorkflowHandler(entity.ref.pluginId, entity.declaration, root));
1947
+ workflows.push(this.converter.convertWorkflowHandler(entity.ref.pluginId, entity.declaration, root));
1742
1948
  } else if (converter === "job") {
1743
- workflows.push(this.convertJobHandler(entity.ref.pluginId, entity.declaration, root));
1949
+ workflows.push(this.converter.convertJobHandler(entity.ref.pluginId, entity.declaration, root));
1744
1950
  } else {
1745
- workflows.push(this.convertCronSchedule(entity.ref.pluginId, entity.declaration, root));
1951
+ workflows.push(this.converter.convertCronSchedule(entity.ref.pluginId, entity.declaration, root));
1746
1952
  }
1747
1953
  } catch (err) {
1748
1954
  this.platform.logger?.warn("ManifestScanner: Failed to convert entity", {
@@ -1781,91 +1987,10 @@ var ManifestScanner = class {
1781
1987
  templates: templatesCount
1782
1988
  });
1783
1989
  if (this.platform.cache) {
1784
- await this.platform.cache.set(cacheKey, workflows, this.cacheTtlMs);
1990
+ await this.platform.cache.set(CACHE_KEY_WORKFLOWS, workflows, this.cacheTtlMs);
1785
1991
  }
1786
1992
  return workflows;
1787
1993
  }
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
1994
  /**
1870
1995
  * Scan all installed plugins for job handlers only.
1871
1996
  *
@@ -1895,7 +2020,7 @@ var ManifestScanner = class {
1895
2020
  */
1896
2021
  async clearCache() {
1897
2022
  if (this.platform.cache) {
1898
- await this.platform.cache.delete("manifest-scanner:workflows");
2023
+ await this.platform.cache.delete(CACHE_KEY_WORKFLOWS);
1899
2024
  this.platform.logger?.debug("ManifestScanner: Cache cleared");
1900
2025
  }
1901
2026
  }
@@ -2007,7 +2132,10 @@ var WorkflowRepository = class {
2007
2132
  * Delete workflow.
2008
2133
  */
2009
2134
  async delete(id) {
2010
- const path = this.getWorkflowPath(id);
2135
+ const path = await this.getWorkflowPath(id);
2136
+ if (!path) {
2137
+ throw new Error(`Invalid workflow ID for deletion: ${id}`);
2138
+ }
2011
2139
  try {
2012
2140
  await this.platform.storage.delete(path);
2013
2141
  this.platform.logger?.info("WorkflowRepository: Deleted workflow", { id });
@@ -2078,21 +2206,22 @@ var WorkflowRepository = class {
2078
2206
  status
2079
2207
  });
2080
2208
  }
2081
- getWorkflowPath(id) {
2209
+ async getWorkflowPath(id) {
2210
+ if (id.includes("/") || id.includes("\\") || id.includes("..") || id.startsWith(".")) {
2211
+ return null;
2212
+ }
2082
2213
  const ymlPath = join(this.absoluteStorageDir, `${id}.yml`);
2083
2214
  const yamlPath = join(this.absoluteStorageDir, `${id}.yaml`);
2084
- if (existsSync(ymlPath)) {
2085
- return ymlPath;
2086
- }
2087
- return yamlPath;
2215
+ return access(ymlPath).then(() => ymlPath).catch(() => yamlPath);
2088
2216
  }
2089
2217
  async saveWorkflow(id, workflow) {
2090
- const path = this.getWorkflowPath(id);
2218
+ const path = await this.getWorkflowPath(id);
2219
+ if (!path) {
2220
+ throw new Error(`Invalid workflow ID for storage: ${id}`);
2221
+ }
2091
2222
  const yaml = stringify(workflow, { indent: 2 });
2092
2223
  try {
2093
- if (!existsSync(this.absoluteStorageDir)) {
2094
- await mkdir(this.absoluteStorageDir, { recursive: true });
2095
- }
2224
+ await mkdir(this.absoluteStorageDir, { recursive: true });
2096
2225
  await writeFile(path, yaml, "utf-8");
2097
2226
  } catch (error) {
2098
2227
  this.platform.logger?.error(
@@ -2104,17 +2233,17 @@ var WorkflowRepository = class {
2104
2233
  }
2105
2234
  }
2106
2235
  async loadWorkflow(id) {
2107
- const path = this.getWorkflowPath(id);
2236
+ const path = await this.getWorkflowPath(id);
2237
+ if (!path) {
2238
+ return null;
2239
+ }
2108
2240
  try {
2109
- if (!existsSync(path)) {
2110
- return null;
2111
- }
2112
2241
  const content = await readFile(path, "utf-8");
2113
2242
  const parsed = parse(content);
2114
2243
  if (parsed.id && parsed.spec && parsed.createdAt) {
2115
- return parsed;
2244
+ return { ...parsed, id };
2116
2245
  }
2117
- const spec = {
2246
+ const candidateSpec = {
2118
2247
  name: parsed.name,
2119
2248
  version: parsed.version || "1.0.0",
2120
2249
  description: parsed.description,
@@ -2125,8 +2254,20 @@ var WorkflowRepository = class {
2125
2254
  env: parsed.env,
2126
2255
  secrets: parsed.secrets
2127
2256
  };
2257
+ const validation = WorkflowSpecSchema.safeParse(candidateSpec);
2258
+ if (!validation.success) {
2259
+ this.platform.logger?.warn(
2260
+ `WorkflowRepository: skipping invalid workflow "${id}" \u2014 ${validation.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`,
2261
+ { id, path }
2262
+ );
2263
+ return null;
2264
+ }
2265
+ const spec = validation.data;
2128
2266
  const stored = {
2129
- id: parsed.id || id,
2267
+ // The filename is the canonical id for file-based workflows. Using the
2268
+ // inner `id:` here made list() report an id that get()/getWorkflowPath()
2269
+ // (which resolve by filename) could not find → POST /runs 404 (B-016).
2270
+ id,
2130
2271
  spec,
2131
2272
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2132
2273
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2147,9 +2288,6 @@ var WorkflowRepository = class {
2147
2288
  }
2148
2289
  async listWorkflowFiles() {
2149
2290
  try {
2150
- if (!existsSync(this.absoluteStorageDir)) {
2151
- return [];
2152
- }
2153
2291
  const files = await readdir(this.absoluteStorageDir);
2154
2292
  return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
2155
2293
  } catch (error) {
@@ -2212,7 +2350,9 @@ var WorkflowRepository = class {
2212
2350
  // Store full spec for execution
2213
2351
  input: spec,
2214
2352
  // Expose declared input schema for REST API / Studio UI
2215
- inputSchema: spec.inputs
2353
+ inputSchema: spec.inputs,
2354
+ version: spec.version,
2355
+ updatedAt: stored.updatedAt
2216
2356
  };
2217
2357
  }
2218
2358
  };
@@ -2265,15 +2405,30 @@ var WorkflowService = class {
2265
2405
  }
2266
2406
  /**
2267
2407
  * Get workflow by ID (from either source).
2408
+ *
2409
+ * Lookup order:
2410
+ * 1. Standalone by filename-derived id (canonical, fast path)
2411
+ * 2. Standalone by `name:` field — allows `--workflow-id=dev-cycle` even
2412
+ * when the file is named `03-dev-cycle.yml` (F8 ergonomics fix)
2413
+ * 3. Manifest-based by id
2414
+ * 4. Manifest-based by name field
2268
2415
  */
2269
2416
  async get(id) {
2270
2417
  const standalone = await this.repository.get(id);
2271
2418
  if (standalone) {
2272
2419
  return standalone;
2273
2420
  }
2421
+ const allStandalone = await this.repository.list();
2422
+ const byName = allStandalone.find((w) => w.name === id);
2423
+ if (byName) {
2424
+ return byName;
2425
+ }
2274
2426
  const manifestWorkflows = await this.scanner.scanPlugins();
2275
2427
  const manifest = manifestWorkflows.find((w) => w.id === id);
2276
- return manifest ?? null;
2428
+ if (manifest) {
2429
+ return manifest;
2430
+ }
2431
+ return manifestWorkflows.find((w) => w.name === id) ?? null;
2277
2432
  }
2278
2433
  /**
2279
2434
  * Create standalone workflow.
@@ -2417,163 +2572,6 @@ var WorkflowService = class {
2417
2572
  this.platform.logger?.info("WorkflowService: Manifest cache cleared");
2418
2573
  }
2419
2574
  };
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
2575
  var WorkflowRegistry = class {
2578
2576
  constructor(options) {
2579
2577
  this.options = options;
@@ -3121,6 +3119,6 @@ var JobManager = class {
3121
3119
  }
3122
3120
  };
3123
3121
 
3124
- export { ArtifactMerger, ConcurrencyManager, EnvSecretProvider, EventBusBridge, JobManager, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowScheduleManager, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
3122
+ export { ArtifactMerger, ConcurrencyManager, EnvSecretProvider, EventBusBridge, JobManager, ManifestConverter, ManifestScanner, RunCoordinator, RunSnapshotStorage, Scheduler, StateStore, WorkflowEngine, WorkflowLoader, WorkflowRegistry, WorkflowRepository, WorkflowService, calculateBackoff, createDefaultSecretProvider, shouldRetry };
3125
3123
  //# sourceMappingURL=index.js.map
3126
3124
  //# sourceMappingURL=index.js.map