@crewx/workflow 0.3.22-rc.1 → 0.3.22-rc.100

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 = 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"));
@@ -45,6 +48,117 @@ const cp = __importStar(require("child_process"));
45
48
  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");
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; } });
54
+ exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
55
+ const KNOWN_AUTO_NODE_TYPES = new Set([
56
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
57
+ ]);
58
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end', 'error']);
59
+ function killProcessGroup(child) {
60
+ try {
61
+ if (process.platform === 'win32') {
62
+ if (child.pid) {
63
+ cp.spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
64
+ }
65
+ }
66
+ else if (child.pid) {
67
+ try {
68
+ process.kill(-child.pid, 'SIGKILL');
69
+ }
70
+ catch {
71
+ try {
72
+ child.kill('SIGKILL');
73
+ }
74
+ catch { }
75
+ }
76
+ }
77
+ }
78
+ catch { }
79
+ }
80
+ async function runProcess(command, argv, opts) {
81
+ return new Promise((resolve) => {
82
+ let child;
83
+ try {
84
+ child = cp.spawn(command, argv, {
85
+ cwd: opts.cwd,
86
+ env: opts.env,
87
+ stdio: [opts.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
88
+ shell: opts.shell ?? false,
89
+ detached: process.platform !== 'win32',
90
+ windowsHide: opts.windowsHide ?? true,
91
+ });
92
+ }
93
+ catch (err) {
94
+ resolve({ status: null, stdout: '', stderr: '', timedOut: false, error: err });
95
+ return;
96
+ }
97
+ let stdout = '';
98
+ let stderr = '';
99
+ let settled = false;
100
+ const finish = (result) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ resolve(result);
106
+ };
107
+ const timer = setTimeout(() => {
108
+ killProcessGroup(child);
109
+ finish({ status: null, stdout, stderr, timedOut: true });
110
+ }, opts.timeout);
111
+ if (typeof timer.unref === 'function')
112
+ timer.unref();
113
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
114
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
115
+ if (opts.stdin !== undefined) {
116
+ child.stdin?.end(opts.stdin);
117
+ }
118
+ child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
119
+ child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
120
+ });
121
+ }
122
+ function maskArg(arg) {
123
+ return arg.replace(/^(--?[\w-]*(?:token|secret|password|passwd|key|auth)[\w-]*)=(.+)$/i, '$1=***');
124
+ }
125
+ function summarizeNode(node) {
126
+ switch (node.type) {
127
+ case 'agent_task':
128
+ return `@${node.agent ?? '?'} (${node.mode === 'query' ? 'q' : 'x'})`;
129
+ case 'skill_task':
130
+ return `skill ${node.skill ?? '?'} ${(node.args ?? []).map(maskArg).join(' ')}`.trim();
131
+ case 'shell_task':
132
+ return (node.command ?? []).map(maskArg).join(' ');
133
+ case 'branch':
134
+ return `branch: ${node.condition ?? ''}`;
135
+ case 'expression':
136
+ return `set ${Object.keys(node.set ?? {}).join(', ')}`;
137
+ default:
138
+ return node.type;
139
+ }
140
+ }
141
+ async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
142
+ if (!nodeSpec.output)
143
+ return;
144
+ const trimmed = stdout.trim();
145
+ if (nodeSpec.output_format === 'json') {
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse((0, output_format_1.extractJson)(trimmed));
149
+ }
150
+ catch (e) {
151
+ throw new Error(`Node "${nodeId}" output_format=json parse failed: ${e.message}`);
152
+ }
153
+ if (nodeSpec.output_schema) {
154
+ await (0, output_format_1.validateJsonSchema)(parsed, nodeSpec.output_schema);
155
+ }
156
+ stateUpdates[nodeSpec.output] = parsed;
157
+ }
158
+ else {
159
+ stateUpdates[nodeSpec.output] = trimmed;
160
+ }
161
+ }
48
162
  function parseValue(raw) {
49
163
  try {
50
164
  return JSON.parse(raw);
@@ -104,6 +218,7 @@ const FORBIDDEN_IDENTS = new Set([
104
218
  'require', 'import', 'process', 'global', 'globalThis',
105
219
  'Function', 'eval', '__proto__', 'constructor', 'prototype',
106
220
  ]);
221
+ const FORBIDDEN_STATE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
107
222
  function tokenize(expr) {
108
223
  const tokens = [];
109
224
  let i = 0;
@@ -337,6 +452,8 @@ class ExprParser {
337
452
  const val = this.state[key];
338
453
  if (val === undefined || val === null)
339
454
  return 0;
455
+ if (typeof val === 'boolean' || typeof val === 'number')
456
+ return val;
340
457
  const strVal = String(val);
341
458
  if (strVal === '')
342
459
  return '';
@@ -359,11 +476,97 @@ function safeEvaluate(expr, state) {
359
476
  const parser = new ExprParser(tokens, state);
360
477
  return parser.parse();
361
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
+ }
362
559
  class RunManager {
363
- constructor(runsDir = '.crewx/workflow-runs') {
560
+ constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
364
561
  this.runsDir = runsDir;
365
- const base = process.env.CREWX_WORKSPACE || process.cwd();
562
+ const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
366
563
  this.resolvedDir = path.resolve(base, this.runsDir);
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
+ }
367
570
  }
368
571
  ensureRunsDir() {
369
572
  if (!fs.existsSync(this.resolvedDir)) {
@@ -438,6 +641,12 @@ class RunManager {
438
641
  return null;
439
642
  return doc;
440
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
+ }
441
650
  start(yamlPath, workflowId, overrides) {
442
651
  const doc = this.loadWorkflowYaml(yamlPath);
443
652
  if (!doc) {
@@ -521,26 +730,67 @@ class RunManager {
521
730
  const nodesToComplete = [];
522
731
  let currentNode = nodeId;
523
732
  let status;
733
+ let failureMessage;
524
734
  let nodeTaskId;
735
+ const trigger = opts?.trigger ?? 'manual';
736
+ const startedAt = new Date();
737
+ let nodeStdout = '';
738
+ let nodeStderr = '';
739
+ let nodeExitCode = null;
740
+ const buildAudit = (error) => {
741
+ const endedAt = new Date();
742
+ return {
743
+ exec_id: snapshot.id,
744
+ node_id: nodeId,
745
+ type: nodeSpec.type,
746
+ summary: summarizeNode(nodeSpec),
747
+ started_at: startedAt.toISOString(),
748
+ ended_at: endedAt.toISOString(),
749
+ duration_ms: endedAt.getTime() - startedAt.getTime(),
750
+ exit_code: nodeExitCode,
751
+ ...(error ? { error } : {}),
752
+ trigger,
753
+ };
754
+ };
525
755
  try {
526
756
  switch (nodeSpec.type) {
527
757
  case 'agent_task': {
528
758
  if (!nodeSpec.agent) {
529
759
  throw new Error(`Node "${nodeId}" is agent_task but has no agent defined`);
530
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;
531
774
  const input = nodeSpec.input
532
- ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
775
+ ? interpolateTemplate(nodeSpec.input, agentCtx)
533
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
+ });
534
784
  const finalInput = nodeSpec.output_format === 'json'
535
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
536
- : input;
785
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
786
+ : preamble + input;
537
787
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
538
- const prompt = `@${nodeSpec.agent} ${finalInput}`;
788
+ const prompt = `@${resolvedAgent} ${finalInput}`;
539
789
  if (opts?.dryRun) {
540
- console.log(`[dry-run] Would execute: crewx ${mode} "@${nodeSpec.agent} ..."`);
790
+ console.log(`[dry-run] Would execute: crewx ${mode} "@${resolvedAgent} ..."`);
541
791
  break;
542
792
  }
543
- console.log(`Executing: @${nodeSpec.agent} (${mode} mode)`);
793
+ console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
544
794
  const crewxCli = process.env.CREWX_CLI || 'npx crewx';
545
795
  const cliParts = crewxCli.split(/\s+/);
546
796
  const bin = cliParts[0];
@@ -564,30 +814,39 @@ class RunManager {
564
814
  packageName: 'crewx',
565
815
  });
566
816
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
567
- const result = cp.spawnSync(invocation.command, invocation.argv, {
568
- encoding: 'utf-8',
569
- timeout: nodeTimeout,
570
- cwd: process.cwd(),
571
- stdio: ['inherit', 'pipe', 'pipe'],
572
- shell: invocation.shell ?? false,
573
- windowsHide: invocation.windowsHide,
817
+ const result = await runProcess(invocation.command, invocation.argv, {
818
+ cwd: nodeCwd,
574
819
  env: {
575
820
  ...process.env,
821
+ CREWX_WORKSPACE: workspaceRoot,
576
822
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
577
823
  CREWX_WORKFLOW_NODE_ID: nodeId,
578
824
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
579
825
  },
826
+ timeout: nodeTimeout,
827
+ shell: invocation.shell ?? false,
828
+ windowsHide: invocation.windowsHide,
580
829
  });
830
+ nodeStdout = result.stdout;
831
+ nodeStderr = result.stderr;
832
+ nodeExitCode = result.status;
581
833
  if (result.error) {
582
834
  throw result.error;
583
835
  }
584
- const taskIdMatch = result.stderr?.match(/crewx kill (tsk_\w+)/);
836
+ if (result.timedOut) {
837
+ throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
838
+ }
839
+ const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
585
840
  nodeTaskId = taskIdMatch?.[1];
586
841
  if (result.status !== 0) {
587
- const stderr = result.stderr?.trim() ?? '';
842
+ const stderr = result.stderr.trim();
588
843
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
589
844
  }
590
- const output = result.stdout?.trim() ?? '';
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
+ }
591
850
  if (nodeSpec.output) {
592
851
  if (nodeSpec.output_format === 'json') {
593
852
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -600,33 +859,41 @@ class RunManager {
600
859
  retryInput = finalInput +
601
860
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
602
861
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
603
- const retryPrompt = `@${nodeSpec.agent} ${retryInput}`;
862
+ const retryPrompt = `@${resolvedAgent} ${retryInput}`;
604
863
  const retryArgs = [...baseArgs, mode, retryPrompt];
605
864
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
606
865
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
607
866
  }
608
867
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
609
868
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
610
- const retryResult = cp.spawnSync(retryInvocation.command, retryInvocation.argv, {
611
- encoding: 'utf-8',
612
- timeout: nodeTimeout,
613
- cwd: process.cwd(),
614
- stdio: ['inherit', 'pipe', 'pipe'],
615
- shell: retryInvocation.shell ?? false,
616
- windowsHide: retryInvocation.windowsHide,
869
+ const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
870
+ cwd: nodeCwd,
617
871
  env: {
618
872
  ...process.env,
873
+ CREWX_WORKSPACE: workspaceRoot,
619
874
  CREWX_WORKFLOW_EXEC_ID: snapshot.id,
620
875
  CREWX_WORKFLOW_NODE_ID: nodeId,
621
876
  CREWX_WORKFLOW_ID: snapshot.workflow_id,
622
877
  },
878
+ timeout: nodeTimeout,
879
+ shell: retryInvocation.shell ?? false,
880
+ windowsHide: retryInvocation.windowsHide,
623
881
  });
882
+ nodeStdout = retryResult.stdout;
883
+ nodeStderr = retryResult.stderr;
884
+ nodeExitCode = retryResult.status;
624
885
  if (retryResult.error)
625
886
  throw retryResult.error;
887
+ if (retryResult.timedOut)
888
+ throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
626
889
  if (retryResult.status !== 0) {
627
- throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr?.trim()}`);
890
+ throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
891
+ }
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)'}`);
628
896
  }
629
- attemptOutput = retryResult.stdout?.trim() ?? '';
630
897
  }
631
898
  try {
632
899
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -652,12 +919,136 @@ class RunManager {
652
919
  }
653
920
  break;
654
921
  }
922
+ case 'skill_task': {
923
+ if (!nodeSpec.skill) {
924
+ throw new Error(`Node "${nodeId}" is skill_task but has no skill defined`);
925
+ }
926
+ if (!shell_security_1.SKILL_NAME_RE.test(nodeSpec.skill)) {
927
+ throw new Error(`Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
928
+ }
929
+ const ctx = { ...snapshot.state, state: snapshot.state };
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;
934
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
935
+ if (!cwdCheck.ok)
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;
941
+ const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
942
+ if (opts?.dryRun) {
943
+ console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
944
+ break;
945
+ }
946
+ console.log(`Executing skill: ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
947
+ const crewxCli = process.env.CREWX_CLI || 'npx crewx';
948
+ const cliParts = crewxCli.split(/\s+/);
949
+ const bin = cliParts[0];
950
+ const baseArgs = cliParts.slice(1);
951
+ const skillArgv = [...baseArgs, 'skill', nodeSpec.skill, ...skillArgs];
952
+ const program = (0, sdk_1.resolveWindowsSpawnProgram)({
953
+ command: bin,
954
+ allowShellFallback: true,
955
+ packageName: 'crewx',
956
+ });
957
+ const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
958
+ const result = await runProcess(invocation.command, invocation.argv, {
959
+ cwd: cwdCheck.resolved,
960
+ env: {
961
+ ...process.env,
962
+ CREWX_WORKSPACE: skillWorkspaceRoot,
963
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
964
+ CREWX_WORKFLOW_NODE_ID: nodeId,
965
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
966
+ },
967
+ timeout,
968
+ shell: invocation.shell ?? false,
969
+ windowsHide: invocation.windowsHide,
970
+ stdin: skillStdin,
971
+ });
972
+ nodeStdout = result.stdout;
973
+ nodeStderr = result.stderr;
974
+ nodeExitCode = result.status;
975
+ if (result.error)
976
+ throw result.error;
977
+ if (result.timedOut)
978
+ throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
979
+ if (result.status !== 0) {
980
+ throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
981
+ }
982
+ await storeNodeOutput(nodeSpec, nodeId, result.stdout, stateUpdates);
983
+ if (nodeSpec.output) {
984
+ console.log(`Result stored in state.${nodeSpec.output}`);
985
+ }
986
+ break;
987
+ }
988
+ case 'shell_task': {
989
+ const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
990
+ const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
991
+ if (!shellEnvEnabled || !shellMetaAllowed) {
992
+ throw new Error('shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
993
+ }
994
+ const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
995
+ if (!cmdCheck.ok)
996
+ throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
997
+ const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
998
+ if (!envCheck.ok)
999
+ throw new Error(`Node "${nodeId}": ${envCheck.error}`);
1000
+ const ctx = { ...snapshot.state, state: snapshot.state };
1001
+ const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
1002
+ const postCheck = (0, shell_security_1.validateShellCommand)(command);
1003
+ if (!postCheck.ok)
1004
+ throw new Error(`Node "${nodeId}": ${postCheck.error}`);
1005
+ const interpolatedEnv = nodeSpec.env
1006
+ ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
1007
+ : undefined;
1008
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
1009
+ if (!cwdCheck.ok)
1010
+ throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1011
+ const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
1012
+ if (opts?.dryRun) {
1013
+ console.log(`[dry-run] Would execute: ${command.join(' ')}`);
1014
+ break;
1015
+ }
1016
+ console.log(`Executing shell_task: ${command[0]} (${command.length - 1} args)`);
1017
+ const childEnv = (0, shell_security_1.buildShellEnv)(interpolatedEnv, process.env);
1018
+ childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
1019
+ childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
1020
+ childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
1021
+ const shellStdin = nodeSpec.stdin !== undefined
1022
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1023
+ : undefined;
1024
+ const result = await runProcess(command[0], command.slice(1), {
1025
+ cwd: cwdCheck.resolved,
1026
+ env: childEnv,
1027
+ timeout,
1028
+ shell: false,
1029
+ stdin: shellStdin,
1030
+ });
1031
+ nodeStdout = result.stdout;
1032
+ nodeStderr = result.stderr;
1033
+ nodeExitCode = result.status;
1034
+ if (result.error)
1035
+ throw result.error;
1036
+ if (result.timedOut)
1037
+ throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1038
+ if (result.status !== 0) {
1039
+ throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
1040
+ }
1041
+ await storeNodeOutput(nodeSpec, nodeId, result.stdout, stateUpdates);
1042
+ if (nodeSpec.output) {
1043
+ console.log(`Result stored in state.${nodeSpec.output}`);
1044
+ }
1045
+ break;
1046
+ }
655
1047
  case 'branch': {
656
1048
  if (!nodeSpec.condition) {
657
1049
  throw new Error(`Branch node "${nodeId}" has no condition`);
658
1050
  }
659
- const evalFn = new Function('state', `return (${nodeSpec.condition});`);
660
- const condResult = String(evalFn(snapshot.state));
1051
+ const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
661
1052
  const branches = nodeSpec.branches;
662
1053
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
663
1054
  if (!targetNode) {
@@ -685,6 +1076,15 @@ class RunManager {
685
1076
  console.log(`End "${nodeId}": workflow execution completed`);
686
1077
  break;
687
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
+ }
688
1088
  case 'expression': {
689
1089
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
690
1090
  throw new Error(`Expression node "${nodeId}" requires "set" field`);
@@ -708,15 +1108,38 @@ class RunManager {
708
1108
  default:
709
1109
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
710
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
+ }
711
1119
  }
712
1120
  catch (e) {
1121
+ const errMessage = e.message;
1122
+ const partial = [
1123
+ nodeStdout.trim() ? `stdout: ${nodeStdout.trim().slice(-500)}` : '',
1124
+ nodeStderr.trim() ? `stderr: ${nodeStderr.trim().slice(-500)}` : '',
1125
+ ].filter(Boolean).join(' | ');
1126
+ const auditEntry = buildAudit(partial ? `${errMessage} (${partial})` : errMessage);
713
1127
  await this.atomicUpdateAsync(execId, (run) => {
1128
+ if (run.status === 'cancelled')
1129
+ return;
1130
+ Object.assign(run.state, stateUpdates);
714
1131
  run.current_node = nodeId;
715
1132
  run.status = 'failed';
1133
+ run.error = errMessage;
1134
+ run.audit = run.audit ?? [];
1135
+ run.audit.push(auditEntry);
716
1136
  });
717
1137
  throw e;
718
1138
  }
1139
+ const successAudit = buildAudit();
719
1140
  return this.atomicUpdateAsync(execId, (run) => {
1141
+ if (run.status === 'cancelled')
1142
+ return;
720
1143
  Object.assign(run.state, stateUpdates);
721
1144
  for (const node of nodesToComplete) {
722
1145
  if (!run.completed_nodes.includes(node)) {
@@ -729,10 +1152,150 @@ class RunManager {
729
1152
  run.current_node = currentNode;
730
1153
  if (status)
731
1154
  run.status = status;
1155
+ if (failureMessage)
1156
+ run.error = failureMessage;
732
1157
  if (nodeTaskId) {
733
1158
  run.tasks = run.tasks ?? {};
734
1159
  run.tasks[nodeId] = nodeTaskId;
735
1160
  }
1161
+ run.audit = run.audit ?? [];
1162
+ run.audit.push(successAudit);
1163
+ });
1164
+ }
1165
+ async runAuto(execId) {
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: [] };
1173
+ }
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}`);
1185
+ }
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
+ };
1194
+ }
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
+ }
1238
+ run = await this.atomicUpdateAsync(execId, (r) => {
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
+ }
1247
+ });
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);
1271
+ }
1272
+ }
1273
+ }
1274
+ finally {
1275
+ inFlightAutoRuns.delete(leaseKey);
1276
+ }
1277
+ }
1278
+ cancelRun(execId, reason = 'Cancelled by user') {
1279
+ return this.atomicUpdate(execId, (run) => {
1280
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1281
+ return;
1282
+ }
1283
+ const now = new Date();
1284
+ run.status = 'cancelled';
1285
+ run.error = reason;
1286
+ run.audit = run.audit ?? [];
1287
+ run.audit.push({
1288
+ exec_id: run.id,
1289
+ node_id: run.current_node || '',
1290
+ type: 'cancel',
1291
+ summary: reason,
1292
+ started_at: now.toISOString(),
1293
+ ended_at: now.toISOString(),
1294
+ duration_ms: 0,
1295
+ exit_code: null,
1296
+ error: reason,
1297
+ trigger: 'manual',
1298
+ });
736
1299
  });
737
1300
  }
738
1301
  reset(execId) {
@@ -742,6 +1305,42 @@ class RunManager {
742
1305
  run.completed_nodes = [];
743
1306
  });
744
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
+ }
745
1344
  listRuns(opts) {
746
1345
  this.ensureRunsDir();
747
1346
  const files = fs.readdirSync(this.resolvedDir).filter(f => f.endsWith('.json'));