@crewx/workflow 0.3.22-rc.2 → 0.3.22-rc.21

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,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RunManager = void 0;
36
+ exports.RunManager = exports.SKILL_TASK_DEFAULT_TIMEOUT = 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;
41
43
  const fs = __importStar(require("fs"));
42
44
  const path = __importStar(require("path"));
43
45
  const yaml = __importStar(require("js-yaml"));
@@ -45,6 +47,112 @@ const cp = __importStar(require("child_process"));
45
47
  const sdk_1 = require("@crewx/sdk");
46
48
  const file_lock_1 = require("./file-lock");
47
49
  const output_format_1 = require("./utils/output-format");
50
+ const shell_security_1 = require("./utils/shell-security");
51
+ exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
52
+ const KNOWN_AUTO_NODE_TYPES = new Set([
53
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
54
+ ]);
55
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end', 'error']);
56
+ function killProcessGroup(child) {
57
+ try {
58
+ if (process.platform === 'win32') {
59
+ if (child.pid) {
60
+ cp.spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
61
+ }
62
+ }
63
+ else if (child.pid) {
64
+ try {
65
+ process.kill(-child.pid, 'SIGKILL');
66
+ }
67
+ catch {
68
+ try {
69
+ child.kill('SIGKILL');
70
+ }
71
+ catch { }
72
+ }
73
+ }
74
+ }
75
+ catch { }
76
+ }
77
+ async function runProcess(command, argv, opts) {
78
+ return new Promise((resolve) => {
79
+ let child;
80
+ try {
81
+ child = cp.spawn(command, argv, {
82
+ cwd: opts.cwd,
83
+ env: opts.env,
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ shell: opts.shell ?? false,
86
+ detached: process.platform !== 'win32',
87
+ windowsHide: opts.windowsHide ?? true,
88
+ });
89
+ }
90
+ catch (err) {
91
+ resolve({ status: null, stdout: '', stderr: '', timedOut: false, error: err });
92
+ return;
93
+ }
94
+ let stdout = '';
95
+ let stderr = '';
96
+ let settled = false;
97
+ const finish = (result) => {
98
+ if (settled)
99
+ return;
100
+ settled = true;
101
+ clearTimeout(timer);
102
+ resolve(result);
103
+ };
104
+ const timer = setTimeout(() => {
105
+ killProcessGroup(child);
106
+ finish({ status: null, stdout, stderr, timedOut: true });
107
+ }, opts.timeout);
108
+ if (typeof timer.unref === 'function')
109
+ timer.unref();
110
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
111
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
112
+ child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
113
+ child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
114
+ });
115
+ }
116
+ function maskArg(arg) {
117
+ return arg.replace(/^(--?[\w-]*(?:token|secret|password|passwd|key|auth)[\w-]*)=(.+)$/i, '$1=***');
118
+ }
119
+ function summarizeNode(node) {
120
+ switch (node.type) {
121
+ case 'agent_task':
122
+ return `@${node.agent ?? '?'} (${node.mode === 'query' ? 'q' : 'x'})`;
123
+ case 'skill_task':
124
+ return `skill ${node.skill ?? '?'} ${(node.args ?? []).map(maskArg).join(' ')}`.trim();
125
+ case 'shell_task':
126
+ return (node.command ?? []).map(maskArg).join(' ');
127
+ case 'branch':
128
+ return `branch: ${node.condition ?? ''}`;
129
+ case 'expression':
130
+ return `set ${Object.keys(node.set ?? {}).join(', ')}`;
131
+ default:
132
+ return node.type;
133
+ }
134
+ }
135
+ async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
136
+ if (!nodeSpec.output)
137
+ return;
138
+ const trimmed = stdout.trim();
139
+ if (nodeSpec.output_format === 'json') {
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse((0, output_format_1.extractJson)(trimmed));
143
+ }
144
+ catch (e) {
145
+ throw new Error(`Node "${nodeId}" output_format=json parse failed: ${e.message}`);
146
+ }
147
+ if (nodeSpec.output_schema) {
148
+ await (0, output_format_1.validateJsonSchema)(parsed, nodeSpec.output_schema);
149
+ }
150
+ stateUpdates[nodeSpec.output] = parsed;
151
+ }
152
+ else {
153
+ stateUpdates[nodeSpec.output] = trimmed;
154
+ }
155
+ }
48
156
  function parseValue(raw) {
49
157
  try {
50
158
  return JSON.parse(raw);
@@ -104,6 +212,7 @@ const FORBIDDEN_IDENTS = new Set([
104
212
  'require', 'import', 'process', 'global', 'globalThis',
105
213
  'Function', 'eval', '__proto__', 'constructor', 'prototype',
106
214
  ]);
215
+ const FORBIDDEN_STATE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
107
216
  function tokenize(expr) {
108
217
  const tokens = [];
109
218
  let i = 0;
@@ -337,6 +446,8 @@ class ExprParser {
337
446
  const val = this.state[key];
338
447
  if (val === undefined || val === null)
339
448
  return 0;
449
+ if (typeof val === 'boolean' || typeof val === 'number')
450
+ return val;
340
451
  const strVal = String(val);
341
452
  if (strVal === '')
342
453
  return '';
@@ -359,11 +470,63 @@ function safeEvaluate(expr, state) {
359
470
  const parser = new ExprParser(tokens, state);
360
471
  return parser.parse();
361
472
  }
473
+ function getStatePathValue(state, pathExpr) {
474
+ const parts = pathExpr.split('.');
475
+ let value = state;
476
+ for (const part of parts) {
477
+ if (FORBIDDEN_STATE_PATH_SEGMENTS.has(part)) {
478
+ throw new Error(`Forbidden state path segment in expression: "${part}"`);
479
+ }
480
+ if (!part || value === null || value === undefined || typeof value !== 'object') {
481
+ return undefined;
482
+ }
483
+ value = value[part];
484
+ }
485
+ return value;
486
+ }
487
+ function unescapeQuotedLiteral(value) {
488
+ return value
489
+ .replace(/\\'/g, "'")
490
+ .replace(/\\"/g, '"')
491
+ .replace(/\\\\/g, '\\');
492
+ }
493
+ function normalizeBranchCondition(expr, state) {
494
+ const scope = {};
495
+ let counter = 0;
496
+ const bind = (value) => {
497
+ const key = `__state_${counter++}`;
498
+ scope[key] = value;
499
+ return key;
500
+ };
501
+ let normalized = expr
502
+ .replace(/!==/g, '!=')
503
+ .replace(/===/g, '==');
504
+ const replaceIncludes = (pattern) => {
505
+ normalized = normalized.replace(pattern, (_match, statePath, literal) => {
506
+ const value = getStatePathValue(state, statePath);
507
+ return bind(String(value ?? '').includes(unescapeQuotedLiteral(literal)));
508
+ });
509
+ };
510
+ replaceIncludes(/\bstate\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.includes\(\s*'((?:\\.|[^'\\])*)'\s*\)/g);
511
+ replaceIncludes(/\bstate\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\.includes\(\s*"((?:\\.|[^"\\])*)"\s*\)/g);
512
+ normalized = normalized.replace(/\bstate\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)/g, (_match, statePath) => bind(getStatePathValue(state, statePath)));
513
+ normalized = normalized.replace(/\bNumber\(\s*([A-Za-z_]\w*)\s*\)/g, '$1');
514
+ normalized = normalized.replace(/\bparseInt\(\s*([A-Za-z_]\w*(?:\s*\|\|\s*(?:'[^']*'|"[^"]*"))?)\s*\)/g, '($1)');
515
+ return { expr: normalized, scope };
516
+ }
517
+ function safeEvaluateBranchCondition(expr, state) {
518
+ const normalized = normalizeBranchCondition(expr, state);
519
+ return safeEvaluate(normalized.expr, { ...state, ...normalized.scope });
520
+ }
521
+ function validateSafeBranchCondition(expr) {
522
+ safeEvaluateBranchCondition(expr, {});
523
+ }
362
524
  class RunManager {
363
- constructor(runsDir = '.crewx/workflow-runs') {
525
+ constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
364
526
  this.runsDir = runsDir;
365
- const base = process.env.CREWX_WORKSPACE || process.cwd();
527
+ const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
366
528
  this.resolvedDir = path.resolve(base, this.runsDir);
529
+ this.projectRoot = path.resolve(base);
367
530
  }
368
531
  ensureRunsDir() {
369
532
  if (!fs.existsSync(this.resolvedDir)) {
@@ -521,7 +684,28 @@ class RunManager {
521
684
  const nodesToComplete = [];
522
685
  let currentNode = nodeId;
523
686
  let status;
687
+ let failureMessage;
524
688
  let nodeTaskId;
689
+ const trigger = opts?.trigger ?? 'manual';
690
+ const startedAt = new Date();
691
+ let nodeStdout = '';
692
+ let nodeStderr = '';
693
+ let nodeExitCode = null;
694
+ const buildAudit = (error) => {
695
+ const endedAt = new Date();
696
+ return {
697
+ exec_id: snapshot.id,
698
+ node_id: nodeId,
699
+ type: nodeSpec.type,
700
+ summary: summarizeNode(nodeSpec),
701
+ started_at: startedAt.toISOString(),
702
+ ended_at: endedAt.toISOString(),
703
+ duration_ms: endedAt.getTime() - startedAt.getTime(),
704
+ exit_code: nodeExitCode,
705
+ ...(error ? { error } : {}),
706
+ trigger,
707
+ };
708
+ };
525
709
  try {
526
710
  switch (nodeSpec.type) {
527
711
  case 'agent_task': {
@@ -531,9 +715,16 @@ class RunManager {
531
715
  const input = nodeSpec.input
532
716
  ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
533
717
  : '';
718
+ const preamble = process.env.CREWX_WORKFLOW_PREAMBLE === 'off'
719
+ ? ''
720
+ : (0, output_format_1.buildWorkflowPreamble)({
721
+ workflowId: snapshot.workflow_id,
722
+ nodeId,
723
+ execId: snapshot.id,
724
+ });
534
725
  const finalInput = nodeSpec.output_format === 'json'
535
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
536
- : input;
726
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
727
+ : preamble + input;
537
728
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
538
729
  const prompt = `@${nodeSpec.agent} ${finalInput}`;
539
730
  if (opts?.dryRun) {
@@ -564,30 +755,38 @@ class RunManager {
564
755
  packageName: 'crewx',
565
756
  });
566
757
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
567
- const result = cp.spawnSync(invocation.command, invocation.argv, {
568
- encoding: 'utf-8',
569
- timeout: nodeTimeout,
758
+ const result = await runProcess(invocation.command, invocation.argv, {
570
759
  cwd: process.cwd(),
571
- stdio: ['inherit', 'pipe', 'pipe'],
572
- shell: invocation.shell ?? false,
573
- windowsHide: invocation.windowsHide,
574
760
  env: {
575
761
  ...process.env,
576
762
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
577
763
  CREWX_WORKFLOW_NODE_ID: nodeId,
578
764
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
579
765
  },
766
+ timeout: nodeTimeout,
767
+ shell: invocation.shell ?? false,
768
+ windowsHide: invocation.windowsHide,
580
769
  });
770
+ nodeStdout = result.stdout;
771
+ nodeStderr = result.stderr;
772
+ nodeExitCode = result.status;
581
773
  if (result.error) {
582
774
  throw result.error;
583
775
  }
584
- const taskIdMatch = result.stderr?.match(/crewx kill (tsk_\w+)/);
776
+ if (result.timedOut) {
777
+ throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
778
+ }
779
+ const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
585
780
  nodeTaskId = taskIdMatch?.[1];
586
781
  if (result.status !== 0) {
587
- const stderr = result.stderr?.trim() ?? '';
782
+ const stderr = result.stderr.trim();
588
783
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
589
784
  }
590
- const output = result.stdout?.trim() ?? '';
785
+ const output = result.stdout.trim();
786
+ const signal = (0, output_format_1.detectWorkflowSignal)(output);
787
+ if (signal) {
788
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${signal.reason ?? '(no reason given)'}`);
789
+ }
591
790
  if (nodeSpec.output) {
592
791
  if (nodeSpec.output_format === 'json') {
593
792
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -607,26 +806,33 @@ class RunManager {
607
806
  }
608
807
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
609
808
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
610
- const retryResult = cp.spawnSync(retryInvocation.command, retryInvocation.argv, {
611
- encoding: 'utf-8',
612
- timeout: nodeTimeout,
809
+ const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
613
810
  cwd: process.cwd(),
614
- stdio: ['inherit', 'pipe', 'pipe'],
615
- shell: retryInvocation.shell ?? false,
616
- windowsHide: retryInvocation.windowsHide,
617
811
  env: {
618
812
  ...process.env,
619
813
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
620
814
  CREWX_WORKFLOW_NODE_ID: nodeId,
621
815
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
622
816
  },
817
+ timeout: nodeTimeout,
818
+ shell: retryInvocation.shell ?? false,
819
+ windowsHide: retryInvocation.windowsHide,
623
820
  });
821
+ nodeStdout = retryResult.stdout;
822
+ nodeStderr = retryResult.stderr;
823
+ nodeExitCode = retryResult.status;
624
824
  if (retryResult.error)
625
825
  throw retryResult.error;
826
+ if (retryResult.timedOut)
827
+ throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
626
828
  if (retryResult.status !== 0) {
627
- throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr?.trim()}`);
829
+ throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
830
+ }
831
+ attemptOutput = retryResult.stdout.trim();
832
+ const retrySignal = (0, output_format_1.detectWorkflowSignal)(attemptOutput);
833
+ if (retrySignal) {
834
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${retrySignal.reason ?? '(no reason given)'}`);
628
835
  }
629
- attemptOutput = retryResult.stdout?.trim() ?? '';
630
836
  }
631
837
  try {
632
838
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -652,12 +858,123 @@ class RunManager {
652
858
  }
653
859
  break;
654
860
  }
861
+ case 'skill_task': {
862
+ if (!nodeSpec.skill) {
863
+ throw new Error(`Node "${nodeId}" is skill_task but has no skill defined`);
864
+ }
865
+ if (!shell_security_1.SKILL_NAME_RE.test(nodeSpec.skill)) {
866
+ throw new Error(`Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
867
+ }
868
+ const ctx = { ...snapshot.state, state: snapshot.state };
869
+ const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
870
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
871
+ if (!cwdCheck.ok)
872
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
873
+ const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
874
+ if (opts?.dryRun) {
875
+ console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
876
+ break;
877
+ }
878
+ console.log(`Executing skill: ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
879
+ const crewxCli = process.env.CREWX_CLI || 'npx crewx';
880
+ const cliParts = crewxCli.split(/\s+/);
881
+ const bin = cliParts[0];
882
+ const baseArgs = cliParts.slice(1);
883
+ const skillArgv = [...baseArgs, 'skill', nodeSpec.skill, ...skillArgs];
884
+ const program = (0, sdk_1.resolveWindowsSpawnProgram)({
885
+ command: bin,
886
+ allowShellFallback: true,
887
+ packageName: 'crewx',
888
+ });
889
+ const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
890
+ const result = await runProcess(invocation.command, invocation.argv, {
891
+ cwd: cwdCheck.resolved,
892
+ env: {
893
+ ...process.env,
894
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
895
+ CREWX_WORKFLOW_NODE_ID: nodeId,
896
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
897
+ },
898
+ timeout,
899
+ shell: invocation.shell ?? false,
900
+ windowsHide: invocation.windowsHide,
901
+ });
902
+ nodeStdout = result.stdout;
903
+ nodeStderr = result.stderr;
904
+ nodeExitCode = result.status;
905
+ if (result.error)
906
+ throw result.error;
907
+ if (result.timedOut)
908
+ throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
909
+ if (result.status !== 0) {
910
+ throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
911
+ }
912
+ await storeNodeOutput(nodeSpec, nodeId, result.stdout, stateUpdates);
913
+ if (nodeSpec.output) {
914
+ console.log(`Result stored in state.${nodeSpec.output}`);
915
+ }
916
+ break;
917
+ }
918
+ case 'shell_task': {
919
+ const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
920
+ const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
921
+ if (!shellEnvEnabled || !shellMetaAllowed) {
922
+ throw new Error('shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
923
+ }
924
+ const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
925
+ if (!cmdCheck.ok)
926
+ throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
927
+ const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
928
+ if (!envCheck.ok)
929
+ throw new Error(`Node "${nodeId}": ${envCheck.error}`);
930
+ const ctx = { ...snapshot.state, state: snapshot.state };
931
+ const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
932
+ const postCheck = (0, shell_security_1.validateShellCommand)(command);
933
+ if (!postCheck.ok)
934
+ throw new Error(`Node "${nodeId}": ${postCheck.error}`);
935
+ const interpolatedEnv = nodeSpec.env
936
+ ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
937
+ : undefined;
938
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
939
+ if (!cwdCheck.ok)
940
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
941
+ const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
942
+ if (opts?.dryRun) {
943
+ console.log(`[dry-run] Would execute: ${command.join(' ')}`);
944
+ break;
945
+ }
946
+ console.log(`Executing shell_task: ${command[0]} (${command.length - 1} args)`);
947
+ const childEnv = (0, shell_security_1.buildShellEnv)(interpolatedEnv, process.env);
948
+ childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
949
+ childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
950
+ childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
951
+ const result = await runProcess(command[0], command.slice(1), {
952
+ cwd: cwdCheck.resolved,
953
+ env: childEnv,
954
+ timeout,
955
+ shell: false,
956
+ });
957
+ nodeStdout = result.stdout;
958
+ nodeStderr = result.stderr;
959
+ nodeExitCode = result.status;
960
+ if (result.error)
961
+ throw result.error;
962
+ if (result.timedOut)
963
+ throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
964
+ if (result.status !== 0) {
965
+ throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
966
+ }
967
+ await storeNodeOutput(nodeSpec, nodeId, result.stdout, stateUpdates);
968
+ if (nodeSpec.output) {
969
+ console.log(`Result stored in state.${nodeSpec.output}`);
970
+ }
971
+ break;
972
+ }
655
973
  case 'branch': {
656
974
  if (!nodeSpec.condition) {
657
975
  throw new Error(`Branch node "${nodeId}" has no condition`);
658
976
  }
659
- const evalFn = new Function('state', `return (${nodeSpec.condition});`);
660
- const condResult = String(evalFn(snapshot.state));
977
+ const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
661
978
  const branches = nodeSpec.branches;
662
979
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
663
980
  if (!targetNode) {
@@ -685,6 +1002,15 @@ class RunManager {
685
1002
  console.log(`End "${nodeId}": workflow execution completed`);
686
1003
  break;
687
1004
  }
1005
+ case 'error': {
1006
+ status = 'failed';
1007
+ const ctx = { ...snapshot.state, state: snapshot.state };
1008
+ failureMessage = nodeSpec.message
1009
+ ? interpolateTemplate(nodeSpec.message, ctx)
1010
+ : `Workflow failed at error node "${nodeId}"`;
1011
+ console.log(`Error "${nodeId}": workflow execution failed — ${failureMessage}`);
1012
+ break;
1013
+ }
688
1014
  case 'expression': {
689
1015
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
690
1016
  throw new Error(`Expression node "${nodeId}" requires "set" field`);
@@ -708,15 +1034,38 @@ class RunManager {
708
1034
  default:
709
1035
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
710
1036
  }
1037
+ if (nodeSpec.fail_when &&
1038
+ !opts?.dryRun &&
1039
+ (nodeSpec.type === 'agent_task' || nodeSpec.type === 'skill_task' || nodeSpec.type === 'shell_task')) {
1040
+ const judged = safeEvaluateBranchCondition(String(nodeSpec.fail_when), { ...snapshot.state, ...stateUpdates });
1041
+ if (judged === true || judged === 'true') {
1042
+ throw new Error(`Node "${nodeId}" judged failed by fail_when: ${nodeSpec.fail_when}`);
1043
+ }
1044
+ }
711
1045
  }
712
1046
  catch (e) {
1047
+ const errMessage = e.message;
1048
+ const partial = [
1049
+ nodeStdout.trim() ? `stdout: ${nodeStdout.trim().slice(-500)}` : '',
1050
+ nodeStderr.trim() ? `stderr: ${nodeStderr.trim().slice(-500)}` : '',
1051
+ ].filter(Boolean).join(' | ');
1052
+ const auditEntry = buildAudit(partial ? `${errMessage} (${partial})` : errMessage);
713
1053
  await this.atomicUpdateAsync(execId, (run) => {
1054
+ if (run.status === 'cancelled')
1055
+ return;
1056
+ Object.assign(run.state, stateUpdates);
714
1057
  run.current_node = nodeId;
715
1058
  run.status = 'failed';
1059
+ run.error = errMessage;
1060
+ run.audit = run.audit ?? [];
1061
+ run.audit.push(auditEntry);
716
1062
  });
717
1063
  throw e;
718
1064
  }
1065
+ const successAudit = buildAudit();
719
1066
  return this.atomicUpdateAsync(execId, (run) => {
1067
+ if (run.status === 'cancelled')
1068
+ return;
720
1069
  Object.assign(run.state, stateUpdates);
721
1070
  for (const node of nodesToComplete) {
722
1071
  if (!run.completed_nodes.includes(node)) {
@@ -729,10 +1078,125 @@ class RunManager {
729
1078
  run.current_node = currentNode;
730
1079
  if (status)
731
1080
  run.status = status;
1081
+ if (failureMessage)
1082
+ run.error = failureMessage;
732
1083
  if (nodeTaskId) {
733
1084
  run.tasks = run.tasks ?? {};
734
1085
  run.tasks[nodeId] = nodeTaskId;
735
1086
  }
1087
+ run.audit = run.audit ?? [];
1088
+ run.audit.push(successAudit);
1089
+ });
1090
+ }
1091
+ async runAuto(execId) {
1092
+ const initial = this.loadRun(execId);
1093
+ if (!initial)
1094
+ throw new Error(`Execution not found: ${execId}`);
1095
+ const doc = this.loadWorkflowYaml(initial.workflow_file);
1096
+ if (!doc)
1097
+ throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1098
+ const spec = doc.workflows[initial.workflow_id];
1099
+ if (!spec) {
1100
+ throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1101
+ }
1102
+ const executed = [];
1103
+ if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1104
+ return {
1105
+ run: initial,
1106
+ outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1107
+ reason: `run already ${initial.status}`,
1108
+ executed,
1109
+ };
1110
+ }
1111
+ const firstNode = Object.keys(spec.nodes ?? {})[0];
1112
+ const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1113
+ ? spec.max_iterations
1114
+ : 30;
1115
+ const visitCounts = {};
1116
+ let iterations = 0;
1117
+ let run = initial;
1118
+ while (true) {
1119
+ run = this.loadRun(execId);
1120
+ if (run.status === 'completed')
1121
+ return { run, outcome: 'completed', executed };
1122
+ if (run.status === 'failed')
1123
+ return { run, outcome: 'failed', reason: run.error, executed };
1124
+ if (run.status === 'cancelled')
1125
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1126
+ const nodeId = run.current_node || firstNode;
1127
+ if (!nodeId)
1128
+ return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
1129
+ const node = spec.nodes[nodeId];
1130
+ if (!node) {
1131
+ run = await this.atomicUpdateAsync(execId, (r) => {
1132
+ r.current_node = nodeId;
1133
+ r.status = 'failed';
1134
+ r.error = `node "${nodeId}" not found in workflow`;
1135
+ });
1136
+ return { run, outcome: 'failed', reason: run.error, executed };
1137
+ }
1138
+ if (node.type === 'approval') {
1139
+ return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1140
+ }
1141
+ if (node.type === 'parallel') {
1142
+ return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1143
+ }
1144
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1145
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1146
+ }
1147
+ visitCounts[nodeId] = (visitCounts[nodeId] ?? 0) + 1;
1148
+ iterations++;
1149
+ if (visitCounts[nodeId] > maxIterations || iterations > maxIterations) {
1150
+ run = await this.atomicUpdateAsync(execId, (r) => {
1151
+ r.status = 'failed';
1152
+ r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1153
+ });
1154
+ return { run, outcome: 'failed', reason: run.error, executed };
1155
+ }
1156
+ try {
1157
+ run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1158
+ executed.push(nodeId);
1159
+ }
1160
+ catch (e) {
1161
+ run = this.loadRun(execId);
1162
+ return { run, outcome: 'failed', reason: e.message, executed };
1163
+ }
1164
+ if (run.status === 'completed')
1165
+ return { run, outcome: 'completed', executed };
1166
+ if (run.status === 'failed')
1167
+ return { run, outcome: 'failed', reason: run.error, executed };
1168
+ if (run.status === 'cancelled')
1169
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1170
+ if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1171
+ const next = node.next;
1172
+ if (!next) {
1173
+ return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
1174
+ }
1175
+ run = this.moveNode(execId, next);
1176
+ }
1177
+ }
1178
+ }
1179
+ cancelRun(execId, reason = 'Cancelled by user') {
1180
+ return this.atomicUpdate(execId, (run) => {
1181
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1182
+ return;
1183
+ }
1184
+ const now = new Date();
1185
+ run.status = 'cancelled';
1186
+ run.error = reason;
1187
+ run.audit = run.audit ?? [];
1188
+ run.audit.push({
1189
+ exec_id: run.id,
1190
+ node_id: run.current_node || '',
1191
+ type: 'cancel',
1192
+ summary: reason,
1193
+ started_at: now.toISOString(),
1194
+ ended_at: now.toISOString(),
1195
+ duration_ms: 0,
1196
+ exit_code: null,
1197
+ error: reason,
1198
+ trigger: 'manual',
1199
+ });
736
1200
  });
737
1201
  }
738
1202
  reset(execId) {