@crewx/workflow 0.3.21 → 0.3.22-rc.10

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,7 +33,7 @@ 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;
@@ -45,6 +45,112 @@ const cp = __importStar(require("child_process"));
45
45
  const sdk_1 = require("@crewx/sdk");
46
46
  const file_lock_1 = require("./file-lock");
47
47
  const output_format_1 = require("./utils/output-format");
48
+ const shell_security_1 = require("./utils/shell-security");
49
+ exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
50
+ const KNOWN_AUTO_NODE_TYPES = new Set([
51
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end',
52
+ ]);
53
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end']);
54
+ function killProcessGroup(child) {
55
+ try {
56
+ if (process.platform === 'win32') {
57
+ if (child.pid) {
58
+ cp.spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
59
+ }
60
+ }
61
+ else if (child.pid) {
62
+ try {
63
+ process.kill(-child.pid, 'SIGKILL');
64
+ }
65
+ catch {
66
+ try {
67
+ child.kill('SIGKILL');
68
+ }
69
+ catch { }
70
+ }
71
+ }
72
+ }
73
+ catch { }
74
+ }
75
+ async function runProcess(command, argv, opts) {
76
+ return new Promise((resolve) => {
77
+ let child;
78
+ try {
79
+ child = cp.spawn(command, argv, {
80
+ cwd: opts.cwd,
81
+ env: opts.env,
82
+ stdio: ['ignore', 'pipe', 'pipe'],
83
+ shell: opts.shell ?? false,
84
+ detached: process.platform !== 'win32',
85
+ windowsHide: opts.windowsHide ?? true,
86
+ });
87
+ }
88
+ catch (err) {
89
+ resolve({ status: null, stdout: '', stderr: '', timedOut: false, error: err });
90
+ return;
91
+ }
92
+ let stdout = '';
93
+ let stderr = '';
94
+ let settled = false;
95
+ const finish = (result) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ resolve(result);
101
+ };
102
+ const timer = setTimeout(() => {
103
+ killProcessGroup(child);
104
+ finish({ status: null, stdout, stderr, timedOut: true });
105
+ }, opts.timeout);
106
+ if (typeof timer.unref === 'function')
107
+ timer.unref();
108
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
109
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
110
+ child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
111
+ child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
112
+ });
113
+ }
114
+ function maskArg(arg) {
115
+ return arg.replace(/^(--?[\w-]*(?:token|secret|password|passwd|key|auth)[\w-]*)=(.+)$/i, '$1=***');
116
+ }
117
+ function summarizeNode(node) {
118
+ switch (node.type) {
119
+ case 'agent_task':
120
+ return `@${node.agent ?? '?'} (${node.mode === 'query' ? 'q' : 'x'})`;
121
+ case 'skill_task':
122
+ return `skill ${node.skill ?? '?'} ${(node.args ?? []).map(maskArg).join(' ')}`.trim();
123
+ case 'shell_task':
124
+ return (node.command ?? []).map(maskArg).join(' ');
125
+ case 'branch':
126
+ return `branch: ${node.condition ?? ''}`;
127
+ case 'expression':
128
+ return `set ${Object.keys(node.set ?? {}).join(', ')}`;
129
+ default:
130
+ return node.type;
131
+ }
132
+ }
133
+ async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
134
+ if (!nodeSpec.output)
135
+ return;
136
+ const trimmed = stdout.trim();
137
+ if (nodeSpec.output_format === 'json') {
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse((0, output_format_1.extractJson)(trimmed));
141
+ }
142
+ catch (e) {
143
+ throw new Error(`Node "${nodeId}" output_format=json parse failed: ${e.message}`);
144
+ }
145
+ if (nodeSpec.output_schema) {
146
+ await (0, output_format_1.validateJsonSchema)(parsed, nodeSpec.output_schema);
147
+ }
148
+ stateUpdates[nodeSpec.output] = parsed;
149
+ }
150
+ else {
151
+ stateUpdates[nodeSpec.output] = trimmed;
152
+ }
153
+ }
48
154
  function parseValue(raw) {
49
155
  try {
50
156
  return JSON.parse(raw);
@@ -360,10 +466,11 @@ function safeEvaluate(expr, state) {
360
466
  return parser.parse();
361
467
  }
362
468
  class RunManager {
363
- constructor(runsDir = '.crewx/workflow-runs') {
469
+ constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
364
470
  this.runsDir = runsDir;
365
- const base = process.env.CREWX_WORKSPACE || process.cwd();
471
+ const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
366
472
  this.resolvedDir = path.resolve(base, this.runsDir);
473
+ this.projectRoot = path.resolve(base);
367
474
  }
368
475
  ensureRunsDir() {
369
476
  if (!fs.existsSync(this.resolvedDir)) {
@@ -522,6 +629,26 @@ class RunManager {
522
629
  let currentNode = nodeId;
523
630
  let status;
524
631
  let nodeTaskId;
632
+ const trigger = opts?.trigger ?? 'manual';
633
+ const startedAt = new Date();
634
+ let nodeStdout = '';
635
+ let nodeStderr = '';
636
+ let nodeExitCode = null;
637
+ const buildAudit = (error) => {
638
+ const endedAt = new Date();
639
+ return {
640
+ exec_id: snapshot.id,
641
+ node_id: nodeId,
642
+ type: nodeSpec.type,
643
+ summary: summarizeNode(nodeSpec),
644
+ started_at: startedAt.toISOString(),
645
+ ended_at: endedAt.toISOString(),
646
+ duration_ms: endedAt.getTime() - startedAt.getTime(),
647
+ exit_code: nodeExitCode,
648
+ ...(error ? { error } : {}),
649
+ trigger,
650
+ };
651
+ };
525
652
  try {
526
653
  switch (nodeSpec.type) {
527
654
  case 'agent_task': {
@@ -545,7 +672,7 @@ class RunManager {
545
672
  const cliParts = crewxCli.split(/\s+/);
546
673
  const bin = cliParts[0];
547
674
  const baseArgs = cliParts.slice(1);
548
- const nodeTimeout = Number(process.env[mode === 'q' ? 'CREWX_TIMEOUT_QUERY' : 'CREWX_TIMEOUT_EXECUTE']) || 3600000;
675
+ const nodeTimeout = Number(process.env[mode === 'q' ? 'CREWX_TIMEOUT_QUERY' : 'CREWX_TIMEOUT_EXECUTE']) || 8 * 3600000;
549
676
  const wfArgs = [...baseArgs, mode, prompt];
550
677
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
551
678
  wfArgs.push(`--thread=workflow:${snapshot.id}`);
@@ -564,30 +691,34 @@ class RunManager {
564
691
  packageName: 'crewx',
565
692
  });
566
693
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
567
- const result = cp.spawnSync(invocation.command, invocation.argv, {
568
- encoding: 'utf-8',
569
- timeout: nodeTimeout,
694
+ const result = await runProcess(invocation.command, invocation.argv, {
570
695
  cwd: process.cwd(),
571
- stdio: ['inherit', 'pipe', 'pipe'],
572
- shell: invocation.shell ?? false,
573
- windowsHide: invocation.windowsHide,
574
696
  env: {
575
697
  ...process.env,
576
698
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
577
699
  CREWX_WORKFLOW_NODE_ID: nodeId,
578
700
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
579
701
  },
702
+ timeout: nodeTimeout,
703
+ shell: invocation.shell ?? false,
704
+ windowsHide: invocation.windowsHide,
580
705
  });
706
+ nodeStdout = result.stdout;
707
+ nodeStderr = result.stderr;
708
+ nodeExitCode = result.status;
581
709
  if (result.error) {
582
710
  throw result.error;
583
711
  }
584
- const taskIdMatch = result.stderr?.match(/crewx kill (tsk_\w+)/);
712
+ if (result.timedOut) {
713
+ throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
714
+ }
715
+ const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
585
716
  nodeTaskId = taskIdMatch?.[1];
586
717
  if (result.status !== 0) {
587
- const stderr = result.stderr?.trim() ?? '';
718
+ const stderr = result.stderr.trim();
588
719
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
589
720
  }
590
- const output = result.stdout?.trim() ?? '';
721
+ const output = result.stdout.trim();
591
722
  if (nodeSpec.output) {
592
723
  if (nodeSpec.output_format === 'json') {
593
724
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -607,26 +738,29 @@ class RunManager {
607
738
  }
608
739
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
609
740
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
610
- const retryResult = cp.spawnSync(retryInvocation.command, retryInvocation.argv, {
611
- encoding: 'utf-8',
612
- timeout: nodeTimeout,
741
+ const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
613
742
  cwd: process.cwd(),
614
- stdio: ['inherit', 'pipe', 'pipe'],
615
- shell: retryInvocation.shell ?? false,
616
- windowsHide: retryInvocation.windowsHide,
617
743
  env: {
618
744
  ...process.env,
619
745
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
620
746
  CREWX_WORKFLOW_NODE_ID: nodeId,
621
747
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
622
748
  },
749
+ timeout: nodeTimeout,
750
+ shell: retryInvocation.shell ?? false,
751
+ windowsHide: retryInvocation.windowsHide,
623
752
  });
753
+ nodeStdout = retryResult.stdout;
754
+ nodeStderr = retryResult.stderr;
755
+ nodeExitCode = retryResult.status;
624
756
  if (retryResult.error)
625
757
  throw retryResult.error;
758
+ if (retryResult.timedOut)
759
+ throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
626
760
  if (retryResult.status !== 0) {
627
- throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr?.trim()}`);
761
+ throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
628
762
  }
629
- attemptOutput = retryResult.stdout?.trim() ?? '';
763
+ attemptOutput = retryResult.stdout.trim();
630
764
  }
631
765
  try {
632
766
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -652,6 +786,118 @@ class RunManager {
652
786
  }
653
787
  break;
654
788
  }
789
+ case 'skill_task': {
790
+ if (!nodeSpec.skill) {
791
+ throw new Error(`Node "${nodeId}" is skill_task but has no skill defined`);
792
+ }
793
+ if (!shell_security_1.SKILL_NAME_RE.test(nodeSpec.skill)) {
794
+ throw new Error(`Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
795
+ }
796
+ const ctx = { ...snapshot.state, state: snapshot.state };
797
+ const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
798
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
799
+ if (!cwdCheck.ok)
800
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
801
+ const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
802
+ if (opts?.dryRun) {
803
+ console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
804
+ break;
805
+ }
806
+ console.log(`Executing skill: ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
807
+ const crewxCli = process.env.CREWX_CLI || 'npx crewx';
808
+ const cliParts = crewxCli.split(/\s+/);
809
+ const bin = cliParts[0];
810
+ const baseArgs = cliParts.slice(1);
811
+ const skillArgv = [...baseArgs, 'skill', nodeSpec.skill, ...skillArgs];
812
+ const program = (0, sdk_1.resolveWindowsSpawnProgram)({
813
+ command: bin,
814
+ allowShellFallback: true,
815
+ packageName: 'crewx',
816
+ });
817
+ const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
818
+ const result = await runProcess(invocation.command, invocation.argv, {
819
+ cwd: cwdCheck.resolved,
820
+ env: {
821
+ ...process.env,
822
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
823
+ CREWX_WORKFLOW_NODE_ID: nodeId,
824
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
825
+ },
826
+ timeout,
827
+ shell: invocation.shell ?? false,
828
+ windowsHide: invocation.windowsHide,
829
+ });
830
+ nodeStdout = result.stdout;
831
+ nodeStderr = result.stderr;
832
+ nodeExitCode = result.status;
833
+ if (result.error)
834
+ throw result.error;
835
+ if (result.timedOut)
836
+ throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
837
+ if (result.status !== 0) {
838
+ throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
839
+ }
840
+ await storeNodeOutput(nodeSpec, nodeId, result.stdout, stateUpdates);
841
+ if (nodeSpec.output) {
842
+ console.log(`Result stored in state.${nodeSpec.output}`);
843
+ }
844
+ break;
845
+ }
846
+ case 'shell_task': {
847
+ const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
848
+ const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
849
+ if (!shellEnvEnabled || !shellMetaAllowed) {
850
+ throw new Error('shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
851
+ }
852
+ const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
853
+ if (!cmdCheck.ok)
854
+ throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
855
+ const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
856
+ if (!envCheck.ok)
857
+ throw new Error(`Node "${nodeId}": ${envCheck.error}`);
858
+ const ctx = { ...snapshot.state, state: snapshot.state };
859
+ const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
860
+ const postCheck = (0, shell_security_1.validateShellCommand)(command);
861
+ if (!postCheck.ok)
862
+ throw new Error(`Node "${nodeId}": ${postCheck.error}`);
863
+ const interpolatedEnv = nodeSpec.env
864
+ ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
865
+ : undefined;
866
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
867
+ if (!cwdCheck.ok)
868
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
869
+ const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
870
+ if (opts?.dryRun) {
871
+ console.log(`[dry-run] Would execute: ${command.join(' ')}`);
872
+ break;
873
+ }
874
+ console.log(`Executing shell_task: ${command[0]} (${command.length - 1} args)`);
875
+ const childEnv = (0, shell_security_1.buildShellEnv)(interpolatedEnv, process.env);
876
+ childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
877
+ childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
878
+ childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
879
+ const result = await runProcess(command[0], command.slice(1), {
880
+ cwd: cwdCheck.resolved,
881
+ env: childEnv,
882
+ timeout,
883
+ shell: false,
884
+ });
885
+ nodeStdout = result.stdout;
886
+ nodeStderr = result.stderr;
887
+ nodeExitCode = result.status;
888
+ if (result.error)
889
+ throw result.error;
890
+ if (result.timedOut)
891
+ throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
892
+ if (result.status !== 0) {
893
+ throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
894
+ }
895
+ await storeNodeOutput(nodeSpec, nodeId, result.stdout, stateUpdates);
896
+ if (nodeSpec.output) {
897
+ console.log(`Result stored in state.${nodeSpec.output}`);
898
+ }
899
+ break;
900
+ }
655
901
  case 'branch': {
656
902
  if (!nodeSpec.condition) {
657
903
  throw new Error(`Branch node "${nodeId}" has no condition`);
@@ -710,13 +956,27 @@ class RunManager {
710
956
  }
711
957
  }
712
958
  catch (e) {
959
+ const errMessage = e.message;
960
+ const partial = [
961
+ nodeStdout.trim() ? `stdout: ${nodeStdout.trim().slice(-500)}` : '',
962
+ nodeStderr.trim() ? `stderr: ${nodeStderr.trim().slice(-500)}` : '',
963
+ ].filter(Boolean).join(' | ');
964
+ const auditEntry = buildAudit(partial ? `${errMessage} (${partial})` : errMessage);
713
965
  await this.atomicUpdateAsync(execId, (run) => {
966
+ if (run.status === 'cancelled')
967
+ return;
714
968
  run.current_node = nodeId;
715
969
  run.status = 'failed';
970
+ run.error = errMessage;
971
+ run.audit = run.audit ?? [];
972
+ run.audit.push(auditEntry);
716
973
  });
717
974
  throw e;
718
975
  }
976
+ const successAudit = buildAudit();
719
977
  return this.atomicUpdateAsync(execId, (run) => {
978
+ if (run.status === 'cancelled')
979
+ return;
720
980
  Object.assign(run.state, stateUpdates);
721
981
  for (const node of nodesToComplete) {
722
982
  if (!run.completed_nodes.includes(node)) {
@@ -733,6 +993,119 @@ class RunManager {
733
993
  run.tasks = run.tasks ?? {};
734
994
  run.tasks[nodeId] = nodeTaskId;
735
995
  }
996
+ run.audit = run.audit ?? [];
997
+ run.audit.push(successAudit);
998
+ });
999
+ }
1000
+ 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
+ };
1019
+ }
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 };
1052
+ }
1053
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1054
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1055
+ }
1056
+ visitCounts[nodeId] = (visitCounts[nodeId] ?? 0) + 1;
1057
+ iterations++;
1058
+ if (visitCounts[nodeId] > maxIterations || iterations > maxIterations) {
1059
+ run = await this.atomicUpdateAsync(execId, (r) => {
1060
+ r.status = 'failed';
1061
+ r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1062
+ });
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 };
1083
+ }
1084
+ run = this.moveNode(execId, next);
1085
+ }
1086
+ }
1087
+ }
1088
+ cancelRun(execId, reason = 'Cancelled by user') {
1089
+ return this.atomicUpdate(execId, (run) => {
1090
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1091
+ return;
1092
+ }
1093
+ const now = new Date();
1094
+ run.status = 'cancelled';
1095
+ run.error = reason;
1096
+ run.audit = run.audit ?? [];
1097
+ run.audit.push({
1098
+ exec_id: run.id,
1099
+ node_id: run.current_node || '',
1100
+ type: 'cancel',
1101
+ summary: reason,
1102
+ started_at: now.toISOString(),
1103
+ ended_at: now.toISOString(),
1104
+ duration_ms: 0,
1105
+ exit_code: null,
1106
+ error: reason,
1107
+ trigger: 'manual',
1108
+ });
736
1109
  });
737
1110
  }
738
1111
  reset(execId) {