@crewx/workflow 0.3.22-rc.12 → 0.3.22-rc.121

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.
@@ -33,13 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RunManager = exports.SKILL_TASK_DEFAULT_TIMEOUT = void 0;
36
+ exports.RunManager = exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = exports.WORKFLOW_RUN_INTERRUPTED_ERROR = exports.SKILL_TASK_DEFAULT_TIMEOUT = exports.FileLock = void 0;
37
37
  exports.parseSetArgs = parseSetArgs;
38
38
  exports.readStdin = readStdin;
39
39
  exports.interpolateTemplate = interpolateTemplate;
40
40
  exports.safeEvaluate = safeEvaluate;
41
41
  exports.safeEvaluateBranchCondition = safeEvaluateBranchCondition;
42
42
  exports.validateSafeBranchCondition = validateSafeBranchCondition;
43
+ exports.isRetryableFailure = isRetryableFailure;
43
44
  const fs = __importStar(require("fs"));
44
45
  const path = __importStar(require("path"));
45
46
  const yaml = __importStar(require("js-yaml"));
@@ -48,11 +49,15 @@ const sdk_1 = require("@crewx/sdk");
48
49
  const file_lock_1 = require("./file-lock");
49
50
  const output_format_1 = require("./utils/output-format");
50
51
  const shell_security_1 = require("./utils/shell-security");
52
+ var file_lock_2 = require("./file-lock");
53
+ Object.defineProperty(exports, "FileLock", { enumerable: true, get: function () { return file_lock_2.FileLock; } });
51
54
  exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
55
+ exports.WORKFLOW_RUN_INTERRUPTED_ERROR = 'Workflow run interrupted: runner process exited before completion';
56
+ exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = 10 * 60000;
52
57
  const KNOWN_AUTO_NODE_TYPES = new Set([
53
- 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end',
58
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
54
59
  ]);
