@crewx/workflow 0.3.22-rc.9 → 0.3.22-rc.91

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,11 +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.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
+ exports.safeEvaluateBranchCondition = safeEvaluateBranchCondition;
42
+ exports.validateSafeBranchCondition = validateSafeBranchCondition;
43
+ exports.isRetryableFailure = isRetryableFailure;
41
44
  const fs = __importStar(require("fs"));
42
45
  const path = __importStar(require("path"));
43
46
  const yaml = __importStar(require("js-yaml"));
@@ -46,11 +49,13 @@ const sdk_1 = require("@crewx/sdk");
46
49
  const file_lock_1 = require("./file-lock");
47
50
  const output_format_1 = require("./utils/output-format");
48
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; } });
49
54
  exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
50
55
  const KNOWN_AUTO_NODE_TYPES = new Set([
51
- 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end',
56
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
52
57
  ]);
53
- const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end']);
58
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end', 'error']);
54
59
  function killProcessGroup(child) {
55
60
  try {
56
61
  if (process.platform === 'win32') {
@@ -79,7 +84,7 @@ async function runProcess(command, argv, opts) {
79
84
  child = cp.spawn(command, argv, {
80
85
  cwd: opts.cwd,
81
86
  env: opts.env,
82
- stdio: ['ignore', 'pipe', 'pipe'],
87
+ stdio: [opts.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
83
88
  shell: opts.shell ?? false,
84
89
  detached: process.platform !== 'win32',
85
90
  windowsHide: opts.windowsHide ?? true,
@@ -107,6 +112,9 @@ async function runProcess(command, argv, opts) {
107
112
  timer.unref();
108
113
  child.stdout?.on('data', (d) => { stdout += d.toString(); });
109
114
  child.stderr?.on('data', (d) => { stderr += d.toString(); });
115
+ if (opts.stdin !== undefined) {
116
+ child.stdin?.end(opts.stdin);
117
+ }
110
118
  child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
111
119
  child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
112
120
  });
@@ -210,6 +218,7 @@ const FORBIDDEN_IDENTS = new Set([
210
218
  'require', 'import', 'process', 'global', 'globalThis',
211
219
  'Function', 'eval', '__proto__', 'constructor', 'prototype',
212
220
  ]);
221
+ const FORBIDDEN_STATE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
213
222
  function tokenize(expr) {
214
223
  const tokens = [];
215
224
  let i = 0;
@@ -443,6 +452,8 @@ class ExprParser {
443
452
  const val = this.state[key];
444
453
  if (val === undefined || val === null)
445
454
  return 0;
455
+ if (typeof val === 'boolean' || typeof val === 'number')
456
+ return val;
446
457
  const strVal = String(val);
447
458
  if (strVal === '')
448
459
  return '';
@@ -465,12 +476,97 @@ function safeEvaluate(expr, state) {
465
476
  const parser = new ExprParser(tokens, state);
466
477
  return parser.parse();
467
478
  }
479
+ function getStatePathValue(state, pathExpr) {
480
+ const parts = pathExpr.split('.');
481
+ let value = state;
482
+ for (const part of parts) {
483
+ if (FORBIDDEN_STATE_PATH_SEGMENTS.has(part)) {
484
+ throw new Error(`Forbidden state path segment in expression: "${part}"`);
485
+ }
486
+ if (!part || value === null || value === undefined || typeof value !== 'object') {
487
+ return undefined;
488
+ }
489
+ value = value[part];
490
+ }
491
+ return value;
492
+ }
493
+ function unescapeQuotedLiteral(value) {
494
+ return value
495
+ .replace(/\\'/g, "'")
496
+ .replace(/\\"/g, '"')
497
+ .replace(/\\\\/g, '\\');
498
+ }
499
+ function normalizeBranchCondition(expr, state) {
500
+ const scope = {};
501
+ let counter = 0;
502
+ const bind = (value) => {
503
+ const key = `__state_${counter++}`;
504
+ scope[key] = value;
505
+ return key;
506
+ };
507
+ let normalized = expr
508
+ .replace(/!==/g, '!=')
509
+ .replace(/===/g, '==');
510
+ const replaceIncludes = (pattern) => {
511
+ normalized = normalized.replace(pattern, (_match, statePath, literal) => {
512
+ const value = getStatePathValue(state, statePath);
513
+ return bind(String(value ?? '').includes(unescapeQuotedLiteral(literal)));
514
+ });
515
+ };
516
+ replaceIncludes(/\bstate\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.includes\(\s*'((?:\\.|[^'\\])*)'\s*\)/g);
517
+ replaceIncludes(/\bstate\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.includes\(\s*"((?:\\.|[^"\\])*)"\s*\)/g);
518
+ normalized = normalized.replace(/\bstate\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)/g, (_match, statePath) => bind(getStatePathValue(state, statePath)));
519
+ normalized = normalized.replace(/\bNumber\(\s*([A-Za-z_]\w*)\s*\)/g, '$1');
520
+ normalized = normalized.replace(/\bparseInt\(\s*([A-Za-z_]\w*(?:\s*\|\|\s*(?:'[^']*'|"[^"]*"))?)\s*\)/g, '($1)');
521
+ return { expr: normalized, scope };
522
+ }
523
+ function safeEvaluateBranchCondition(expr, state) {
524
+ const normalized = normalizeBranchCondition(expr, state);
525
+ return safeEvaluate(normalized.expr, { ...state, ...normalized.scope });
526
+ }
527
+ function validateSafeBranchCondition(expr) {
528
+ safeEvaluateBranchCondition(expr, {});
529
+ }
530
+ function readRunEngineState(run) {
531
+ const raw = run.state['__engine'];
532
+ if (!raw || typeof raw !== 'object')
533
+ return { visitCounts: {}, iterations: 0 };
534
+ const record = raw;
535
+ const rawCounts = record['visitCounts'];
536
+ const visitCounts = {};
537
+ if (rawCounts && typeof rawCounts === 'object') {
538
+ for (const [key, value] of Object.entries(rawCounts)) {
539
+ if (typeof value === 'number' && Number.isFinite(value))
540
+ visitCounts[key] = value;
541
+ }
542
+ }
543
+ const iterations = typeof record['iterations'] === 'number' && Number.isFinite(record['iterations'])
544
+ ? record['iterations']
545
+ : 0;
546
+ return { visitCounts, iterations };
547
+ }
548
+ const inFlightAutoRuns = new Set();
549
+ function isRetryableFailure(run, spec) {
550
+ if (run.status !== 'failed')
551
+ return false;
552
+ if (!run.current_node)
553
+ return false;
554
+ const node = spec.nodes?.[run.current_node];
555
+ if (!node)
556
+ return false;
557
+ return node.type !== 'error';
558
+ }
468
559
  class RunManager {
469
560
  constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
470
561
  this.runsDir = runsDir;
471
562
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
472
563
  this.resolvedDir = path.resolve(base, this.runsDir);
473
564
  this.projectRoot = path.resolve(base);
565
+ const hasConfig = fs.existsSync(path.join(this.projectRoot, 'crewx.yaml'))
566
+ || fs.existsSync(path.join(this.projectRoot, 'crewx.yml'));
567
+ if (!hasConfig) {
568
+ console.warn(`[RunManager] projectRoot "${this.projectRoot}" has no crewx.yaml/crewx.yml — agent_task nodes may fail to resolve agents.`);
569
+ }
474
570
  }
475
571
  ensureRunsDir() {
476
572
  if (!fs.existsSync(this.resolvedDir)) {
@@ -545,6 +641,12 @@ class RunManager {
545
641
  return null;
546
642
  return doc;
547
643
  }
644
+ getNodeType(workflowFile, workflowId, nodeId) {
645
+ const doc = this.loadWorkflowYaml(workflowFile);
646
+ const spec = doc?.workflows[workflowId];
647
+ const node = spec?.nodes?.[nodeId];
648
+ return typeof node?.type === 'string' ? node.type : null;
649
+ }
548
650
  start(yamlPath, workflowId, overrides) {
549
651
  const doc = this.loadWorkflowYaml(yamlPath);
550
652
  if (!doc) {
@@ -628,6 +730,7 @@ class RunManager {
628
730
  const nodesToComplete = [];
629
731
  let currentNode = nodeId;
630
732
  let status;
733
+ let failureMessage;
631
734
  let nodeTaskId;
632
735
  const trigger = opts?.trigger ?? 'manual';
633
736
  const startedAt = new Date();
@@ -655,19 +758,39 @@ class RunManager {
655
758
  if (!nodeSpec.agent) {
656
759
  throw new Error(`Node "${nodeId}" is agent_task but has no agent defined`);
657
760
  }
761
+ const agentCtx = { ...snapshot.state, state: snapshot.state };
762
+ const resolvedAgent = interpolateTemplate(nodeSpec.agent, agentCtx).trim();
763
+ if (!resolvedAgent) {
764
+ throw new Error(`Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
765
+ }
766
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, agentCtx) : undefined, this.projectRoot);
767
+ if (!cwdCheck.ok)
768
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
769
+ const nodeCwd = cwdCheck.resolved;
770
+ const rootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
771
+ if (!rootCheck.ok)
772
+ throw new Error(`Node "${nodeId}": ${rootCheck.error}`);
773
+ const workspaceRoot = rootCheck.resolved;
658
774
  const input = nodeSpec.input
659
- ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
775
+ ? interpolateTemplate(nodeSpec.input, agentCtx)
660
776
  : '';
777
+ const preamble = process.env.CREWX_WORKFLOW_PREAMBLE === 'off'
778
+ ? ''
779
+ : (0, output_format_1.buildWorkflowPreamble)({
780
+ workflowId: snapshot.workflow_id,
781
+ nodeId,
782
+ execId: snapshot.id,
783
+ });
661
784
  const finalInput = nodeSpec.output_format === 'json'
662
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
663
- : input;
785
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
786
+ : preamble + input;
664
787
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
665
- const prompt = `@${nodeSpec.agent} ${finalInput}`;
788
+ const prompt = `@${resolvedAgent} ${finalInput}`;
666
789
  if (opts?.dryRun) {
667
- console.log(`[dry-run] Would execute: crewx ${mode} "@${nodeSpec.agent} ..."`);
790
+ console.log(`[dry-run] Would execute: crewx ${mode} "@${resolvedAgent} ..."`);
668
791
  break;
669
792
  }
670
- console.log(`Executing: @${nodeSpec.agent} (${mode} mode)`);
793
+ console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
671
794
  const crewxCli = process.env.CREWX_CLI || 'npx crewx';
672
795
  const cliParts = crewxCli.split(/\s+/);
673
796
  const bin = cliParts[0];
@@ -692,9 +815,10 @@ class RunManager {
692
815
  });
693
816
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
694
817
  const result = await runProcess(invocation.command, invocation.argv, {
695
- cwd: process.cwd(),
818
+ cwd: nodeCwd,
696
819
  env: {
697
820
  ...process.env,
821
+ CREWX_WORKSPACE: workspaceRoot,
698
822
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
699
823
  CREWX_WORKFLOW_NODE_ID: nodeId,
700
824
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
@@ -719,6 +843,10 @@ class RunManager {
719
843
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
720
844
  }
721
845
  const output = result.stdout.trim();
846
+ const signal = (0, output_format_1.detectWorkflowSignal)(output);
847
+ if (signal) {
848
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${signal.reason ?? '(no reason given)'}`);
849
+ }
722
850
  if (nodeSpec.output) {
723
851
  if (nodeSpec.output_format === 'json') {
724
852
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -731,7 +859,7 @@ class RunManager {
731
859
  retryInput = finalInput +
732
860
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
733
861
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
734
- const retryPrompt = `@${nodeSpec.agent} ${retryInput}`;
862
+ const retryPrompt = `@${resolvedAgent} ${retryInput}`;
735
863
  const retryArgs = [...baseArgs, mode, retryPrompt];
736
864
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
737
865
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
@@ -739,9 +867,10 @@ class RunManager {
739
867
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
740
868
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
741
869
  const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
742
- cwd: process.cwd(),
870
+ cwd: nodeCwd,
743
871
  env: {
744
872
  ...process.env,
873
+ CREWX_WORKSPACE: workspaceRoot,
745
874
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
746
875
  CREWX_WORKFLOW_NODE_ID: nodeId,
747
876
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
@@ -761,6 +890,10 @@ class RunManager {
761
890
  throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
762
891
  }
763
892
  attemptOutput = retryResult.stdout.trim();
893
+ const retrySignal = (0, output_format_1.detectWorkflowSignal)(attemptOutput);
894
+ if (retrySignal) {
895
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${retrySignal.reason ?? '(no reason given)'}`);
896
+ }
764
897
  }
765
898
  try {
766
899
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -795,9 +928,16 @@ class RunManager {
795
928
  }
796
929
  const ctx = { ...snapshot.state, state: snapshot.state };
797
930
  const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
931
+ const skillStdin = nodeSpec.stdin !== undefined
932
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
933
+ : undefined;
798
934
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
799
935
  if (!cwdCheck.ok)
800
936
  throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
937
+ const skillRootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
938
+ if (!skillRootCheck.ok)
939
+ throw new Error(`Node "${nodeId}": ${skillRootCheck.error}`);
940
+ const skillWorkspaceRoot = skillRootCheck.resolved;
801
941
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
802
942
  if (opts?.dryRun) {
803
943
  console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
@@ -819,6 +959,7 @@ class RunManager {
819
959
  cwd: cwdCheck.resolved,
820
960
  env: {
821
961
  ...process.env,
962
+ CREWX_WORKSPACE: skillWorkspaceRoot,
822
963
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
823
964
  CREWX_WORKFLOW_NODE_ID: nodeId,
824
965
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
@@ -826,6 +967,7 @@ class RunManager {
826
967
  timeout,
827
968
  shell: invocation.shell ?? false,
828
969
  windowsHide: invocation.windowsHide,
970
+ stdin: skillStdin,
829
971
  });
830
972
  nodeStdout = result.stdout;
831
973
  nodeStderr = result.stderr;
@@ -876,11 +1018,15 @@ class RunManager {
876
1018
  childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
877
1019
  childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
878
1020
  childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
1021
+ const shellStdin = nodeSpec.stdin !== undefined
1022
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1023
+ : undefined;
879
1024
  const result = await runProcess(command[0], command.slice(1), {
880
1025
  cwd: cwdCheck.resolved,
881
1026
  env: childEnv,
882
1027
  timeout,
883
1028
  shell: false,
1029
+ stdin: shellStdin,
884
1030
  });
885
1031
  nodeStdout = result.stdout;
886
1032
  nodeStderr = result.stderr;
@@ -902,8 +1048,7 @@ class RunManager {
902
1048
  if (!nodeSpec.condition) {
903
1049
  throw new Error(`Branch node "${nodeId}" has no condition`);
904
1050
  }
905
- const evalFn = new Function('state', `return (${nodeSpec.condition});`);
906
- const condResult = String(evalFn(snapshot.state));
1051
+ const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
907
1052
  const branches = nodeSpec.branches;
908
1053
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
909
1054
  if (!targetNode) {
@@ -931,6 +1076,15 @@ class RunManager {
931
1076
  console.log(`End "${nodeId}": workflow execution completed`);
932
1077
  break;
933
1078
  }
1079
+ case 'error': {
1080
+ status = 'failed';
1081
+ const ctx = { ...snapshot.state, state: snapshot.state };
1082
+ failureMessage = nodeSpec.message
1083
+ ? interpolateTemplate(nodeSpec.message, ctx)
1084
+ : `Workflow failed at error node "${nodeId}"`;
1085
+ console.log(`Error "${nodeId}": workflow execution failed — ${failureMessage}`);
1086
+ break;
1087
+ }
934
1088
  case 'expression': {
935
1089
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
936
1090
  throw new Error(`Expression node "${nodeId}" requires "set" field`);
@@ -954,6 +1108,14 @@ class RunManager {
954
1108
  default:
955
1109
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
956
1110
  }
1111
+ if (nodeSpec.fail_when &&
1112
+ !opts?.dryRun &&
1113
+ (nodeSpec.type === 'agent_task' || nodeSpec.type === 'skill_task' || nodeSpec.type === 'shell_task')) {
1114
+ const judged = safeEvaluateBranchCondition(String(nodeSpec.fail_when), { ...snapshot.state, ...stateUpdates });
1115
+ if (judged === true || judged === 'true') {
1116
+ throw new Error(`Node "${nodeId}" judged failed by fail_when: ${nodeSpec.fail_when}`);
1117
+ }
1118
+ }
957
1119
  }
958
1120
  catch (e) {
959
1121
  const errMessage = e.message;
@@ -965,6 +1127,7 @@ class RunManager {
965
1127
  await this.atomicUpdateAsync(execId, (run) => {
966
1128
  if (run.status === 'cancelled')
967
1129
  return;
1130
+ Object.assign(run.state, stateUpdates);
968
1131
  run.current_node = nodeId;
969
1132
  run.status = 'failed';
970
1133
  run.error = errMessage;
@@ -989,6 +1152,8 @@ class RunManager {
989
1152
  run.current_node = currentNode;
990
1153
  if (status)
991
1154
  run.status = status;
1155
+ if (failureMessage)
1156
+ run.error = failureMessage;
992
1157
  if (nodeTaskId) {
993
1158
  run.tasks = run.tasks ?? {};
994
1159
  run.tasks[nodeId] = nodeTaskId;
@@ -998,92 +1163,117 @@ class RunManager {
998
1163
  });
999
1164
  }
1000
1165
  async runAuto(execId) {
1001
- const initial = this.loadRun(execId);
1002
- if (!initial)
1003
- throw new Error(`Execution not found: ${execId}`);
1004
- const doc = this.loadWorkflowYaml(initial.workflow_file);
1005
- if (!doc)
1006
- throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1007
- const spec = doc.workflows[initial.workflow_id];
1008
- if (!spec) {
1009
- throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1010
- }
1011
- const executed = [];
1012
- if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1013
- return {
1014
- run: initial,
1015
- outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1016
- reason: `run already ${initial.status}`,
1017
- executed,
1018
- };
1166
+ const leaseKey = `${this.resolvedDir}::${execId}`;
1167
+ if (inFlightAutoRuns.has(leaseKey)) {
1168
+ const run = this.loadRun(execId);
1169
+ if (!run)
1170
+ throw new Error(`Execution not found: ${execId}`);
1171
+ console.log(`runAuto "${execId}": already in-flight skipping duplicate auto-run (single-flight guard)`);
1172
+ return { run, outcome: 'paused', reason: 'execution already in-flight (single-flight guard)', executed: [] };
1019
1173
  }
1020
- const firstNode = Object.keys(spec.nodes ?? {})[0];
1021
- const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1022
- ? spec.max_iterations
1023
- : 30;
1024
- const visitCounts = {};
1025
- let iterations = 0;
1026
- let run = initial;
1027
- while (true) {
1028
- run = this.loadRun(execId);
1029
- if (run.status === 'completed')
1030
- return { run, outcome: 'completed', executed };
1031
- if (run.status === 'failed')
1032
- return { run, outcome: 'failed', reason: run.error, executed };
1033
- if (run.status === 'cancelled')
1034
- return { run, outcome: 'cancelled', reason: run.error, executed };
1035
- const nodeId = run.current_node || firstNode;
1036
- if (!nodeId)
1037
- return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
1038
- const node = spec.nodes[nodeId];
1039
- if (!node) {
1040
- run = await this.atomicUpdateAsync(execId, (r) => {
1041
- r.current_node = nodeId;
1042
- r.status = 'failed';
1043
- r.error = `node "${nodeId}" not found in workflow`;
1044
- });
1045
- return { run, outcome: 'failed', reason: run.error, executed };
1046
- }
1047
- if (node.type === 'approval') {
1048
- return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1049
- }
1050
- if (node.type === 'parallel') {
1051
- return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1174
+ inFlightAutoRuns.add(leaseKey);
1175
+ try {
1176
+ const initial = this.loadRun(execId);
1177
+ if (!initial)
1178
+ throw new Error(`Execution not found: ${execId}`);
1179
+ const doc = this.loadWorkflowYaml(initial.workflow_file);
1180
+ if (!doc)
1181
+ throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1182
+ const spec = doc.workflows[initial.workflow_id];
1183
+ if (!spec) {
1184
+ throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1052
1185
  }
1053
- if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1054
- return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1186
+ const executed = [];
1187
+ if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1188
+ return {
1189
+ run: initial,
1190
+ outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1191
+ reason: `run already ${initial.status}`,
1192
+ executed,
1193
+ };
1055
1194
  }
1056
- visitCounts[nodeId] = (visitCounts[nodeId] ?? 0) + 1;
1057
- iterations++;
1058
- if (visitCounts[nodeId] > maxIterations || iterations > maxIterations) {
1195
+ const firstNode = Object.keys(spec.nodes ?? {})[0];
1196
+ const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1197
+ ? spec.max_iterations
1198
+ : 30;
1199
+ let run = initial;
1200
+ while (true) {
1201
+ run = this.loadRun(execId);
1202
+ if (run.status === 'completed')
1203
+ return { run, outcome: 'completed', executed };
1204
+ if (run.status === 'failed')
1205
+ return { run, outcome: 'failed', reason: run.error, executed };
1206
+ if (run.status === 'cancelled')
1207
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1208
+ const nodeId = run.current_node || firstNode;
1209
+ if (!nodeId)
1210
+ return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
1211
+ const node = spec.nodes[nodeId];
1212
+ if (!node) {
1213
+ run = await this.atomicUpdateAsync(execId, (r) => {
1214
+ r.current_node = nodeId;
1215
+ r.status = 'failed';
1216
+ r.error = `node "${nodeId}" not found in workflow`;
1217
+ });
1218
+ return { run, outcome: 'failed', reason: run.error, executed };
1219
+ }
1220
+ if (node.type === 'approval') {
1221
+ if (run.current_node !== nodeId) {
1222
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1223
+ }
1224
+ return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1225
+ }
1226
+ if (node.type === 'parallel') {
1227
+ if (run.current_node !== nodeId) {
1228
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1229
+ }
1230
+ return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1231
+ }
1232
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1233
+ if (run.current_node !== nodeId) {
1234
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1235
+ }
1236
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1237
+ }
1059
1238
  run = await this.atomicUpdateAsync(execId, (r) => {
1060
- r.status = 'failed';
1061
- r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1239
+ const engine = readRunEngineState(r);
1240
+ engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1241
+ engine.iterations += 1;
1242
+ r.state['__engine'] = engine;
1243
+ if (engine.visitCounts[nodeId] > maxIterations || engine.iterations > maxIterations) {
1244
+ r.status = 'failed';
1245
+ r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1246
+ }
1062
1247
  });
1063
- return { run, outcome: 'failed', reason: run.error, executed };
1064
- }
1065
- try {
1066
- run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1067
- executed.push(nodeId);
1068
- }
1069
- catch (e) {
1070
- run = this.loadRun(execId);
1071
- return { run, outcome: 'failed', reason: e.message, executed };
1072
- }
1073
- if (run.status === 'completed')
1074
- return { run, outcome: 'completed', executed };
1075
- if (run.status === 'failed')
1076
- return { run, outcome: 'failed', reason: run.error, executed };
1077
- if (run.status === 'cancelled')
1078
- return { run, outcome: 'cancelled', reason: run.error, executed };
1079
- if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1080
- const next = node.next;
1081
- if (!next) {
1082
- return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
1248
+ if (run.status === 'failed') {
1249
+ return { run, outcome: 'failed', reason: run.error, executed };
1250
+ }
1251
+ try {
1252
+ run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1253
+ executed.push(nodeId);
1254
+ }
1255
+ catch (e) {
1256
+ run = this.loadRun(execId);
1257
+ return { run, outcome: 'failed', reason: e.message, executed };
1258
+ }
1259
+ if (run.status === 'completed')
1260
+ return { run, outcome: 'completed', executed };
1261
+ if (run.status === 'failed')
1262
+ return { run, outcome: 'failed', reason: run.error, executed };
1263
+ if (run.status === 'cancelled')
1264
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1265
+ if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1266
+ const next = node.next;
1267
+ if (!next) {
1268
+ return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
1269
+ }
1270
+ run = this.moveNode(execId, next);
1083
1271
  }
1084
- run = this.moveNode(execId, next);
1085
1272
  }
1086
1273
  }
1274
+ finally {
1275
+ inFlightAutoRuns.delete(leaseKey);
1276
+ }
1087
1277
  }
1088
1278
  cancelRun(execId, reason = 'Cancelled by user') {
1089
1279
  return this.atomicUpdate(execId, (run) => {
@@ -1115,6 +1305,42 @@ class RunManager {
1115
1305
  run.completed_nodes = [];
1116
1306
  });
1117
1307
  }
1308
+ retry(execId) {
1309
+ const snapshot = this.loadRun(execId);
1310
+ if (!snapshot)
1311
+ throw new Error(`Execution not found: ${execId}`);
1312
+ const doc = this.loadWorkflowYaml(snapshot.workflow_file);
1313
+ if (!doc)
1314
+ throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
1315
+ const spec = doc.workflows[snapshot.workflow_id];
1316
+ if (!spec)
1317
+ throw new Error(`Workflow "${snapshot.workflow_id}" not found`);
1318
+ if (snapshot.status !== 'failed') {
1319
+ throw new Error(`Run "${execId}" is not failed (status=${snapshot.status ?? 'running'}); only failed runs can be retried`);
1320
+ }
1321
+ if (!isRetryableFailure(snapshot, spec)) {
1322
+ throw new Error(`Run "${execId}" failed at a non-retryable node "${snapshot.current_node}" (error node); use reset to restart`);
1323
+ }
1324
+ return this.atomicUpdate(execId, (run) => {
1325
+ const failedNode = run.current_node;
1326
+ delete run.status;
1327
+ delete run.error;
1328
+ run.retry_count = (run.retry_count ?? 0) + 1;
1329
+ run.audit = run.audit ?? [];
1330
+ const now = new Date().toISOString();
1331
+ run.audit.push({
1332
+ exec_id: run.id,
1333
+ node_id: failedNode,
1334
+ type: 'retry',
1335
+ summary: `manual retry #${run.retry_count} of node "${failedNode}"`,
1336
+ started_at: now,
1337
+ ended_at: now,
1338
+ duration_ms: 0,
1339
+ exit_code: null,
1340
+ trigger: 'manual',
1341
+ });
1342
+ });
1343
+ }
1118
1344
  listRuns(opts) {
1119
1345
  this.ensureRunsDir();
1120
1346
  const files = fs.readdirSync(this.resolvedDir).filter(f => f.endsWith('.json'));