@crewx/workflow 0.3.22-rc.137 → 0.3.22-rc.138

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 = exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = exports.WORKFLOW_RUN_INTERRUPTED_ERROR = exports.SKILL_TASK_DEFAULT_TIMEOUT = exports.FileLock = void 0;
36
+ exports.RunManager = exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = exports.WORKFLOW_RUN_INTERRUPTED_ERROR = exports.WORKFLOW_CANCEL_GRACE_MS = exports.SKILL_TASK_DEFAULT_TIMEOUT = exports.FileLock = void 0;
37
37
  exports.parseSetArgs = parseSetArgs;
38
38
  exports.readStdin = readStdin;
39
39
  exports.interpolateTemplate = interpolateTemplate;
@@ -55,6 +55,8 @@ const retry_policy_1 = require("./retry-policy");
55
55
  var file_lock_2 = require("./file-lock");
56
56
  Object.defineProperty(exports, "FileLock", { enumerable: true, get: function () { return file_lock_2.FileLock; } });
57
57
  exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
58
+ exports.WORKFLOW_CANCEL_GRACE_MS = 2000;
59
+ const WORKFLOW_CANCEL_POLL_INTERVAL_MS = 100;
58
60
  exports.WORKFLOW_RUN_INTERRUPTED_ERROR = 'Workflow run interrupted: runner process exited before completion';
59
61
  exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = 10 * 60000;
60
62
  const RETRY_WAIT_SLICE_MS = 1000;
@@ -80,20 +82,23 @@ function resolveCrewxChildInvocation(env, cwd) {
80
82
  },
81
83
  };
82
84
  }
