@crewx/workflow 0.3.22-rc.136 → 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, {
@@ -129,19 +154,65 @@ async function runProcess(command, argv, opts) {
129
154
  let stdout = '';
130
155
  let stderr = '';
131
156
  let settled = false;
157
+ let finishing = false;
132
158
  let spawned = false;
159
+ let cancellationRequested = false;
133
160
  let postSpawnError;
134
161
  let stdinError;
162
+ let timeoutTimer;
163
+ let cancellationEscalationTimer;
164
+ let cancellationPollTimer;
165
+ let activeController;
166
+ const outDecoder = new sdk_1.ConsoleDecoder();
167
+ const errDecoder = new sdk_1.ConsoleDecoder();
168
+ let stdoutDecodeChain = Promise.resolve();
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
+ };
135
183
  const finish = (result) => {
136
184
  if (settled)
137
185
  return;
138
186
  settled = true;
139
- clearTimeout(timer);
140
- resolve(result);
187
+ cleanup();
188
+ resolve(cancellationRequested ? { ...result, cancelled: true, timedOut: false } : result);
141
189
  };
142
- 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(() => {
143
214
  killProcessGroup(child);
144
- finish({
215
+ finishAfterDecode(() => ({
145
216
  status: null,
146
217
  stdout,
147
218
  stderr,
@@ -149,12 +220,20 @@ async function runProcess(command, argv, opts) {
149
220
  signal: null,
150
221
  errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
151
222
  ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
152
- });
223
+ }));
153
224
  }, opts.timeout);
154
- if (typeof timer.unref === 'function')
155
- timer.unref();
156
- child.stdout?.on('data', (d) => { stdout += d.toString(); });
157
- child.stderr?.on('data', (d) => { stderr += d.toString(); });
225
+ if (typeof timeoutTimer.unref === 'function')
226
+ timeoutTimer.unref();
227
+ child.stdout?.on('data', (d) => {
228
+ stdoutDecodeChain = stdoutDecodeChain.then(async () => {
229
+ stdout += await outDecoder.write(d);
230
+ });
231
+ });
232
+ child.stderr?.on('data', (d) => {
233
+ stderrDecodeChain = stderrDecodeChain.then(async () => {
234
+ stderr += await errDecoder.write(d);
235
+ });
236
+ });
158
237
  child.once('spawn', () => { spawned = true; });
159
238
  child.on('error', (err) => {
160
239
  if (!spawned) {
@@ -182,7 +261,19 @@ async function runProcess(command, argv, opts) {
182
261
  stdinError = stdinError ?? err;
183
262
  }
184
263
  }
185
- child.on('close', (code, signal) => finish({
264
+ const finishAfterDecode = (createResult) => {
265
+ if (settled || finishing)
266
+ return;
267
+ finishing = true;
268
+ void (async () => {
269
+ await Promise.all([stdoutDecodeChain, stderrDecodeChain]);
270
+ const [tailOut, tailErr] = await Promise.all([outDecoder.end(), errDecoder.end()]);
271
+ stdout += tailOut;
272
+ stderr += tailErr;
273
+ finish(createResult());
274
+ })().catch(() => finish(createResult()));
275
+ };
276
+ child.on('close', (code, signal) => finishAfterDecode(() => ({
186
277
  status: code,
187
278
  stdout,
188
279
  stderr,
@@ -190,7 +281,9 @@ async function runProcess(command, argv, opts) {
190
281
  signal,
191
282
  errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
192
283
  ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
193
- }));
284
+ })));
285
+ if (isCancelled())
286
+ requestCancellation();
194
287
  });
195
288
  }
196
289
  const AUDIT_IDENTIFIER_RE = /^[A-Za-z0-9_.-]{1,128}$/;
@@ -663,6 +756,13 @@ function readRunEngineState(run) {
663
756
  return { visitCounts, iterations };
664
757
  }
665
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
+ }
666
766
  function isProcessAlive(pid) {
667
767
  if (!Number.isInteger(pid) || pid <= 0)
668
768
  return false;
@@ -721,6 +821,7 @@ class RunManager {
721
821
  constructor(runsDir = '.crewx/workflow-runs', projectRoot, deps) {
722
822
  this.runsDir = runsDir;
723
823
  this.sleep = deps?.sleep ?? sleep;
824
+ this.onRunPersisted = deps?.onRunPersisted;
724
825
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
725
826
  this.resolvedDir = path.resolve(base, this.runsDir);
726
827
  this.projectRoot = path.resolve(base);
@@ -740,6 +841,20 @@ class RunManager {
740
841
  throw new Error(`Invalid execution ID: "${execId}"`);
741
842
  }
742
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
+ }
743
858
  readRunRaw(execId) {
744
859
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
745
860
  if (!fs.existsSync(filePath))
@@ -752,6 +867,7 @@ class RunManager {
752
867
  }
753
868
  }
754
869
  readRun(execId) {
870
+ this.validateExecId(execId);
755
871
  const run = this.readRunRaw(execId);
756
872
  return run ? normalizeRunStatus(run) : null;
757
873
  }
@@ -772,6 +888,24 @@ class RunManager {
772
888
  const node = spec?.nodes?.[run.current_node];
773
889
  return Boolean(node && KNOWN_AUTO_NODE_TYPES.has(node.type));
774
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
+ }
775
909
  hasStaleRunner(run) {
776
910
  if (run.auto_run)
777
911
  return !isProcessAlive(run.auto_run.pid);
@@ -824,7 +958,53 @@ class RunManager {
824
958
  this.ensureRunsDir();
825
959
  run.updated_at = new Date().toISOString();
826
960
  const filePath = path.join(this.resolvedDir, `${run.id}.json`);
827
- 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
+ }
828
1008
  }
829
1009
  atomicUpdate(execId, updater) {
830
1010
  this.validateExecId(execId);
@@ -959,6 +1139,8 @@ class RunManager {
959
1139
  }
960
1140
  moveNode(execId, nodeId) {
961
1141
  return this.atomicUpdate(execId, (run) => {
1142
+ if (run.status === 'cancelled')
1143
+ return;
962
1144
  if (run.current_node && !run.completed_nodes.includes(run.current_node)) {
963
1145
  run.completed_nodes.push(run.current_node);
964
1146
  }
@@ -970,6 +1152,9 @@ class RunManager {
970
1152
  if (!snapshot) {
971
1153
  throw new Error(`Execution not found: ${execId}`);
972
1154
  }
1155
+ if (snapshot.status === 'cancelled') {
1156
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Workflow run "${execId}" is already cancelled`);
1157
+ }
973
1158
  const doc = this.loadWorkflowYaml(snapshot.workflow_file);
974
1159
  if (!doc) {
975
1160
  throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${snapshot.workflow_file}`);
@@ -1108,8 +1293,12 @@ class RunManager {
1108
1293
  timeout: nodeTimeout,
1109
1294
  shell: invocation.shell ?? false,
1110
1295
  windowsHide: invocation.windowsHide,
1296
+ cancellation: this.processCancellation(execId),
1111
1297
  });
1112
1298
  recordProcessResult(result);
1299
+ if (result.cancelled) {
1300
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent execution cancelled for node "${nodeId}"`);
1301
+ }
1113
1302
  if (result.timedOut) {
1114
1303
  throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
1115
1304
  }
@@ -1159,8 +1348,12 @@ class RunManager {
1159
1348
  timeout: nodeTimeout,
1160
1349
  shell: retryInvocation.shell ?? false,
1161
1350
  windowsHide: retryInvocation.windowsHide,
1351
+ cancellation: this.processCancellation(execId),
1162
1352
  });
1163
1353
  recordProcessResult(retryResult);
1354
+ if (retryResult.cancelled) {
1355
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent retry cancelled for node "${nodeId}"`);
1356
+ }
1164
1357
  if (retryResult.timedOut)
1165
1358
  throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
1166
1359
  if (retryResult.error)
@@ -1248,8 +1441,12 @@ class RunManager {
1248
1441
  shell: invocation.shell ?? false,
1249
1442
  windowsHide: invocation.windowsHide,
1250
1443
  stdin: skillStdin,
1444
+ cancellation: this.processCancellation(execId),
1251
1445
  });
1252
1446
  recordProcessResult(result);
1447
+ if (result.cancelled) {
1448
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `skill_task "${nodeId}" was cancelled`);
1449
+ }
1253
1450
  if (result.timedOut)
1254
1451
  throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
1255
1452
  if (result.error)
@@ -1309,8 +1506,12 @@ class RunManager {
1309
1506
  timeout,
1310
1507
  shell: false,
1311
1508
  stdin: shellStdin,
1509
+ cancellation: this.processCancellation(execId),
1312
1510
  });
1313
1511
  recordProcessResult(result);
1512
+ if (result.cancelled) {
1513
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `shell_task "${nodeId}" was cancelled`);
1514
+ }
1314
1515
  if (result.timedOut)
1315
1516
  throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1316
1517
  if (result.error)
@@ -1489,6 +1690,8 @@ class RunManager {
1489
1690
  if (!isTerminalStatus(run.status)) {
1490
1691
  run.status = 'running';
1491
1692
  }
1693
+ if (!run.current_node)
1694
+ run.current_node = nodeId;
1492
1695
  run.auto_run = {
1493
1696
  pid: process.pid,
1494
1697
  token,
@@ -1594,6 +1797,9 @@ class RunManager {
1594
1797
  while (true) {
1595
1798
  try {
1596
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
+ }
1597
1803
  return { run, runnerToken: token, outcome: 'success' };
1598
1804
  }
1599
1805
  catch (caught) {
@@ -1766,6 +1972,8 @@ class RunManager {
1766
1972
  }
1767
1973
  runnerToken = claimedToken;
1768
1974
  run = await this.atomicUpdateAsync(execId, (r) => {
1975
+ if (r.status === 'cancelled')
1976
+ return;
1769
1977
  const engine = readRunEngineState(r);
1770
1978
  engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1771
1979
  engine.iterations += 1;
@@ -1783,6 +1991,9 @@ class RunManager {
1783
1991
  appendFailureAudit(r, failure, 'config', 'auto');
1784
1992
  }
1785
1993
  });
1994
+ if (run.status === 'cancelled') {
1995
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1996
+ }
1786
1997
  if (run.status === 'failed') {
1787
1998
  return { run, outcome: 'failed', reason: run.error, executed };
1788
1999
  }
@@ -1820,14 +2031,15 @@ class RunManager {
1820
2031
  }
1821
2032
  }
1822
2033
  cancelRun(execId, reason = 'Cancelled by user') {
1823
- return this.atomicUpdate(execId, (run) => {
2034
+ const cancelled = this.atomicUpdate(execId, (run) => {
1824
2035
  if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1825
2036
  return;
1826
2037
  }
1827
2038
  const now = new Date();
2039
+ const activeNodeId = run.auto_run?.node_id || run.current_node || '';
1828
2040
  const failure = (0, failure_1.buildNodeFailure)({
1829
- failedNode: run.current_node || '',
1830
- attempt: (0, failure_1.readNodeAttempt)(run, run.current_node || ''),
2041
+ failedNode: activeNodeId,
2042
+ attempt: (0, failure_1.readNodeAttempt)(run, activeNodeId),
1831
2043
  causeCode: 'CANCELLED',
1832
2044
  exitCode: null,
1833
2045
  signal: null,
@@ -1839,6 +2051,10 @@ class RunManager {
1839
2051
  delete run.manual_resume;
1840
2052
  appendFailureAudit(run, failure, 'cancel', 'manual', now.toISOString());
1841
2053
  });
2054
+ if (cancelled.status === 'cancelled') {
2055
+ requestActiveProcessCancellation(this.resolvedDir, execId);
2056
+ }
2057
+ return cancelled;
1842
2058
  }
1843
2059
  reset(execId) {
1844
2060
  return this.atomicUpdate(execId, (run) => {