@hexabot-ai/agentic 3.1.2-alpha.9 → 3.1.2-beta.1

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.
Files changed (82) hide show
  1. package/README.md +13 -5
  2. package/dist/cjs/action/abstract-action.js +8 -3
  3. package/dist/cjs/dsl.types.js +2 -1
  4. package/dist/cjs/errors.js +41 -0
  5. package/dist/cjs/index.js +7 -1
  6. package/dist/cjs/step-executors/parallel-executor.js +238 -24
  7. package/dist/cjs/step-executors/skip-helpers.js +25 -1
  8. package/dist/cjs/step-executors/task-executor.js +77 -10
  9. package/dist/cjs/suspension-rebuilder.js +1 -24
  10. package/dist/cjs/utils/naming.js +4 -4
  11. package/dist/cjs/utils/timeout.js +63 -8
  12. package/dist/cjs/utils/workflow-definition-resources.js +73 -0
  13. package/dist/cjs/workflow-compiler.js +0 -2
  14. package/dist/cjs/workflow-runner.js +40 -17
  15. package/dist/esm/action/abstract-action.js +8 -3
  16. package/dist/esm/dsl.types.js +2 -1
  17. package/dist/esm/errors.js +33 -0
  18. package/dist/esm/index.js +2 -0
  19. package/dist/esm/step-executors/parallel-executor.js +239 -25
  20. package/dist/esm/step-executors/skip-helpers.js +23 -0
  21. package/dist/esm/step-executors/task-executor.js +77 -10
  22. package/dist/esm/suspension-rebuilder.js +1 -24
  23. package/dist/esm/utils/naming.js +1 -1
  24. package/dist/esm/utils/timeout.js +63 -8
  25. package/dist/esm/utils/workflow-definition-resources.js +68 -0
  26. package/dist/esm/workflow-compiler.js +0 -2
  27. package/dist/esm/workflow-runner.js +40 -17
  28. package/dist/types/action/abstract-action.d.ts +1 -1
  29. package/dist/types/action/abstract-action.d.ts.map +1 -1
  30. package/dist/types/action/action.types.d.ts +2 -1
  31. package/dist/types/action/action.types.d.ts.map +1 -1
  32. package/dist/types/context.d.ts +2 -1
  33. package/dist/types/context.d.ts.map +1 -1
  34. package/dist/types/dsl.types.d.ts.map +1 -1
  35. package/dist/types/errors.d.ts +10 -0
  36. package/dist/types/errors.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +2 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/step-executors/parallel-executor.d.ts +3 -4
  40. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -1
  41. package/dist/types/step-executors/skip-helpers.d.ts +1 -0
  42. package/dist/types/step-executors/skip-helpers.d.ts.map +1 -1
  43. package/dist/types/step-executors/task-executor.d.ts.map +1 -1
  44. package/dist/types/step-executors/types.d.ts +9 -1
  45. package/dist/types/step-executors/types.d.ts.map +1 -1
  46. package/dist/types/suspension-rebuilder.d.ts.map +1 -1
  47. package/dist/types/utils/naming.d.ts +1 -0
  48. package/dist/types/utils/naming.d.ts.map +1 -1
  49. package/dist/types/utils/timeout.d.ts +4 -2
  50. package/dist/types/utils/timeout.d.ts.map +1 -1
  51. package/dist/types/utils/workflow-definition-resources.d.ts +16 -0
  52. package/dist/types/utils/workflow-definition-resources.d.ts.map +1 -0
  53. package/dist/types/workflow-compiler.d.ts.map +1 -1
  54. package/dist/types/workflow-event-emitter.d.ts +12 -13
  55. package/dist/types/workflow-event-emitter.d.ts.map +1 -1
  56. package/dist/types/workflow-runner.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/__tests__/suspension-rebuilder.test.ts +3 -42
  59. package/src/__tests__/workflow-definition-resources.test.ts +97 -0
  60. package/src/__tests__/workflow-runner.test.ts +58 -5
  61. package/src/action/__tests__/action.test.ts +2 -1
  62. package/src/action/abstract-action.ts +9 -1
  63. package/src/action/action.types.ts +2 -0
  64. package/src/context.ts +2 -0
  65. package/src/dsl.types.ts +5 -1
  66. package/src/errors.ts +43 -0
  67. package/src/index.ts +10 -0
  68. package/src/step-executors/conditional-executor.test.ts +8 -3
  69. package/src/step-executors/loop-executor.test.ts +8 -3
  70. package/src/step-executors/parallel-executor.test.ts +191 -82
  71. package/src/step-executors/parallel-executor.ts +402 -34
  72. package/src/step-executors/skip-helpers.ts +41 -0
  73. package/src/step-executors/task-executor.test.ts +85 -21
  74. package/src/step-executors/task-executor.ts +94 -10
  75. package/src/step-executors/types.ts +14 -1
  76. package/src/suspension-rebuilder.ts +1 -53
  77. package/src/utils/naming.ts +1 -1
  78. package/src/utils/timeout.ts +78 -8
  79. package/src/utils/workflow-definition-resources.ts +111 -0
  80. package/src/workflow-compiler.ts +0 -3
  81. package/src/workflow-event-emitter.ts +13 -6
  82. package/src/workflow-runner.ts +59 -19