83
- function killProcessGroup(child) {
85
+ function killProcessGroup(child, signal = 'SIGKILL') {
84
86
  try {
85
87
  if (process.platform === 'win32') {
86
88
  if (child.pid) {
87
- cp.spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
89
+ const args = ['/pid', String(child.pid), '/T'];
90
+ if (signal === 'SIGKILL')
91
+ args.push('/F');
92
+ cp.spawn('taskkill', args, { stdio: 'ignore' });
88
93
  }
89
94
  }
90
95
  else if (child.pid) {
91
96
  try {
92
- process.kill(-child.pid, 'SIGKILL');
97
+ process.kill(-child.pid, signal);
93
98
  }
94
99
  catch {
95
100
  try {
96
- child.kill('SIGKILL');
101
+ child.kill(signal);
97
102
  }
98
103
  catch { }
99
104
  }
@@ -103,6 +108,26 @@ function killProcessGroup(child) {
103
108
  }
104
109
  async function runProcess(command, argv, opts) {
105
110
  return new Promise((resolve) => {
111
+ const isCancelled = () => {
112
+ try {
113
+ return opts.cancellation?.isCancelled() === true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ };
119
+ if (isCancelled()) {
120
+ resolve({
121
+ status: null,
122
+ stdout: '',
123
+ stderr: '',
124
+ timedOut: false,
125
+ cancelled: true,
126
+ signal: null,
127
+ errorOrigin: null,
128
+ });
129
+ return;
130
+ }
106
131
  let child;
107
132
  try {
108
133
  child = cp.spawn(command, argv, {
@@ -131,20 +156,61 @@ async function runProcess(command, argv, opts) {
131
156
  let settled = false;
132
157
  let finishing = false;
133
158
  let spawned = false;
159
+ let cancellationRequested = false;
134
160
  let postSpawnError;
135
161
  let stdinError;
162
+ let timeoutTimer;
163
+ let cancellationEscalationTimer;
164
+ let cancellationPollTimer;
165
+ let activeController;
136
166
  const outDecoder = new sdk_1.ConsoleDecoder();
137
167
  const errDecoder = new sdk_1.ConsoleDecoder();
138
168
  let stdoutDecodeChain = Promise.resolve();
139
169
  let stderrDecodeChain = Promise.resolve();
170
+ const cleanup = () => {
171
+ if (timeoutTimer)
172
+ clearTimeout(timeoutTimer);
173
+ if (cancellationEscalationTimer)
174
+ clearTimeout(cancellationEscalationTimer);
175
+ if (cancellationPollTimer)
176
+ clearInterval(cancellationPollTimer);
177
+ if (opts.cancellation
178
+ && activeController
179
+ && activeProcessControllers.get(opts.cancellation.key) === activeController) {
180
+ activeProcessControllers.delete(opts.cancellation.key);
181
+ }
182
+ };
140
183
  const finish = (result) => {
141
184
  if (settled)
142
185
  return;
143
186
  settled = true;
144
- clearTimeout(timer);
145
- resolve(result);
187
+ cleanup();
188
+ resolve(cancellationRequested ? { ...result, cancelled: true, timedOut: false } : result);
146
189
  };
147
- const timer = setTimeout(() => {
190
+ const requestCancellation = () => {
191
+ if (settled || cancellationRequested)
192
+ return;
193
+ cancellationRequested = true;
194
+ killProcessGroup(child, 'SIGTERM');
195
+ cancellationEscalationTimer = setTimeout(() => {
196
+ if (!settled)
197
+ killProcessGroup(child, 'SIGKILL');
198
+ }, exports.WORKFLOW_CANCEL_GRACE_MS);
199
+ if (typeof cancellationEscalationTimer.unref === 'function') {
200
+ cancellationEscalationTimer.unref();
201
+ }
202
+ };
203
+ if (opts.cancellation) {
204
+ activeController = { cancel: requestCancellation };
205
+ activeProcessControllers.set(opts.cancellation.key, activeController);
206
+ cancellationPollTimer = setInterval(() => {
207
+ if (isCancelled())
208
+ requestCancellation();
209
+ }, WORKFLOW_CANCEL_POLL_INTERVAL_MS);
210
+ if (typeof cancellationPollTimer.unref === 'function')
211
+ cancellationPollTimer.unref();
212
+ }
213
+ timeoutTimer = setTimeout(() => {
148
214
  killProcessGroup(child);
149
215
  finishAfterDecode(() => ({
150
216
  status: null,
@@ -156,8 +222,8 @@ async function runProcess(command, argv, opts) {
156
222
  ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
157
223
  }));
158
224
  }, opts.timeout);
159
- if (typeof timer.unref === 'function')
160
- timer.unref();
225
+ if (typeof timeoutTimer.unref === 'function')
226
+ timeoutTimer.unref();
161
227
  child.stdout?.on('data', (d) => {
162
228
  stdoutDecodeChain = stdoutDecodeChain.then(async () => {
163
229
  stdout += await outDecoder.write(d);
@@ -216,6 +282,8 @@ async function runProcess(command, argv, opts) {
216
282
  errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
217
283
  ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
218
284
  })));
285
+ if (isCancelled())
286
+ requestCancellation();
219
287
  });
220
288
  }
221
289
  const AUDIT_IDENTIFIER_RE = /^[A-Za-z0-9_.-]{1,128}$/;
@@ -688,6 +756,13 @@ function readRunEngineState(run) {
688
756
  return { visitCounts, iterations };
689
757
  }
690
758
  const inFlightAutoRuns = new Set();
759
+ const activeProcessControllers = new Map();
760
+ function processControllerKey(resolvedDir, execId) {
761
+ return `${resolvedDir}::${execId}`;
762
+ }
763
+ function requestActiveProcessCancellation(resolvedDir, execId) {
764
+ activeProcessControllers.get(processControllerKey(resolvedDir, execId))?.cancel();
765
+ }
691
766
  function isProcessAlive(pid) {
692
767
  if (!Number.isInteger(pid) || pid <= 0)
693
768
  return false;
@@ -746,6 +821,7 @@ class RunManager {
746
821
  constructor(runsDir = '.crewx/workflow-runs', projectRoot, deps) {
747
822
  this.runsDir = runsDir;
748
823
  this.sleep = deps?.sleep ?? sleep;
824
+ this.onRunPersisted = deps?.onRunPersisted;
749
825
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
750
826
  this.resolvedDir = path.resolve(base, this.runsDir);
751
827
  this.projectRoot = path.resolve(base);
@@ -765,6 +841,20 @@ class RunManager {
765
841
  throw new Error(`Invalid execution ID: "${execId}"`);
766
842
  }
767
843
  }
844
+ isRunCancelled(execId) {
845
+ try {
846
+ return this.readRunRaw(execId)?.status === 'cancelled';
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ }
852
+ processCancellation(execId) {
853
+ return {
854
+ key: processControllerKey(this.resolvedDir, execId),
855
+ isCancelled: () => this.isRunCancelled(execId),
856
+ };
857
+ }
768
858
  readRunRaw(execId) {
769
859
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
770
860
  if (!fs.existsSync(filePath))
@@ -777,6 +867,7 @@ class RunManager {
777
867
  }
778
868
  }
779
869
  readRun(execId) {
870
+ this.validateExecId(execId);
780
871
  const run = this.readRunRaw(execId);
781
872
  return run ? normalizeRunStatus(run) : null;
782
873
  }
@@ -797,6 +888,24 @@ class RunManager {
797
888
  const node = spec?.nodes?.[run.current_node];
798
889
  return Boolean(node && KNOWN_AUTO_NODE_TYPES.has(node.type));
799
890
  }
891
+ withRunLock(execId, callback) {
892
+ this.validateExecId(execId);
893
+ const filePath = path.join(this.resolvedDir, `${execId}.json`);
894
+ if (!fs.existsSync(filePath)) {
895
+ throw new Error(`Execution not found: ${execId}`);
896
+ }
897
+ const lock = new file_lock_1.FileLock(filePath);
898
+ lock.acquire();
899
+ try {
900
+ const run = this.readRunRaw(execId);
901
+ if (!run)
902
+ throw new Error(`Execution not found: ${execId}`);
903
+ return callback(normalizeRunStatus(run));
904
+ }
905
+ finally {
906
+ lock.release();
907
+ }
908
+ }
800
909
  hasStaleRunner(run) {
801
910
  if (run.auto_run)
802
911
  return !isProcessAlive(run.auto_run.pid);
@@ -849,7 +958,53 @@ class RunManager {
849
958
  this.ensureRunsDir();
850
959
  run.updated_at = new Date().toISOString();
851
960
  const filePath = path.join(this.resolvedDir, `${run.id}.json`);
852
- fs.writeFileSync(filePath, JSON.stringify(run, null, 2) + '\n', 'utf-8');
961
+ let existingMode;
962
+ try {
963
+ existingMode = fs.statSync(filePath).mode & 0o7777;
964
+ }
965
+ catch (error) {
966
+ if (error.code !== 'ENOENT')
967
+ throw error;
968
+ }
969
+ const tempPath = path.join(this.resolvedDir, `.${run.id}.tmp-${process.pid}-${(0, sdk_1.generateId)('wfr')}`);
970
+ const serialized = JSON.stringify(run, null, 2) + '\n';
971
+ let tempFd;
972
+ let tempCreated = false;
973
+ try {
974
+ tempFd = fs.openSync(tempPath, 'wx');
975
+ tempCreated = true;
976
+ fs.writeFileSync(tempFd, serialized, 'utf-8');
977
+ if (existingMode !== undefined)
978
+ fs.fchmodSync(tempFd, existingMode);
979
+ fs.closeSync(tempFd);
980
+ tempFd = undefined;
981
+ fs.renameSync(tempPath, filePath);
982
+ tempCreated = false;
983
+ }
984
+ finally {
985
+ if (tempFd !== undefined) {
986
+ try {
987
+ fs.closeSync(tempFd);
988
+ }
989
+ catch {
990
+ }
991
+ }
992
+ if (tempCreated) {
993
+ try {
994
+ fs.unlinkSync(tempPath);
995
+ }
996
+ catch {
997
+ }
998
+ }
999
+ }
1000
+ if (!this.onRunPersisted)
1001
+ return;
1002
+ try {
1003
+ this.onRunPersisted(run);
1004
+ }
1005
+ catch (error) {
1006
+ console.warn(`[RunManager] onRunPersisted failed for ${run.id}: ${getErrorMessage(error)}`);
1007
+ }
853
1008
  }
854
1009
  atomicUpdate(execId, updater) {
855
1010
  this.validateExecId(execId);
@@ -984,6 +1139,8 @@ class RunManager {
984
1139
  }
985
1140
  moveNode(execId, nodeId) {
986
1141
  return this.atomicUpdate(execId, (run) => {
1142
+ if (run.status === 'cancelled')
1143
+ return;
987
1144
  if (run.current_node && !run.completed_nodes.includes(run.current_node)) {
988
1145
  run.completed_nodes.push(run.current_node);
989
1146
  }
@@ -995,6 +1152,9 @@ class RunManager {
995
1152
  if (!snapshot) {
996
1153
  throw new Error(`Execution not found: ${execId}`);
997
1154
  }
1155
+ if (snapshot.status === 'cancelled') {
1156
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Workflow run "${execId}" is already cancelled`);
1157
+ }
998
1158
  const doc = this.loadWorkflowYaml(snapshot.workflow_file);
999
1159
  if (!doc) {
1000
1160
  throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${snapshot.workflow_file}`);
@@ -1133,8 +1293,12 @@ class RunManager {
1133
1293
  timeout: nodeTimeout,
1134
1294
  shell: invocation.shell ?? false,
1135
1295
  windowsHide: invocation.windowsHide,
1296
+ cancellation: this.processCancellation(execId),
1136
1297
  });
1137
1298
  recordProcessResult(result);
1299
+ if (result.cancelled) {
1300
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent execution cancelled for node "${nodeId}"`);
1301
+ }
1138
1302
  if (result.timedOut) {
1139
1303
  throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
1140
1304
  }
@@ -1184,8 +1348,12 @@ class RunManager {
1184
1348
  timeout: nodeTimeout,
1185
1349
  shell: retryInvocation.shell ?? false,
1186
1350
  windowsHide: retryInvocation.windowsHide,
1351
+ cancellation: this.processCancellation(execId),
1187
1352
  });
1188
1353
  recordProcessResult(retryResult);
1354
+ if (retryResult.cancelled) {
1355
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent retry cancelled for node "${nodeId}"`);
1356
+ }
1189
1357
  if (retryResult.timedOut)
1190
1358
  throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
1191
1359
  if (retryResult.error)
@@ -1273,8 +1441,12 @@ class RunManager {
1273
1441
  shell: invocation.shell ?? false,
1274
1442
  windowsHide: invocation.windowsHide,
1275
1443
  stdin: skillStdin,
1444
+ cancellation: this.processCancellation(execId),
1276
1445
  });
1277
1446
  recordProcessResult(result);
1447
+ if (result.cancelled) {
1448
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `skill_task "${nodeId}" was cancelled`);
1449
+ }
1278
1450
  if (result.timedOut)
1279
1451
  throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
1280
1452
  if (result.error)
@@ -1334,8 +1506,12 @@ class RunManager {
1334
1506
  timeout,
1335
1507
  shell: false,
1336
1508
  stdin: shellStdin,
1509
+ cancellation: this.processCancellation(execId),
1337
1510
  });
1338
1511
  recordProcessResult(result);
1512
+ if (result.cancelled) {
1513
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `shell_task "${nodeId}" was cancelled`);
1514
+ }
1339
1515
  if (result.timedOut)
1340
1516
  throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1341
1517
  if (result.error)
@@ -1514,6 +1690,8 @@ class RunManager {
1514
1690
  if (!isTerminalStatus(run.status)) {
1515
1691
  run.status = 'running';
1516
1692
  }
1693
+ if (!run.current_node)
1694
+ run.current_node = nodeId;
1517
1695
  run.auto_run = {
1518
1696
  pid: process.pid,
1519
1697
  token,
@@ -1619,6 +1797,9 @@ class RunManager {
1619
1797
  while (true) {
1620
1798
  try {
1621
1799
  const run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1800
+ if (run.status === 'cancelled') {
1801
+ return { run, runnerToken: token, outcome: 'cancelled', reason: run.error };
1802
+ }
1622
1803
  return { run, runnerToken: token, outcome: 'success' };
1623
1804
  }
1624
1805
  catch (caught) {
@@ -1791,6 +1972,8 @@ class RunManager {
1791
1972
  }
1792
1973
  runnerToken = claimedToken;
1793
1974
  run = await this.atomicUpdateAsync(execId, (r) => {
1975
+ if (r.status === 'cancelled')
1976
+ return;
1794
1977
  const engine = readRunEngineState(r);
1795
1978
  engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1796
1979
  engine.iterations += 1;
@@ -1808,6 +1991,9 @@ class RunManager {
1808
1991
  appendFailureAudit(r, failure, 'config', 'auto');
1809
1992
  }
1810
1993
  });
1994
+ if (run.status === 'cancelled') {
1995
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1996
+ }
1811
1997
  if (run.status === 'failed') {
1812
1998
  return { run, outcome: 'failed', reason: run.error, executed };
1813
1999
  }
@@ -1845,14 +2031,15 @@ class RunManager {
1845
2031
  }
1846
2032
  }
1847
2033
  cancelRun(execId, reason = 'Cancelled by user') {
1848
- return this.atomicUpdate(execId, (run) => {
2034
+ const cancelled = this.atomicUpdate(execId, (run) => {
1849
2035
  if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1850
2036
  return;
1851
2037
  }
1852
2038
  const now = new Date();
2039
+ const activeNodeId = run.auto_run?.node_id || run.current_node || '';
1853
2040
  const failure = (0, failure_1.buildNodeFailure)({
1854
- failedNode: run.current_node || '',
1855
- attempt: (0, failure_1.readNodeAttempt)(run, run.current_node || ''),
2041
+ failedNode: activeNodeId,
2042
+ attempt: (0, failure_1.readNodeAttempt)(run, activeNodeId),
1856
2043
  causeCode: 'CANCELLED',
1857
2044
  exitCode: null,
1858
2045
  signal: null,
@@ -1864,6 +2051,10 @@ class RunManager {
1864
2051
  delete run.manual_resume;
1865
2052
  appendFailureAudit(run, failure, 'cancel', 'manual', now.toISOString());
1866
2053
  });
2054
+ if (cancelled.status === 'cancelled') {
2055
+ requestActiveProcessCancellation(this.resolvedDir, execId);
2056
+ }
2057
+ return cancelled;
1867
2058
  }
1868
2059
  reset(execId) {
1869
2060
  return this.atomicUpdate(execId, (run) => {