55
- const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end']);
60
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end', 'error']);
56
61
  function killProcessGroup(child) {
57
62
  try {
58
63
  if (process.platform === 'win32') {
@@ -81,7 +86,7 @@ async function runProcess(command, argv, opts) {
81
86
  child = cp.spawn(command, argv, {
82
87
  cwd: opts.cwd,
83
88
  env: opts.env,
84
- stdio: ['ignore', 'pipe', 'pipe'],
89
+ stdio: [opts.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
85
90
  shell: opts.shell ?? false,
86
91
  detached: process.platform !== 'win32',
87
92
  windowsHide: opts.windowsHide ?? true,
@@ -109,6 +114,9 @@ async function runProcess(command, argv, opts) {
109
114
  timer.unref();
110
115
  child.stdout?.on('data', (d) => { stdout += d.toString(); });
111
116
  child.stderr?.on('data', (d) => { stderr += d.toString(); });
117
+ if (opts.stdin !== undefined) {
118
+ child.stdin?.end(opts.stdin);
119
+ }
112
120
  child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
113
121
  child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
114
122
  });
@@ -521,12 +529,62 @@ function safeEvaluateBranchCondition(expr, state) {
521
529
  function validateSafeBranchCondition(expr) {
522
530
  safeEvaluateBranchCondition(expr, {});
523
531
  }
532
+ function readRunEngineState(run) {
533
+ const raw = run.state['__engine'];
534
+ if (!raw || typeof raw !== 'object')
535
+ return { visitCounts: {}, iterations: 0 };
536
+ const record = raw;
537
+ const rawCounts = record['visitCounts'];
538
+ const visitCounts = {};
539
+ if (rawCounts && typeof rawCounts === 'object') {
540
+ for (const [key, value] of Object.entries(rawCounts)) {
541
+ if (typeof value === 'number' && Number.isFinite(value))
542
+ visitCounts[key] = value;
543
+ }
544
+ }
545
+ const iterations = typeof record['iterations'] === 'number' && Number.isFinite(record['iterations'])
546
+ ? record['iterations']
547
+ : 0;
548
+ return { visitCounts, iterations };
549
+ }
550
+ const inFlightAutoRuns = new Set();
551
+ function isProcessAlive(pid) {
552
+ if (!Number.isInteger(pid) || pid <= 0)
553
+ return false;
554
+ if (pid === process.pid)
555
+ return true;
556
+ try {
557
+ process.kill(pid, 0);
558
+ return true;
559
+ }
560
+ catch (error) {
561
+ return error.code === 'EPERM';
562
+ }
563
+ }
564
+ function isTerminalStatus(status) {
565
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
566
+ }
567
+ function isRetryableFailure(run, spec) {
568
+ if (run.status !== 'failed')
569
+ return false;
570
+ if (!run.current_node)
571
+ return false;
572
+ const node = spec.nodes?.[run.current_node];
573
+ if (!node)
574
+ return false;
575
+ return node.type !== 'error';
576
+ }
524
577
  class RunManager {
525
578
  constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
526
579
  this.runsDir = runsDir;
527
580
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
528
581
  this.resolvedDir = path.resolve(base, this.runsDir);
529
582
  this.projectRoot = path.resolve(base);
583
+ const hasConfig = fs.existsSync(path.join(this.projectRoot, 'crewx.yaml'))
584
+ || fs.existsSync(path.join(this.projectRoot, 'crewx.yml'));
585
+ if (!hasConfig) {
586
+ console.warn(`[RunManager] projectRoot "${this.projectRoot}" has no crewx.yaml/crewx.yml — agent_task nodes may fail to resolve agents.`);
587
+ }
530
588
  }
531
589
  ensureRunsDir() {
532
590
  if (!fs.existsSync(this.resolvedDir)) {
@@ -538,8 +596,7 @@ class RunManager {
538
596
  throw new Error(`Invalid execution ID: "${execId}"`);
539
597
  }
540
598
  }
541
- loadRun(execId) {
542
- this.validateExecId(execId);
599
+ readRun(execId) {
543
600
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
544
601
  if (!fs.existsSync(filePath))
545
602
  return null;
@@ -550,6 +607,78 @@ class RunManager {
550
607
  throw new Error(`Failed to parse execution file ${filePath}: ${e.message}`);
551
608
  }
552
609
  }
610
+ isLegacyRetryStale(run) {
611
+ if (run.status !== undefined
612
+ || run.auto_run
613
+ || run.manual_resume
614
+ || !run.current_node) {
615
+ return false;
616
+ }
617
+ const lastAudit = run.audit?.at(-1);
618
+ if (lastAudit?.type !== 'retry')
619
+ return false;
620
+ const updatedAt = Date.parse(run.updated_at);
621
+ if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS) {
622
+ return false;
623
+ }
624
+ const doc = this.loadWorkflowYaml(run.workflow_file);
625
+ const spec = doc?.workflows[run.workflow_id];
626
+ const node = spec?.nodes?.[run.current_node];
627
+ return Boolean(node && KNOWN_AUTO_NODE_TYPES.has(node.type));
628
+ }
629
+ hasStaleRunner(run) {
630
+ if (run.auto_run)
631
+ return !isProcessAlive(run.auto_run.pid);
632
+ return this.isLegacyRetryStale(run);
633
+ }
634
+ recoverStaleRunner(execId) {
635
+ const filePath = path.join(this.resolvedDir, `${execId}.json`);
636
+ const lock = new file_lock_1.FileLock(filePath);
637
+ lock.acquire();
638
+ try {
639
+ const run = this.readRun(execId);
640
+ if (!run || !this.hasStaleRunner(run))
641
+ return run;
642
+ if (isTerminalStatus(run.status)) {
643
+ delete run.auto_run;
644
+ delete run.manual_resume;
645
+ this.saveRun(run);
646
+ return run;
647
+ }
648
+ const lease = run.auto_run;
649
+ const nodeId = lease?.node_id || run.current_node;
650
+ const now = new Date().toISOString();
651
+ run.status = 'failed';
652
+ run.error = exports.WORKFLOW_RUN_INTERRUPTED_ERROR;
653
+ delete run.auto_run;
654
+ delete run.manual_resume;
655
+ run.audit = run.audit ?? [];
656
+ run.audit.push({
657
+ exec_id: run.id,
658
+ node_id: nodeId,
659
+ type: 'interrupted',
660
+ summary: exports.WORKFLOW_RUN_INTERRUPTED_ERROR,
661
+ started_at: now,
662
+ ended_at: now,
663
+ duration_ms: 0,
664
+ exit_code: null,
665
+ error: exports.WORKFLOW_RUN_INTERRUPTED_ERROR,
666
+ trigger: 'auto',
667
+ });
668
+ this.saveRun(run);
669
+ return run;
670
+ }
671
+ finally {
672
+ lock.release();
673
+ }
674
+ }
675
+ loadRun(execId) {
676
+ this.validateExecId(execId);
677
+ const run = this.readRun(execId);
678
+ if (!run || !this.hasStaleRunner(run))
679
+ return run;
680
+ return this.recoverStaleRunner(execId);
681
+ }
553
682
  saveRun(run) {
554
683
  this.ensureRunsDir();
555
684
  run.updated_at = new Date().toISOString();
@@ -563,7 +692,7 @@ class RunManager {
563
692
  const lock = new file_lock_1.FileLock(filePath);
564
693
  lock.acquire();
565
694
  try {
566
- const run = this.loadRun(execId);
695
+ const run = this.readRun(execId);
567
696
  if (!run)
568
697
  throw new Error(`Execution not found: ${execId}`);
569
698
  updater(run);
@@ -581,7 +710,7 @@ class RunManager {
581
710
  const lock = new file_lock_1.FileLock(filePath);
582
711
  await lock.acquireAsync();
583
712
  try {
584
- const run = this.loadRun(execId);
713
+ const run = this.readRun(execId);
585
714
  if (!run)
586
715
  throw new Error(`Execution not found: ${execId}`);
587
716
  updater(run);
@@ -601,6 +730,12 @@ class RunManager {
601
730
  return null;
602
731
  return doc;
603
732
  }
733
+ getNodeType(workflowFile, workflowId, nodeId) {
734
+ const doc = this.loadWorkflowYaml(workflowFile);
735
+ const spec = doc?.workflows[workflowId];
736
+ const node = spec?.nodes?.[nodeId];
737
+ return typeof node?.type === 'string' ? node.type : null;
738
+ }
604
739
  start(yamlPath, workflowId, overrides) {
605
740
  const doc = this.loadWorkflowYaml(yamlPath);
606
741
  if (!doc) {
@@ -684,6 +819,7 @@ class RunManager {
684
819
  const nodesToComplete = [];
685
820
  let currentNode = nodeId;
686
821
  let status;
822
+ let failureMessage;
687
823
  let nodeTaskId;
688
824
  const trigger = opts?.trigger ?? 'manual';
689
825
  const startedAt = new Date();
@@ -711,19 +847,39 @@ class RunManager {
711
847
  if (!nodeSpec.agent) {
712
848
  throw new Error(`Node "${nodeId}" is agent_task but has no agent defined`);
713
849
  }
850
+ const agentCtx = { ...snapshot.state, state: snapshot.state };
851
+ const resolvedAgent = interpolateTemplate(nodeSpec.agent, agentCtx).trim();
852
+ if (!resolvedAgent) {
853
+ throw new Error(`Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
854
+ }
855
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, agentCtx) : undefined, this.projectRoot);
856
+ if (!cwdCheck.ok)
857
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
858
+ const nodeCwd = cwdCheck.resolved;
859
+ const rootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
860
+ if (!rootCheck.ok)
861
+ throw new Error(`Node "${nodeId}": ${rootCheck.error}`);
862
+ const workspaceRoot = rootCheck.resolved;
714
863
  const input = nodeSpec.input
715
- ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
864
+ ? interpolateTemplate(nodeSpec.input, agentCtx)
716
865
  : '';
866
+ const preamble = process.env.CREWX_WORKFLOW_PREAMBLE === 'off'
867
+ ? ''
868
+ : (0, output_format_1.buildWorkflowPreamble)({
869
+ workflowId: snapshot.workflow_id,
870
+ nodeId,
871
+ execId: snapshot.id,
872
+ });
717
873
  const finalInput = nodeSpec.output_format === 'json'
718
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
719
- : input;
874
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
875
+ : preamble + input;
720
876
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
721
- const prompt = `@${nodeSpec.agent} ${finalInput}`;
877
+ const prompt = `@${resolvedAgent} ${finalInput}`;
722
878
  if (opts?.dryRun) {
723
- console.log(`[dry-run] Would execute: crewx ${mode} "@${nodeSpec.agent} ..."`);
879
+ console.log(`[dry-run] Would execute: crewx ${mode} "@${resolvedAgent} ..."`);
724
880
  break;
725
881
  }
726
- console.log(`Executing: @${nodeSpec.agent} (${mode} mode)`);
882
+ console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
727
883
  const crewxCli = process.env.CREWX_CLI || 'npx crewx';
728
884
  const cliParts = crewxCli.split(/\s+/);
729
885
  const bin = cliParts[0];
@@ -748,9 +904,10 @@ class RunManager {
748
904
  });
749
905
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
750
906
  const result = await runProcess(invocation.command, invocation.argv, {
751
- cwd: process.cwd(),
907
+ cwd: nodeCwd,
752
908
  env: {
753
909
  ...process.env,
910
+ CREWX_WORKSPACE: workspaceRoot,
754
911
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
755
912
  CREWX_WORKFLOW_NODE_ID: nodeId,
756
913
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
@@ -775,6 +932,10 @@ class RunManager {
775
932
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
776
933
  }
777
934
  const output = result.stdout.trim();
935
+ const signal = (0, output_format_1.detectWorkflowSignal)(output);
936
+ if (signal) {
937
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${signal.reason ?? '(no reason given)'}`);
938
+ }
778
939
  if (nodeSpec.output) {
779
940
  if (nodeSpec.output_format === 'json') {
780
941
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -787,7 +948,7 @@ class RunManager {
787
948
  retryInput = finalInput +
788
949
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
789
950
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
790
- const retryPrompt = `@${nodeSpec.agent} ${retryInput}`;
951
+ const retryPrompt = `@${resolvedAgent} ${retryInput}`;
791
952
  const retryArgs = [...baseArgs, mode, retryPrompt];
792
953
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
793
954
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
@@ -795,9 +956,10 @@ class RunManager {
795
956
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
796
957
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
797
958
  const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
798
- cwd: process.cwd(),
959
+ cwd: nodeCwd,
799
960
  env: {
800
961
  ...process.env,
962
+ CREWX_WORKSPACE: workspaceRoot,
801
963
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
802
964
  CREWX_WORKFLOW_NODE_ID: nodeId,
803
965
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
@@ -817,6 +979,10 @@ class RunManager {
817
979
  throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
818
980
  }
819
981
  attemptOutput = retryResult.stdout.trim();
982
+ const retrySignal = (0, output_format_1.detectWorkflowSignal)(attemptOutput);
983
+ if (retrySignal) {
984
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${retrySignal.reason ?? '(no reason given)'}`);
985
+ }
820
986
  }
821
987
  try {
822
988
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -851,9 +1017,16 @@ class RunManager {
851
1017
  }
852
1018
  const ctx = { ...snapshot.state, state: snapshot.state };
853
1019
  const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
1020
+ const skillStdin = nodeSpec.stdin !== undefined
1021
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1022
+ : undefined;
854
1023
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
855
1024
  if (!cwdCheck.ok)
856
1025
  throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1026
+ const skillRootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1027
+ if (!skillRootCheck.ok)
1028
+ throw new Error(`Node "${nodeId}": ${skillRootCheck.error}`);
1029
+ const skillWorkspaceRoot = skillRootCheck.resolved;
857
1030
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
858
1031
  if (opts?.dryRun) {
859
1032
  console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
@@ -875,6 +1048,7 @@ class RunManager {
875
1048
  cwd: cwdCheck.resolved,
876
1049
  env: {
877
1050
  ...process.env,
1051
+ CREWX_WORKSPACE: skillWorkspaceRoot,
878
1052
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
879
1053
  CREWX_WORKFLOW_NODE_ID: nodeId,
880
1054
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
@@ -882,6 +1056,7 @@ class RunManager {
882
1056
  timeout,
883
1057
  shell: invocation.shell ?? false,
884
1058
  windowsHide: invocation.windowsHide,
1059
+ stdin: skillStdin,
885
1060
  });
886
1061
  nodeStdout = result.stdout;
887
1062
  nodeStderr = result.stderr;
@@ -932,11 +1107,15 @@ class RunManager {
932
1107
  childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
933
1108
  childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
934
1109
  childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
1110
+ const shellStdin = nodeSpec.stdin !== undefined
1111
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1112
+ : undefined;
935
1113
  const result = await runProcess(command[0], command.slice(1), {
936
1114
  cwd: cwdCheck.resolved,
937
1115
  env: childEnv,
938
1116
  timeout,
939
1117
  shell: false,
1118
+ stdin: shellStdin,
940
1119
  });
941
1120
  nodeStdout = result.stdout;
942
1121
  nodeStderr = result.stderr;
@@ -986,6 +1165,15 @@ class RunManager {
986
1165
  console.log(`End "${nodeId}": workflow execution completed`);
987
1166
  break;
988
1167
  }
1168
+ case 'error': {
1169
+ status = 'failed';
1170
+ const ctx = { ...snapshot.state, state: snapshot.state };
1171
+ failureMessage = nodeSpec.message
1172
+ ? interpolateTemplate(nodeSpec.message, ctx)
1173
+ : `Workflow failed at error node "${nodeId}"`;
1174
+ console.log(`Error "${nodeId}": workflow execution failed — ${failureMessage}`);
1175
+ break;
1176
+ }
989
1177
  case 'expression': {
990
1178
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
991
1179
  throw new Error(`Expression node "${nodeId}" requires "set" field`);
@@ -1009,6 +1197,14 @@ class RunManager {
1009
1197
  default:
1010
1198
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
1011
1199
  }
1200
+ if (nodeSpec.fail_when &&
1201
+ !opts?.dryRun &&
1202
+ (nodeSpec.type === 'agent_task' || nodeSpec.type === 'skill_task' || nodeSpec.type === 'shell_task')) {
1203
+ const judged = safeEvaluateBranchCondition(String(nodeSpec.fail_when), { ...snapshot.state, ...stateUpdates });
1204
+ if (judged === true || judged === 'true') {
1205
+ throw new Error(`Node "${nodeId}" judged failed by fail_when: ${nodeSpec.fail_when}`);
1206
+ }
1207
+ }
1012
1208
  }
1013
1209
  catch (e) {
1014
1210
  const errMessage = e.message;
@@ -1020,6 +1216,8 @@ class RunManager {
1020
1216
  await this.atomicUpdateAsync(execId, (run) => {
1021
1217
  if (run.status === 'cancelled')
1022
1218
  return;
1219
+ Object.assign(run.state, stateUpdates);
1220
+ delete run.manual_resume;
1023
1221
  run.current_node = nodeId;
1024
1222
  run.status = 'failed';
1025
1223
  run.error = errMessage;
@@ -1033,6 +1231,7 @@ class RunManager {
1033
1231
  if (run.status === 'cancelled')
1034
1232
  return;
1035
1233
  Object.assign(run.state, stateUpdates);
1234
+ delete run.manual_resume;
1036
1235
  for (const node of nodesToComplete) {
1037
1236
  if (!run.completed_nodes.includes(node)) {
1038
1237
  run.completed_nodes.push(node);
@@ -1042,8 +1241,18 @@ class RunManager {
1042
1241
  run.completed_nodes.push(prevNode);
1043
1242
  }
1044
1243
  run.current_node = currentNode;
1045
- if (status)
1244
+ if (status) {
1046
1245
  run.status = status;
1246
+ if (status !== 'failed') {
1247
+ delete run.error;
1248
+ }
1249
+ }
1250
+ else if (run.status === 'failed') {
1251
+ run.status = 'running';
1252
+ delete run.error;
1253
+ }
1254
+ if (failureMessage)
1255
+ run.error = failureMessage;
1047
1256
  if (nodeTaskId) {
1048
1257
  run.tasks = run.tasks ?? {};
1049
1258
  run.tasks[nodeId] = nodeTaskId;
@@ -1052,92 +1261,155 @@ class RunManager {
1052
1261
  run.audit.push(successAudit);
1053
1262
  });
1054
1263
  }
1055
- async runAuto(execId) {
1056
- const initial = this.loadRun(execId);
1057
- if (!initial)
1058
- throw new Error(`Execution not found: ${execId}`);
1059
- const doc = this.loadWorkflowYaml(initial.workflow_file);
1060
- if (!doc)
1061
- throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1062
- const spec = doc.workflows[initial.workflow_id];
1063
- if (!spec) {
1064
- throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1065
- }
1066
- const executed = [];
1067
- if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1068
- return {
1069
- run: initial,
1070
- outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1071
- reason: `run already ${initial.status}`,
1072
- executed,
1264
+ async claimAutoRunLease(execId, nodeId, preferredToken) {
1265
+ const updated = await this.atomicUpdateAsync(execId, (run) => {
1266
+ const existing = run.auto_run;
1267
+ const ownedByThisProcess = existing?.pid === process.pid && typeof existing.token === 'string';
1268
+ if (existing && !ownedByThisProcess && isProcessAlive(existing.pid)) {
1269
+ throw new Error(`Workflow run "${execId}" is already owned by another runner process`);
1270
+ }
1271
+ const token = preferredToken ?? (ownedByThisProcess ? existing.token : (0, sdk_1.generateId)('wfr'));
1272
+ run.auto_run = {
1273
+ pid: process.pid,
1274
+ token,
1275
+ node_id: nodeId,
1276
+ started_at: ownedByThisProcess && existing.token === token
1277
+ ? existing.started_at
1278
+ : new Date().toISOString(),
1073
1279
  };
1280
+ delete run.manual_resume;
1281
+ });
1282
+ return updated.auto_run.token;
1283
+ }
1284
+ async releaseAutoRunLease(execId, token) {
1285
+ await this.atomicUpdateAsync(execId, (run) => {
1286
+ if (run.auto_run?.token === token)
1287
+ delete run.auto_run;
1288
+ });
1289
+ }
1290
+ async runAuto(execId) {
1291
+ const leaseKey = `${this.resolvedDir}::${execId}`;
1292
+ if (inFlightAutoRuns.has(leaseKey)) {
1293
+ const run = this.loadRun(execId);
1294
+ if (!run)
1295
+ throw new Error(`Execution not found: ${execId}`);
1296
+ console.log(`runAuto "${execId}": already in-flight — skipping duplicate auto-run (single-flight guard)`);
1297
+ return { run, outcome: 'paused', reason: 'execution already in-flight (single-flight guard)', executed: [] };
1074
1298
  }
1075
- const firstNode = Object.keys(spec.nodes ?? {})[0];
1076
- const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1077
- ? spec.max_iterations
1078
- : 30;
1079
- const visitCounts = {};
1080
- let iterations = 0;
1081
- let run = initial;
1082
- while (true) {
1083
- run = this.loadRun(execId);
1084
- if (run.status === 'completed')
1085
- return { run, outcome: 'completed', executed };
1086
- if (run.status === 'failed')
1087
- return { run, outcome: 'failed', reason: run.error, executed };
1088
- if (run.status === 'cancelled')
1089
- return { run, outcome: 'cancelled', reason: run.error, executed };
1090
- const nodeId = run.current_node || firstNode;
1091
- if (!nodeId)
1092
- return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
1093
- const node = spec.nodes[nodeId];
1094
- if (!node) {
1095
- run = await this.atomicUpdateAsync(execId, (r) => {
1096
- r.current_node = nodeId;
1097
- r.status = 'failed';
1098
- r.error = `node "${nodeId}" not found in workflow`;
1099
- });
1100
- return { run, outcome: 'failed', reason: run.error, executed };
1101
- }
1102
- if (node.type === 'approval') {
1103
- return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1299
+ inFlightAutoRuns.add(leaseKey);
1300
+ let runnerToken;
1301
+ try {
1302
+ const initial = this.loadRun(execId);
1303
+ if (!initial)
1304
+ throw new Error(`Execution not found: ${execId}`);
1305
+ if (initial.auto_run?.pid === process.pid) {
1306
+ runnerToken = initial.auto_run.token;
1104
1307
  }
1105
- if (node.type === 'parallel') {
1106
- return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1308
+ const doc = this.loadWorkflowYaml(initial.workflow_file);
1309
+ if (!doc)
1310
+ throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1311
+ const spec = doc.workflows[initial.workflow_id];
1312
+ if (!spec) {
1313
+ throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1107
1314
  }
1108
- if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1109
- return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1315
+ const executed = [];
1316
+ if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1317
+ return {
1318
+ run: initial,
1319
+ outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1320
+ reason: `run already ${initial.status}`,
1321
+ executed,
1322
+ };
1110
1323
  }
1111
- visitCounts[nodeId] = (visitCounts[nodeId] ?? 0) + 1;
1112
- iterations++;
1113
- if (visitCounts[nodeId] > maxIterations || iterations > maxIterations) {
1324
+ const firstNode = Object.keys(spec.nodes ?? {})[0];
1325
+ const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1326
+ ? spec.max_iterations
1327
+ : 30;
1328
+ let run = initial;
1329
+ while (true) {
1330
+ run = this.loadRun(execId);
1331
+ if (run.status === 'completed')
1332
+ return { run, outcome: 'completed', executed };
1333
+ if (run.status === 'failed')
1334
+ return { run, outcome: 'failed', reason: run.error, executed };
1335
+ if (run.status === 'cancelled')
1336
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1337
+ const nodeId = run.current_node || firstNode;
1338
+ if (!nodeId)
1339
+ return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
1340
+ const node = spec.nodes[nodeId];
1341
+ if (!node) {
1342
+ run = await this.atomicUpdateAsync(execId, (r) => {
1343
+ r.current_node = nodeId;
1344
+ r.status = 'failed';
1345
+ r.error = `node "${nodeId}" not found in workflow`;
1346
+ });
1347
+ return { run, outcome: 'failed', reason: run.error, executed };
1348
+ }
1349
+ if (node.type === 'approval') {
1350
+ if (run.current_node !== nodeId) {
1351
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1352
+ }
1353
+ return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1354
+ }
1355
+ if (node.type === 'parallel') {
1356
+ if (run.current_node !== nodeId) {
1357
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1358
+ }
1359
+ return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1360
+ }
1361
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1362
+ if (run.current_node !== nodeId) {
1363
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1364
+ }
1365
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1366
+ }
1367
+ runnerToken = await this.claimAutoRunLease(execId, nodeId, runnerToken);
1114
1368
  run = await this.atomicUpdateAsync(execId, (r) => {
1115
- r.status = 'failed';
1116
- r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1369
+ const engine = readRunEngineState(r);
1370
+ engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1371
+ engine.iterations += 1;
1372
+ r.state['__engine'] = engine;
1373
+ if (engine.visitCounts[nodeId] > maxIterations || engine.iterations > maxIterations) {
1374
+ r.status = 'failed';
1375
+ r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1376
+ }
1117
1377
  });
1118
- return { run, outcome: 'failed', reason: run.error, executed };
1119
- }
1120
- try {
1121
- run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1122
- executed.push(nodeId);
1123
- }
1124
- catch (e) {
1125
- run = this.loadRun(execId);
1126
- return { run, outcome: 'failed', reason: e.message, executed };
1378
+ if (run.status === 'failed') {
1379
+ return { run, outcome: 'failed', reason: run.error, executed };
1380
+ }
1381
+ try {
1382
+ run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1383
+ executed.push(nodeId);
1384
+ }
1385
+ catch (e) {
1386
+ run = this.loadRun(execId);
1387
+ return { run, outcome: 'failed', reason: e.message, executed };
1388
+ }
1389
+ if (run.status === 'completed')
1390
+ return { run, outcome: 'completed', executed };
1391
+ if (run.status === 'failed')
1392
+ return { run, outcome: 'failed', reason: run.error, executed };
1393
+ if (run.status === 'cancelled')
1394
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1395
+ if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1396
+ const next = node.next;
1397
+ if (!next) {
1398
+ return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
1399
+ }
1400
+ run = this.moveNode(execId, next);
1401
+ }
1127
1402
  }
1128
- if (run.status === 'completed')
1129
- return { run, outcome: 'completed', executed };
1130
- if (run.status === 'failed')
1131
- return { run, outcome: 'failed', reason: run.error, executed };
1132
- if (run.status === 'cancelled')
1133
- return { run, outcome: 'cancelled', reason: run.error, executed };
1134
- if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1135
- const next = node.next;
1136
- if (!next) {
1137
- return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
1403
+ }
1404
+ finally {
1405
+ if (runnerToken) {
1406
+ try {
1407
+ await this.releaseAutoRunLease(execId, runnerToken);
1408
+ }
1409
+ catch {
1138
1410
  }
1139
- run = this.moveNode(execId, next);
1140
1411
  }
1412
+ inFlightAutoRuns.delete(leaseKey);
1141
1413
  }
1142
1414
  }
1143
1415
  cancelRun(execId, reason = 'Cancelled by user') {
@@ -1148,6 +1420,8 @@ class RunManager {
1148
1420
  const now = new Date();
1149
1421
  run.status = 'cancelled';
1150
1422
  run.error = reason;
1423
+ delete run.auto_run;
1424
+ delete run.manual_resume;
1151
1425
  run.audit = run.audit ?? [];
1152
1426
  run.audit.push({
1153
1427
  exec_id: run.id,
@@ -1168,6 +1442,57 @@ class RunManager {
1168
1442
  run.state = JSON.parse(JSON.stringify(run.initial_state));
1169
1443
  run.current_node = '';
1170
1444
  run.completed_nodes = [];
1445
+ delete run.auto_run;
1446
+ delete run.manual_resume;
1447
+ });
1448
+ }
1449
+ retry(execId, options = {}) {
1450
+ const snapshot = this.loadRun(execId);
1451
+ if (!snapshot)
1452
+ throw new Error(`Execution not found: ${execId}`);
1453
+ const doc = this.loadWorkflowYaml(snapshot.workflow_file);
1454
+ if (!doc)
1455
+ throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
1456
+ const spec = doc.workflows[snapshot.workflow_id];
1457
+ if (!spec)
1458
+ throw new Error(`Workflow "${snapshot.workflow_id}" not found`);
1459
+ if (snapshot.status !== 'failed') {
1460
+ throw new Error(`Run "${execId}" is not failed (status=${snapshot.status ?? 'running'}); only failed runs can be retried`);
1461
+ }
1462
+ if (!isRetryableFailure(snapshot, spec)) {
1463
+ throw new Error(`Run "${execId}" failed at a non-retryable node "${snapshot.current_node}" (error node); use reset to restart`);
1464
+ }
1465
+ return this.atomicUpdate(execId, (run) => {
1466
+ const failedNode = run.current_node;
1467
+ delete run.status;
1468
+ delete run.error;
1469
+ delete run.manual_resume;
1470
+ run.retry_count = (run.retry_count ?? 0) + 1;
1471
+ run.audit = run.audit ?? [];
1472
+ const now = new Date().toISOString();
1473
+ if (options.autoRun === false) {
1474
+ delete run.auto_run;
1475
+ run.manual_resume = { node_id: failedNode, requested_at: now };
1476
+ }
1477
+ else {
1478
+ run.auto_run = {
1479
+ pid: process.pid,
1480
+ token: (0, sdk_1.generateId)('wfr'),
1481
+ node_id: failedNode,
1482
+ started_at: now,
1483
+ };
1484
+ }
1485
+ run.audit.push({
1486
+ exec_id: run.id,
1487
+ node_id: failedNode,
1488
+ type: 'retry',
1489
+ summary: `manual retry #${run.retry_count} of node "${failedNode}"`,
1490
+ started_at: now,
1491
+ ended_at: now,
1492
+ duration_ms: 0,
1493
+ exit_code: null,
1494
+ trigger: 'manual',
1495
+ });
1171
1496
  });
1172
1497
  }
1173
1498
  listRuns(opts) {
@@ -1176,7 +1501,10 @@ class RunManager {
1176
1501
  const runs = [];
1177
1502
  for (const file of files) {
1178
1503
  try {
1179
- const run = JSON.parse(fs.readFileSync(path.join(this.resolvedDir, file), 'utf-8'));
1504
+ const execId = file.slice(0, -'.json'.length);
1505
+ const run = this.loadRun(execId);
1506
+ if (!run)
1507
+ continue;
1180
1508
  if (opts?.workflowId && run.workflow_id !== opts.workflowId)
1181
1509
  continue;
1182
1510
  runs.push(run);