@@ -3,40 +3,254 @@
3
3
  * Copyright (c) 2025 Hexastack.
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
- import { markStepsSkipped } from './skip-helpers';
7
- import { wrapSuspensionContinuation } from './suspension-continuation';
6
+ import { getAbortReason, ParallelSuspensionError, throwIfAborted, WorkflowCancellationError, } from '../errors';
7
+ import { markStepsCancelled } from './skip-helpers';
8
8
  /**
9
- * Execute a set of steps in parallel order, respecting the configured strategy.
9
+ * Execute a set of child steps concurrently and resolve according to the configured strategy.
10
10
  *
11
11
  * @param env Executor environment providing helpers and workflow context.
12
12
  * @param step The parallel step definition including strategy and children.
13
13
  * @param state Mutable workflow execution state.
14
14
  * @param path Path tokens locating this parallel block in the workflow.
15
- * @param startIndex Index to resume execution from when continuing.
16
- * @returns A suspension if a child pauses execution, otherwise void.
15
+ * @returns Always undefined; suspensions are rejected inside parallel blocks.
17
16
  */
18
- export async function executeParallel(env, step, state, path, startIndex = 0) {
19
- for (let index = startIndex; index < step.steps.length; index += 1) {
20
- const child = step.steps[index];
21
- const childPath = [...path, 'parallel', index];
22
- const suspension = await env.executeStep(child, state, childPath);
17
+ export async function executeParallel(env, step, state, path) {
18
+ if (step.steps.length === 0) {
19
+ return undefined;
20
+ }
21
+ throwIfAborted(env.signal);
22
+ const baseOutput = cloneRecord(state.output);
23
+ const baseContextState = cloneRecord(env.context.state);
24
+ const branches = step.steps.map((child, index) => launchBranch({
25
+ env,
26
+ child,
27
+ index,
28
+ path,
29
+ parentState: state,
30
+ baseOutput,
31
+ baseContextState,
32
+ }));
33
+ if (step.strategy === 'wait_any') {
34
+ return executeWaitAny(env, branches, state);
35
+ }
36
+ return executeWaitAll(env, branches, state);
37
+ }
38
+ async function executeWaitAll(env, branches, state) {
39
+ const pending = new Set(branches);
40
+ const outcomes = new Map();
41
+ while (pending.size > 0) {
42
+ const { branch, result } = await raceNext(pending);
43
+ pending.delete(branch);
44
+ outcomes.set(result.index, result);
45
+ if (result.status === 'failed' || result.status === 'cancelled') {
46
+ cancelBranches(env, pending, result.error, 'Parallel branch execution was cancelled.');
47
+ await settleBranches(branches);
48
+ throw result.error;
49
+ }
50
+ }
51
+ mergeBranchOutputs(state, outcomes);
52
+ }
53
+ async function executeWaitAny(env, branches, state) {
54
+ const pending = new Set(branches);
55
+ const outcomes = new Map();
56
+ while (pending.size > 0) {
57
+ const { branch, result } = await raceNext(pending);
58
+ pending.delete(branch);
59
+ outcomes.set(result.index, result);
60
+ if (result.status === 'fulfilled') {
61
+ cancelBranches(env, pending, new WorkflowCancellationError('Parallel wait_any winner selected.'), 'Parallel wait_any branch lost the race.');
62
+ await settleBranches(branches);
63
+ mergeBranchOutputs(state, new Map([[result.index, result]]));
64
+ return;
65
+ }
66
+ cancelBranches(env, pending, result.error, 'Parallel branch execution was cancelled.');
67
+ await settleBranches(branches);
68
+ throw result.error;
69
+ }
70
+ }
71
+ function launchBranch({ env, child, index, path, parentState, baseOutput, baseContextState, }) {
72
+ const controller = createBranchController(env.signal);
73
+ const trackedOutput = createTrackedOutput(baseOutput);
74
+ const branchState = {
75
+ input: parentState.input,
76
+ output: trackedOutput.output,
77
+ iteration: parentState.iteration ? { ...parentState.iteration } : undefined,
78
+ accumulator: cloneValue(parentState.accumulator),
79
+ iterationStack: [...parentState.iterationStack],
80
+ };
81
+ const branchContext = createParallelBranchContext(env.context, cloneRecord(baseContextState));
82
+ const branchEnv = env.fork({
83
+ context: branchContext,
84
+ signal: controller.signal,
85
+ setCurrentStep: () => undefined,
86
+ });
87
+ const childPath = [...path, 'parallel', index];
88
+ const promise = runBranch(branchEnv, child, branchState, childPath, trackedOutput, index);
89
+ return {
90
+ index,
91
+ step: child,
92
+ iterationStack: [...parentState.iterationStack],
93
+ controller,
94
+ promise,
95
+ };
96
+ }
97
+ async function runBranch(env, child, branchState, childPath, trackedOutput, index) {
98
+ try {
99
+ throwIfAborted(env.signal);
100
+ const suspension = await env.executeStep(child, branchState, childPath);
23
101
  if (suspension) {
24
- return wrapSuspensionContinuation(suspension, async () => {
25
- if (step.strategy === 'wait_any') {
26
- if (index + 1 < step.steps.length) {
27
- markStepsSkipped(env, step.steps.slice(index + 1), state.iterationStack);
28
- }
29
- return undefined;
30
- }
31
- return executeParallel(env, step, state, path, index + 1);
32
- });
33
- }
34
- if (step.strategy === 'wait_any') {
35
- if (index + 1 < step.steps.length) {
36
- markStepsSkipped(env, step.steps.slice(index + 1), state.iterationStack);
102
+ throw new ParallelSuspensionError();
103
+ }
104
+ throwIfAborted(env.signal);
105
+ return {
106
+ status: 'fulfilled',
107
+ index,
108
+ outputDelta: trackedOutput.getDelta(),
109
+ };
110
+ }
111
+ catch (error) {
112
+ if (env.signal.aborted) {
113
+ return { status: 'cancelled', index, error: getAbortReason(env.signal) };
114
+ }
115
+ return { status: 'failed', index, error };
116
+ }
117
+ }
118
+ async function raceNext(pending) {
119
+ return Promise.race([...pending].map((branch) => branch.promise.then((result) => ({ branch, result }))));
120
+ }
121
+ async function settleBranches(branches) {
122
+ await Promise.all(branches.map((branch) => branch.promise.catch(() => null)));
123
+ }
124
+ function cancelBranches(env, branches, reason, snapshotReason) {
125
+ for (const branch of branches) {
126
+ if (!branch.controller.signal.aborted) {
127
+ branch.controller.abort(reason instanceof Error
128
+ ? reason
129
+ : new WorkflowCancellationError(String(reason)));
130
+ }
131
+ markStepsCancelled(env, [branch.step], branch.iterationStack, reason instanceof Error ? reason.message : snapshotReason);
132
+ }
133
+ }
134
+ function mergeBranchOutputs(state, outcomes) {
135
+ const ordered = [...outcomes.values()].sort((left, right) => {
136
+ return left.index - right.index;
137
+ });
138
+ for (const outcome of ordered) {
139
+ if (outcome.status !== 'fulfilled') {
140
+ continue;
141
+ }
142
+ Object.assign(state.output, outcome.outputDelta);
143
+ }
144
+ }
145
+ function createBranchController(parentSignal) {
146
+ const controller = new AbortController();
147
+ if (parentSignal.aborted) {
148
+ controller.abort(getAbortReason(parentSignal));
149
+ return controller;
150
+ }
151
+ parentSignal.addEventListener('abort', () => controller.abort(getAbortReason(parentSignal)), { once: true });
152
+ return controller;
153
+ }
154
+ function createTrackedOutput(baseOutput) {
155
+ const writtenKeys = new Set();
156
+ const target = cloneRecord(baseOutput);
157
+ const output = new Proxy(target, {
158
+ set(record, property, value) {
159
+ if (typeof property === 'string') {
160
+ writtenKeys.add(property);
161
+ }
162
+ return Reflect.set(record, property, value);
163
+ },
164
+ deleteProperty(record, property) {
165
+ if (typeof property === 'string') {
166
+ writtenKeys.add(property);
167
+ }
168
+ return Reflect.deleteProperty(record, property);
169
+ },
170
+ });
171
+ return {
172
+ output,
173
+ getDelta: () => Object.fromEntries([...writtenKeys]
174
+ .filter((key) => Object.prototype.hasOwnProperty.call(target, key))
175
+ .map((key) => [key, target[key]])),
176
+ };
177
+ }
178
+ class ParallelWorkflowRuntimeControl {
179
+ constructor(getParent) {
180
+ this.getParent = getParent;
181
+ }
182
+ get status() {
183
+ return this.getParent().status;
184
+ }
185
+ get resumeData() {
186
+ return this.getParent().resumeData;
187
+ }
188
+ suspend() {
189
+ return Promise.reject(new ParallelSuspensionError());
190
+ }
191
+ resume(data) {
192
+ this.getParent().resume(data);
193
+ }
194
+ getSnapshot() {
195
+ return this.getParent().getSnapshot();
196
+ }
197
+ }
198
+ function createParallelBranchContext(context, branchState) {
199
+ let state = branchState;
200
+ const workflow = new ParallelWorkflowRuntimeControl(() => context.workflow);
201
+ return new Proxy(context, {
202
+ get(target, property, receiver) {
203
+ if (property === 'state') {
204
+ return state;
205
+ }
206
+ if (property === 'workflow') {
207
+ return workflow;
37
208
  }
38
- return undefined;
209
+ if (property === 'snapshot') {
210
+ return () => cloneRecord(state);
211
+ }
212
+ if (property === 'attachWorkflowRuntime') {
213
+ return () => undefined;
214
+ }
215
+ return Reflect.get(target, property, receiver);
216
+ },
217
+ set(target, property, value, receiver) {
218
+ if (property === 'state') {
219
+ state = isRecord(value) ? value : {};
220
+ return true;
221
+ }
222
+ return Reflect.set(target, property, value, receiver);
223
+ },
224
+ });
225
+ }
226
+ function cloneRecord(value) {
227
+ return cloneValue(value);
228
+ }
229
+ function cloneValue(value) {
230
+ if (value === undefined || value === null) {
231
+ return value;
232
+ }
233
+ if (typeof structuredClone === 'function') {
234
+ try {
235
+ return structuredClone(value);
39
236
  }
237
+ catch {
238
+ // Fall through to JSON clone below.
239
+ }
240
+ }
241
+ try {
242
+ return JSON.parse(JSON.stringify(value));
243
+ }
244
+ catch {
245
+ if (Array.isArray(value)) {
246
+ return [...value];
247
+ }
248
+ if (isRecord(value)) {
249
+ return { ...value };
250
+ }
251
+ return value;
40
252
  }
41
- return undefined;
253
+ }
254
+ function isRecord(value) {
255
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
42
256
  }
@@ -20,6 +20,29 @@ const markStepSkipped = (env, step, iterationStack, reason) => {
20
20
  break;
21
21
  }
22
22
  };
23
+ const markStepCancelled = (env, step, iterationStack, reason) => {
24
+ const stepInfo = env.buildInstanceStepInfo(step, iterationStack);
25
+ env.markSnapshot(stepInfo, 'cancelled', reason);
26
+ env.emit('hook:step:cancelled', {
27
+ runId: env.runId,
28
+ step: stepInfo,
29
+ error: new Error(reason ?? 'Step execution was cancelled.'),
30
+ });
31
+ switch (step.type) {
32
+ case StepType.Parallel:
33
+ step.steps.forEach((child) => markStepCancelled(env, child, iterationStack, reason));
34
+ break;
35
+ case StepType.Conditional:
36
+ step.branches.forEach((branch) => branch.steps.forEach((child) => markStepCancelled(env, child, iterationStack, reason)));
37
+ break;
38
+ case StepType.Loop:
39
+ case StepType.Task:
40
+ break;
41
+ }
42
+ };
23
43
  export const markStepsSkipped = (env, steps, iterationStack, reason) => {
24
44
  steps.forEach((step) => markStepSkipped(env, step, iterationStack, reason));
25
45
  };
46
+ export const markStepsCancelled = (env, steps, iterationStack, reason) => {
47
+ steps.forEach((step) => markStepCancelled(env, step, iterationStack, reason));
48
+ };
@@ -3,6 +3,7 @@
3
3
  * Copyright (c) 2025 Hexastack.
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
+ import { getAbortReason, throwIfAborted } from '../errors';
6
7
  import { evaluateMapping } from '../workflow-values';
7
8
  /**
8
9
  * Execute a task step by running its task and handling suspension or output mapping.
@@ -20,6 +21,11 @@ export async function executeTaskStep(env, step, state, _path) {
20
21
  throw new Error(`Task "${step.taskName}" is not defined.`);
21
22
  }
22
23
  const stepInfo = env.buildInstanceStepInfo(step, state.iterationStack);
24
+ if (env.signal.aborted) {
25
+ const error = getAbortReason(env.signal);
26
+ recordTaskCancellation(env, stepInfo, error);
27
+ throw error;
28
+ }
23
29
  const scope = {
24
30
  input: state.input,
25
31
  context: env.context.state,
@@ -28,7 +34,12 @@ export async function executeTaskStep(env, step, state, _path) {
28
34
  accumulator: state.accumulator,
29
35
  };
30
36
  const inputs = await evaluateMapping(task.inputs, scope);
31
- env.recordStepExecution(stepInfo, {
37
+ if (env.signal.aborted) {
38
+ const error = getAbortReason(env.signal);
39
+ recordTaskCancellation(env, stepInfo, error);
40
+ throw error;
41
+ }
42
+ const stepExecution = env.recordStepExecution(stepInfo, {
32
43
  action: task.actionName,
33
44
  status: 'running',
34
45
  startedAt: Date.now(),
@@ -38,9 +49,14 @@ export async function executeTaskStep(env, step, state, _path) {
38
49
  env.beginStepExecution?.(stepInfo.id);
39
50
  env.setCurrentStep(stepInfo);
40
51
  env.markSnapshot(stepInfo, 'running');
41
- env.emit('hook:step:start', { runId: env.runId, step: stepInfo });
52
+ env.emit('hook:step:start', {
53
+ runId: env.runId,
54
+ step: stepInfo,
55
+ stepExecution,
56
+ });
42
57
  try {
43
- const actionPromise = Promise.resolve().then(() => task.action.run(inputs, env.context, task.settings, task.bindings));
58
+ throwIfAborted(env.signal);
59
+ const actionPromise = Promise.resolve().then(() => task.action.run(inputs, env.context, task.settings, task.bindings, env.signal));
44
60
  const outcome = await waitForTaskProgress(env, stepInfo.id, actionPromise);
45
61
  if (outcome.type === 'completed') {
46
62
  await completeTask(env, stepInfo.id, stepInfo, task, state, outcome.value);
@@ -51,6 +67,11 @@ export async function executeTaskStep(env, step, state, _path) {
51
67
  recordTaskFailure(env, stepInfo, outcome.error);
52
68
  throw outcome.error;
53
69
  }
70
+ if (outcome.type === 'cancelled') {
71
+ env.clearStepSuspensions(stepInfo.id, outcome.error);
72
+ recordTaskCancellation(env, stepInfo, outcome.error);
73
+ throw outcome.error;
74
+ }
54
75
  return buildSuspensionContinuation(env, stepInfo.id, stepInfo, task, state, actionPromise, outcome.request);
55
76
  }
56
77
  finally {
@@ -62,22 +83,45 @@ const waitForTaskProgress = async (env, stepId, actionPromise) => {
62
83
  const suspension = env
63
84
  .waitForStepSuspension(stepId)
64
85
  .then((request) => ({ type: 'suspended', request }));
65
- return Promise.race([completion, suspension]);
86
+ let cleanupCancellation = () => undefined;
87
+ const cancellation = new Promise((resolve) => {
88
+ if (env.signal.aborted) {
89
+ resolve({ type: 'cancelled', error: getAbortReason(env.signal) });
90
+ return;
91
+ }
92
+ const onAbort = () => {
93
+ resolve({ type: 'cancelled', error: getAbortReason(env.signal) });
94
+ };
95
+ env.signal.addEventListener('abort', onAbort, { once: true });
96
+ cleanupCancellation = () => {
97
+ env.signal.removeEventListener('abort', onAbort);
98
+ };
99
+ });
100
+ try {
101
+ return await Promise.race([completion, suspension, cancellation]);
102
+ }
103
+ finally {
104
+ cleanupCancellation();
105
+ }
66
106
  };
67
107
  const completeTask = async (env, stepId, stepInfo, task, state, result) => {
68
108
  await env.captureTaskOutput(task, state, result);
69
- env.recordStepExecution(stepInfo, {
109
+ const stepExecution = env.recordStepExecution(stepInfo, {
70
110
  status: 'completed',
71
111
  endedAt: Date.now(),
72
112
  output: result,
73
113
  context: { after: env.context.snapshot() },
74
114
  });
75
115
  env.markSnapshot(stepInfo, 'completed');
76
- env.emit('hook:step:success', { runId: env.runId, step: stepInfo });
116
+ env.emit('hook:step:success', {
117
+ runId: env.runId,
118
+ step: stepInfo,
119
+ stepExecution,
120
+ });
77
121
  env.clearStepSuspensions(stepId);
78
122
  };
79
123
  const recordSuspension = (env, stepInfo, reason, data) => {
80
- env.recordStepExecution(stepInfo, {
124
+ const stepExecution = env.recordStepExecution(stepInfo, {
81
125
  status: 'suspended',
82
126
  endedAt: Date.now(),
83
127
  reason,
@@ -87,6 +131,7 @@ const recordSuspension = (env, stepInfo, reason, data) => {
87
131
  env.emit('hook:step:suspended', {
88
132
  runId: env.runId,
89
133
  step: stepInfo,
134
+ stepExecution,
90
135
  reason,
91
136
  data,
92
137
  });
@@ -137,14 +182,36 @@ const buildSuspensionContinuation = (env, stepId, stepInfo, task, state, actionP
137
182
  };
138
183
  };
139
184
  const recordTaskFailure = (env, stepInfo, error) => {
140
- env.recordStepExecution(stepInfo, {
185
+ const normalizedError = normalizeError(error);
186
+ const stepExecution = env.recordStepExecution(stepInfo, {
141
187
  status: 'failed',
142
188
  endedAt: Date.now(),
143
- error: normalizeError(error),
189
+ error: normalizedError,
144
190
  context: { after: env.context.snapshot() },
145
191
  });
146
192
  env.markSnapshot(stepInfo, 'failed', normalizeErrorMessage(error));
147
- env.emit('hook:step:error', { runId: env.runId, step: stepInfo, error });
193
+ env.emit('hook:step:error', {
194
+ runId: env.runId,
195
+ step: stepInfo,
196
+ stepExecution,
197
+ error,
198
+ });
199
+ };
200
+ const recordTaskCancellation = (env, stepInfo, error) => {
201
+ const normalizedError = normalizeError(error);
202
+ const stepExecution = env.recordStepExecution(stepInfo, {
203
+ status: 'cancelled',
204
+ endedAt: Date.now(),
205
+ error: normalizedError,
206
+ context: { after: env.context.snapshot() },
207
+ });
208
+ env.markSnapshot(stepInfo, 'cancelled', normalizeErrorMessage(error));
209
+ env.emit('hook:step:cancelled', {
210
+ runId: env.runId,
211
+ step: stepInfo,
212
+ stepExecution,
213
+ error,
214
+ });
148
215
  };
149
216
  const normalizeError = (error) => error instanceof Error
150
217
  ? { message: error.message, stack: error.stack }
@@ -4,8 +4,6 @@
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
6
  import { executeLoop as runLoopExecutor, shouldStopLoop, updateAccumulator, } from './step-executors/loop-executor';
7
- import { executeParallel as runParallelExecutor } from './step-executors/parallel-executor';
8
- import { markStepsSkipped } from './step-executors/skip-helpers';
9
7
  import { wrapSuspensionContinuation } from './step-executors/suspension-continuation';
10
8
  import { StepType, } from './workflow-event-emitter';
11
9
  /**
@@ -166,28 +164,7 @@ export function buildSuspensionForPath(deps, steps, state, targetPath, pathPrefi
166
164
  };
167
165
  }
168
166
  if (step.type === StepType.Parallel) {
169
- if (rest[0] !== 'parallel') {
170
- return null;
171
- }
172
- const childPath = rest.slice(1);
173
- const childSuspension = buildSuspensionForPath(deps, step.steps, state, childPath, [...currentPath, 'parallel'], iterationDepth, suspensionMetadata);
174
- if (!childSuspension) {
175
- return null;
176
- }
177
- const childIndex = typeof childPath[0] === 'number' ? childPath[0] : 0;
178
- return wrapSuspensionContinuation(childSuspension, async () => {
179
- if (step.strategy === 'wait_any') {
180
- if (childIndex + 1 < step.steps.length) {
181
- markStepsSkipped(env, step.steps.slice(childIndex + 1), state.iterationStack ?? []);
182
- }
183
- return deps.executeFlow(steps, state, pathPrefix, current + 1);
184
- }
185
- const next = await runParallelExecutor(env, step, state, currentPath, childIndex + 1);
186
- if (next) {
187
- return wrapSuspensionContinuation(next, () => deps.executeFlow(steps, state, pathPrefix, current + 1));
188
- }
189
- return deps.executeFlow(steps, state, pathPrefix, current + 1);
190
- });
167
+ return null;
191
168
  }
192
169
  if (step.type === StepType.Conditional) {
193
170
  if (rest[0] !== 'branch' || typeof rest[1] !== 'number') {
@@ -3,7 +3,7 @@
3
3
  * Copyright (c) 2025 Hexastack.
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
- const SNAKE_CASE_REGEX = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
6
+ export const SNAKE_CASE_REGEX = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
7
7
  /**
8
8
  * Verifies that names follow the snake_case convention required by the system.
9
9
  *
@@ -3,34 +3,89 @@
3
3
  * Copyright (c) 2025 Hexastack.
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
+ import { getAbortReason, throwIfAborted } from '../errors';
6
7
  /**
7
8
  * Resolves after the specified duration; useful for retry delays.
8
9
  *
9
10
  * @param durationMs - Number of milliseconds to wait before resolving.
11
+ * @param signal - Optional signal used to cancel the wait.
10
12
  * @returns A promise that resolves once the duration elapses.
11
13
  */
12
- export const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
14
+ export const sleep = (durationMs, signal) => {
15
+ if (!signal) {
16
+ return new Promise((resolve) => setTimeout(resolve, durationMs));
17
+ }
18
+ throwIfAborted(signal);
19
+ return new Promise((resolve, reject) => {
20
+ const timer = setTimeout(() => {
21
+ signal.removeEventListener('abort', onAbort);
22
+ resolve();
23
+ }, durationMs);
24
+ const onAbort = () => {
25
+ clearTimeout(timer);
26
+ reject(getAbortReason(signal));
27
+ };
28
+ signal.addEventListener('abort', onAbort, { once: true });
29
+ });
30
+ };
13
31
  /**
14
32
  * Wraps a promise and rejects if it does not settle within the timeout.
15
33
  *
16
34
  * @param promise - Operation that may take longer than the allowed timeout.
17
35
  * @param timeoutMs - Maximum time in milliseconds to wait before rejecting.
36
+ * @param signal - Optional signal used to cancel the operation.
18
37
  * @returns The result of the original promise when it resolves in time.
19
38
  * @throws Error when the timeout is exceeded.
20
39
  */
21
- export async function withTimeout(promise, timeoutMs) {
22
- if (!timeoutMs) {
40
+ export async function withTimeout(promise, timeoutMs, signal) {
41
+ if (!timeoutMs && !signal) {
23
42
  return promise;
24
43
  }
25
44
  return new Promise((resolve, reject) => {
26
- const timer = setTimeout(() => {
27
- reject(new Error(`Step execution exceeded timeout of ${timeoutMs}ms`));
28
- }, timeoutMs);
45
+ let settled = false;
46
+ const timer = timeoutMs
47
+ ? setTimeout(() => {
48
+ settled = true;
49
+ signal?.removeEventListener('abort', onAbort);
50
+ reject(new Error(`Step execution exceeded timeout of ${timeoutMs}ms`));
51
+ }, timeoutMs)
52
+ : undefined;
53
+ const onAbort = () => {
54
+ if (settled) {
55
+ return;
56
+ }
57
+ settled = true;
58
+ if (timer) {
59
+ clearTimeout(timer);
60
+ }
61
+ reject(getAbortReason(signal));
62
+ };
63
+ if (signal) {
64
+ if (signal.aborted) {
65
+ onAbort();
66
+ return;
67
+ }
68
+ signal.addEventListener('abort', onAbort, { once: true });
69
+ }
29
70
  promise.then((value) => {
30
- clearTimeout(timer);
71
+ if (settled) {
72
+ return;
73
+ }
74
+ settled = true;
75
+ if (timer) {
76
+ clearTimeout(timer);
77
+ }
78
+ signal?.removeEventListener('abort', onAbort);
31
79
  resolve(value);
32
80
  }, (error) => {
33
- clearTimeout(timer);
81
+ if (settled) {
82
+ return;
83
+ }
84
+ settled = true;
85
+ if (timer) {
86
+ clearTimeout(timer);
87
+ }
88
+ signal?.removeEventListener('abort', onAbort);
34
89
  reject(error);
35
90
  });
36
91
  });
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2026 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+ const getSettingsRef = (definition, settingsKey) => {
7
+ const settings = definition.settings;
8
+ if (!settings || typeof settings !== 'object') {
9
+ return null;
10
+ }
11
+ const value = settings[settingsKey];
12
+ return typeof value === 'string' && value.trim() ? value : null;
13
+ };
14
+ /**
15
+ * Collect external resource IDs referenced by workflow definition defs.
16
+ */
17
+ export const collectWorkflowDefinitionResourceRefs = (definition, descriptors) => {
18
+ const result = Object.fromEntries(descriptors.map((descriptor) => [descriptor.kind, new Set()]));
19
+ for (const def of Object.values(definition.defs ?? {})) {
20
+ for (const descriptor of descriptors) {
21
+ if (def.kind !== descriptor.kind) {
22
+ continue;
23
+ }
24
+ const ref = getSettingsRef(def, descriptor.settingsKey);
25
+ if (ref) {
26
+ result[descriptor.kind].add(ref);
27
+ }
28
+ }
29
+ }
30
+ return Object.fromEntries(Object.entries(result).map(([kind, refs]) => [kind, Array.from(refs)]));
31
+ };
32
+ /**
33
+ * Rewrite external resource IDs referenced by workflow definition defs.
34
+ */
35
+ export const remapWorkflowDefinitionResourceRefs = (definition, descriptors, idMaps) => {
36
+ let didChange = false;
37
+ const nextDefs = Object.fromEntries(Object.entries(definition.defs ?? {}).map(([defName, def]) => {
38
+ const descriptor = descriptors.find((item) => item.kind === def.kind);
39
+ if (!descriptor) {
40
+ return [defName, def];
41
+ }
42
+ const currentRef = getSettingsRef(def, descriptor.settingsKey);
43
+ const nextRef = currentRef
44
+ ? idMaps[descriptor.kind]?.[currentRef]
45
+ : undefined;
46
+ if (!currentRef || !nextRef || nextRef === currentRef) {
47
+ return [defName, def];
48
+ }
49
+ didChange = true;
50
+ return [
51
+ defName,
52
+ {
53
+ ...def,
54
+ settings: {
55
+ ...(def.settings ?? {}),
56
+ [descriptor.settingsKey]: nextRef,
57
+ },
58
+ },
59
+ ];
60
+ }));
61
+ if (!didChange) {
62
+ return definition;
63
+ }
64
+ return {
65
+ ...definition,
66
+ defs: nextDefs,
67
+ };
68
+ };