@hexabot-ai/agentic 3.0.1-alpha.0

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 (176) hide show
  1. package/LICENSE.md +120 -0
  2. package/README.md +231 -0
  3. package/dist/cjs/action/abstract-action.js +141 -0
  4. package/dist/cjs/action/action.js +47 -0
  5. package/dist/cjs/action/action.types.js +7 -0
  6. package/dist/cjs/bindings/base-binding.js +278 -0
  7. package/dist/cjs/context.js +79 -0
  8. package/dist/cjs/dsl.types.js +318 -0
  9. package/dist/cjs/index.js +52 -0
  10. package/dist/cjs/runner-runtime-control.js +343 -0
  11. package/dist/cjs/step-executors/conditional-executor.js +63 -0
  12. package/dist/cjs/step-executors/loop-executor.js +187 -0
  13. package/dist/cjs/step-executors/parallel-executor.js +51 -0
  14. package/dist/cjs/step-executors/skip-helpers.js +29 -0
  15. package/dist/cjs/step-executors/task-executor.js +155 -0
  16. package/dist/cjs/step-executors/types.js +7 -0
  17. package/dist/cjs/suspension-rebuilder.js +301 -0
  18. package/dist/cjs/utils/deferred.js +17 -0
  19. package/dist/cjs/utils/naming.js +48 -0
  20. package/dist/cjs/utils/timeout.js +42 -0
  21. package/dist/cjs/utils/workflow-definition.js +102 -0
  22. package/dist/cjs/workflow-compiler.js +212 -0
  23. package/dist/cjs/workflow-event-emitter.js +40 -0
  24. package/dist/cjs/workflow-runner.js +448 -0
  25. package/dist/cjs/workflow-types.js +7 -0
  26. package/dist/cjs/workflow-values.js +99 -0
  27. package/dist/cjs/workflow.js +243 -0
  28. package/dist/esm/action/abstract-action.js +137 -0
  29. package/dist/esm/action/action.js +44 -0
  30. package/dist/esm/action/action.types.js +6 -0
  31. package/dist/esm/bindings/base-binding.js +273 -0
  32. package/dist/esm/context.js +75 -0
  33. package/dist/esm/dsl.types.js +309 -0
  34. package/dist/esm/index.js +16 -0
  35. package/dist/esm/runner-runtime-control.js +338 -0
  36. package/dist/esm/step-executors/conditional-executor.js +60 -0
  37. package/dist/esm/step-executors/loop-executor.js +182 -0
  38. package/dist/esm/step-executors/parallel-executor.js +48 -0
  39. package/dist/esm/step-executors/skip-helpers.js +25 -0
  40. package/dist/esm/step-executors/task-executor.js +152 -0
  41. package/dist/esm/step-executors/types.js +6 -0
  42. package/dist/esm/suspension-rebuilder.js +296 -0
  43. package/dist/esm/utils/deferred.js +14 -0
  44. package/dist/esm/utils/naming.js +42 -0
  45. package/dist/esm/utils/timeout.js +37 -0
  46. package/dist/esm/utils/workflow-definition.js +98 -0
  47. package/dist/esm/workflow-compiler.js +208 -0
  48. package/dist/esm/workflow-event-emitter.js +36 -0
  49. package/dist/esm/workflow-runner.js +444 -0
  50. package/dist/esm/workflow-types.js +6 -0
  51. package/dist/esm/workflow-values.js +89 -0
  52. package/dist/esm/workflow.js +236 -0
  53. package/dist/types/action/abstract-action.d.ts +54 -0
  54. package/dist/types/action/abstract-action.d.ts.map +1 -0
  55. package/dist/types/action/action.d.ts +23 -0
  56. package/dist/types/action/action.d.ts.map +1 -0
  57. package/dist/types/action/action.types.d.ts +55 -0
  58. package/dist/types/action/action.types.d.ts.map +1 -0
  59. package/dist/types/bindings/base-binding.d.ts +52 -0
  60. package/dist/types/bindings/base-binding.d.ts.map +1 -0
  61. package/dist/types/context.d.ts +105 -0
  62. package/dist/types/context.d.ts.map +1 -0
  63. package/dist/types/dsl.types.d.ts +220 -0
  64. package/dist/types/dsl.types.d.ts.map +1 -0
  65. package/dist/types/index.d.ts +18 -0
  66. package/dist/types/index.d.ts.map +1 -0
  67. package/dist/types/runner-runtime-control.d.ts +64 -0
  68. package/dist/types/runner-runtime-control.d.ts.map +1 -0
  69. package/dist/types/step-executors/conditional-executor.d.ts +13 -0
  70. package/dist/types/step-executors/conditional-executor.d.ts.map +1 -0
  71. package/dist/types/step-executors/loop-executor.d.ts +33 -0
  72. package/dist/types/step-executors/loop-executor.d.ts.map +1 -0
  73. package/dist/types/step-executors/parallel-executor.d.ts +14 -0
  74. package/dist/types/step-executors/parallel-executor.d.ts.map +1 -0
  75. package/dist/types/step-executors/skip-helpers.d.ts +12 -0
  76. package/dist/types/step-executors/skip-helpers.d.ts.map +1 -0
  77. package/dist/types/step-executors/task-executor.d.ts +13 -0
  78. package/dist/types/step-executors/task-executor.d.ts.map +1 -0
  79. package/dist/types/step-executors/types.d.ts +24 -0
  80. package/dist/types/step-executors/types.d.ts.map +1 -0
  81. package/dist/types/suspension-rebuilder.d.ts +67 -0
  82. package/dist/types/suspension-rebuilder.d.ts.map +1 -0
  83. package/dist/types/utils/deferred.d.ts +7 -0
  84. package/dist/types/utils/deferred.d.ts.map +1 -0
  85. package/dist/types/utils/naming.d.ts +24 -0
  86. package/dist/types/utils/naming.d.ts.map +1 -0
  87. package/dist/types/utils/timeout.d.ts +17 -0
  88. package/dist/types/utils/timeout.d.ts.map +1 -0
  89. package/dist/types/utils/workflow-definition.d.ts +3 -0
  90. package/dist/types/utils/workflow-definition.d.ts.map +1 -0
  91. package/dist/types/workflow-compiler.d.ts +12 -0
  92. package/dist/types/workflow-compiler.d.ts.map +1 -0
  93. package/dist/types/workflow-event-emitter.d.ts +70 -0
  94. package/dist/types/workflow-event-emitter.d.ts.map +1 -0
  95. package/dist/types/workflow-runner.d.ts +179 -0
  96. package/dist/types/workflow-runner.d.ts.map +1 -0
  97. package/dist/types/workflow-types.d.ts +195 -0
  98. package/dist/types/workflow-types.d.ts.map +1 -0
  99. package/dist/types/workflow-values.d.ts +34 -0
  100. package/dist/types/workflow-values.d.ts.map +1 -0
  101. package/dist/types/workflow.d.ts +75 -0
  102. package/dist/types/workflow.d.ts.map +1 -0
  103. package/examples/defs-bindings-agent/actions/ai-agent.ts +110 -0
  104. package/examples/defs-bindings-agent/actions/caculate-score.ts +49 -0
  105. package/examples/defs-bindings-agent/actions/index.ts +15 -0
  106. package/examples/defs-bindings-agent/bindings.ts +20 -0
  107. package/examples/defs-bindings-agent/context.ts +32 -0
  108. package/examples/defs-bindings-agent/workflow.ts +67 -0
  109. package/examples/defs-bindings-agent/workflow.yml +28 -0
  110. package/examples/full/actions/await-user-input.ts +46 -0
  111. package/examples/full/actions/call-llm.ts +133 -0
  112. package/examples/full/actions/create-ticket.ts +46 -0
  113. package/examples/full/actions/decision-router.ts +83 -0
  114. package/examples/full/actions/get-calendar-events.ts +55 -0
  115. package/examples/full/actions/get-user-profile.ts +72 -0
  116. package/examples/full/actions/index.ts +33 -0
  117. package/examples/full/actions/query-memory.ts +45 -0
  118. package/examples/full/actions/search-web.ts +53 -0
  119. package/examples/full/actions/send-email.ts +50 -0
  120. package/examples/full/context.ts +41 -0
  121. package/examples/full/workflow.ts +75 -0
  122. package/examples/full/workflow.yml +359 -0
  123. package/examples/loop/actions/await-reply.ts +47 -0
  124. package/examples/loop/actions/index.ts +19 -0
  125. package/examples/loop/actions/send-text-message.ts +40 -0
  126. package/examples/loop/context.ts +32 -0
  127. package/examples/loop/workflow.ts +66 -0
  128. package/examples/loop/workflow.yml +66 -0
  129. package/examples/suspend-resume/actions/format-reply.ts +43 -0
  130. package/examples/suspend-resume/actions/index.ts +13 -0
  131. package/examples/suspend-resume/actions/wait-for-user.ts +51 -0
  132. package/examples/suspend-resume/context.ts +32 -0
  133. package/examples/suspend-resume/workflow.ts +79 -0
  134. package/examples/suspend-resume/workflow.yml +29 -0
  135. package/package.json +60 -0
  136. package/src/__tests__/parser.test.ts +95 -0
  137. package/src/__tests__/suspension-rebuilder.test.ts +361 -0
  138. package/src/__tests__/test-helpers.ts +36 -0
  139. package/src/__tests__/validation.test.ts +526 -0
  140. package/src/__tests__/workflow-compiler.test.ts +715 -0
  141. package/src/__tests__/workflow-definition-path.test.ts +381 -0
  142. package/src/__tests__/workflow-event-emitter.test.ts +50 -0
  143. package/src/__tests__/workflow-runner.test.ts +1397 -0
  144. package/src/__tests__/workflow-values.test.ts +132 -0
  145. package/src/__tests__/workflow.test.ts +320 -0
  146. package/src/action/__tests__/abstract-action-timing.test.ts +183 -0
  147. package/src/action/__tests__/action.test.ts +473 -0
  148. package/src/action/abstract-action.ts +215 -0
  149. package/src/action/action.ts +83 -0
  150. package/src/action/action.types.ts +93 -0
  151. package/src/bindings/base-binding.ts +508 -0
  152. package/src/context.ts +179 -0
  153. package/src/dsl.types.ts +473 -0
  154. package/src/index.ts +114 -0
  155. package/src/runner-runtime-control.ts +544 -0
  156. package/src/step-executors/conditional-executor.test.ts +192 -0
  157. package/src/step-executors/conditional-executor.ts +83 -0
  158. package/src/step-executors/loop-executor.test.ts +303 -0
  159. package/src/step-executors/loop-executor.ts +299 -0
  160. package/src/step-executors/parallel-executor.test.ts +182 -0
  161. package/src/step-executors/parallel-executor.ts +77 -0
  162. package/src/step-executors/skip-helpers.ts +68 -0
  163. package/src/step-executors/task-executor.test.ts +257 -0
  164. package/src/step-executors/task-executor.ts +248 -0
  165. package/src/step-executors/types.ts +70 -0
  166. package/src/suspension-rebuilder.ts +504 -0
  167. package/src/utils/deferred.ts +23 -0
  168. package/src/utils/naming.ts +55 -0
  169. package/src/utils/timeout.ts +48 -0
  170. package/src/utils/workflow-definition.ts +173 -0
  171. package/src/workflow-compiler.ts +317 -0
  172. package/src/workflow-event-emitter.ts +86 -0
  173. package/src/workflow-runner.ts +603 -0
  174. package/src/workflow-types.ts +207 -0
  175. package/src/workflow-values.ts +159 -0
  176. package/src/workflow.ts +387 -0
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ import { defineAction, type Settings } from '../../../src';
10
+ import type { SuspendResumeContext } from '../context';
11
+
12
+ const inputSchema = z.object({
13
+ prompt: z.string(),
14
+ });
15
+ const resumeSchema = z.object({
16
+ reply: z.string(),
17
+ });
18
+ const outputSchema = z.object({
19
+ reply: z.string(),
20
+ });
21
+
22
+ type WaitForUserInput = z.infer<typeof inputSchema>;
23
+ type WaitForUserOutput = z.infer<typeof outputSchema>;
24
+
25
+ export const waitForUser = defineAction<
26
+ WaitForUserInput,
27
+ WaitForUserOutput,
28
+ SuspendResumeContext,
29
+ Settings
30
+ >({
31
+ name: 'wait_for_user',
32
+ description: 'Pause execution until a reply is provided to the resume call.',
33
+ inputSchema,
34
+ outputSchema,
35
+ execute: async ({ input, context }) => {
36
+ context.log('Suspending workflow until we get a reply', {
37
+ prompt: input.prompt,
38
+ });
39
+
40
+ const resumeData = await context.workflow.suspend({
41
+ reason: 'awaiting_user',
42
+ data: { prompt: input.prompt },
43
+ });
44
+ const parsed = resumeSchema.safeParse(resumeData);
45
+ if (!parsed.success) {
46
+ throw new Error('resumeData must include a string reply');
47
+ }
48
+
49
+ return { reply: parsed.data.reply };
50
+ },
51
+ });
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import {
8
+ BaseWorkflowContext,
9
+ WorkflowEventEmitter,
10
+ type WorkflowEventEmitterLike,
11
+ } from '../../src';
12
+
13
+ export type SuspendResumeContextState = {
14
+ channel: string;
15
+ };
16
+
17
+ export class SuspendResumeContext extends BaseWorkflowContext<SuspendResumeContextState> {
18
+ public eventEmitter: WorkflowEventEmitterLike<WorkflowEventEmitter>;
19
+
20
+ constructor(
21
+ state: SuspendResumeContextState,
22
+ eventEmitter: WorkflowEventEmitterLike<WorkflowEventEmitter> = new WorkflowEventEmitter(),
23
+ ) {
24
+ super(state);
25
+ this.eventEmitter = eventEmitter;
26
+ }
27
+
28
+ log(message: string, payload?: unknown): void {
29
+ const suffix = payload === undefined ? '' : ` ${JSON.stringify(payload)}`;
30
+ console.log(`[suspend/resume] ${message}${suffix}`);
31
+ }
32
+ }
@@ -0,0 +1,79 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ import { Workflow, WorkflowEventEmitter } from '../../src';
11
+
12
+ import { suspendResumeActions } from './actions';
13
+ import { SuspendResumeContext } from './context';
14
+
15
+ const workflowPath = path.join(__dirname, 'workflow.yml');
16
+
17
+ async function main() {
18
+ const yamlSource = fs.readFileSync(workflowPath, 'utf8');
19
+ const workflow = Workflow.fromYaml(yamlSource, {
20
+ actions: suspendResumeActions,
21
+ });
22
+ const emitter = new WorkflowEventEmitter();
23
+ const context = new SuspendResumeContext({ channel: 'chat' }, emitter);
24
+
25
+ emitter.on('hook:workflow:start', () => context.log('workflow started'));
26
+ emitter.on('hook:workflow:suspended', ({ step, reason, data }) =>
27
+ context.log(`workflow suspended at "${step.name}"`, { reason, data }),
28
+ );
29
+ emitter.on('hook:workflow:finish', ({ output }) =>
30
+ context.log('workflow finished', output),
31
+ );
32
+
33
+ const runner = await workflow.buildAsyncRunner();
34
+ const startResult = await runner.start({
35
+ inputData: {
36
+ question: "Can we deploy to production after today's smoke tests?",
37
+ },
38
+ context,
39
+ });
40
+
41
+ if (startResult.status === 'finished') {
42
+ context.log('finished without suspending', startResult.output);
43
+
44
+ return;
45
+ }
46
+
47
+ if (startResult.status === 'failed') {
48
+ console.error('Workflow failed', startResult.error);
49
+
50
+ return;
51
+ }
52
+
53
+ context.log('persist this snapshot to resume later', startResult.snapshot);
54
+
55
+ const resumeResult = await runner.resume({
56
+ resumeData: { reply: 'Yes, proceed once monitoring is green.' },
57
+ });
58
+
59
+ if (resumeResult.status === 'finished') {
60
+ context.log('resumed and completed', resumeResult.output);
61
+
62
+ return;
63
+ }
64
+
65
+ if (resumeResult.status === 'suspended') {
66
+ context.log('suspended again', {
67
+ step: resumeResult.step.name,
68
+ reason: resumeResult.reason,
69
+ data: resumeResult.data,
70
+ });
71
+
72
+ return;
73
+ }
74
+
75
+ console.error('Workflow failed after resume', resumeResult.error);
76
+ }
77
+
78
+ // pnpm dlx ts-node packages/agentic/examples/suspend-resume/workflow.ts
79
+ void main();
@@ -0,0 +1,29 @@
1
+ # Minimal flow that pauses until an external reply is supplied.
2
+ inputs:
3
+ schema:
4
+ question:
5
+ type: string
6
+ description: "Prompt shown to the user when the workflow suspends."
7
+
8
+ defs:
9
+ wait_for_confirmation:
10
+ kind: task
11
+ description: "Sends a prompt and suspends until the reply is provided via resume."
12
+ action: wait_for_user
13
+ inputs:
14
+ prompt: "=$input.question"
15
+
16
+ format_acknowledgement:
17
+ kind: task
18
+ description: "Formats a message using the reply captured after resuming the run."
19
+ action: format_reply
20
+ inputs:
21
+ reply: "=$output.wait_for_confirmation.reply"
22
+
23
+ flow:
24
+ - do: wait_for_confirmation
25
+ - do: format_acknowledgement
26
+
27
+ outputs:
28
+ user_reply: "=$output.wait_for_confirmation.reply"
29
+ message: "=$output.format_acknowledgement.message"
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@hexabot-ai/agentic",
3
+ "version": "3.0.1-alpha.0",
4
+ "description": "Schemas and utilities for the Hexabot agentic workflow DSL.",
5
+ "main": "dist/cjs/index.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/types/index.d.ts",
11
+ "import": "./dist/esm/index.js",
12
+ "require": "./dist/cjs/index.js",
13
+ "default": "./dist/esm/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "examples",
19
+ "src"
20
+ ],
21
+ "dependencies": {
22
+ "jsonata": "^2.1.0",
23
+ "yaml": "^2.5.0",
24
+ "zod": "^4.3.6"
25
+ },
26
+ "devDependencies": {
27
+ "@typescript-eslint/eslint-plugin": "^8.46.0",
28
+ "@typescript-eslint/parser": "^8.46.0",
29
+ "@types/jest": "^30.0.0",
30
+ "@types/node": "^22.7.4",
31
+ "eslint": "^9.37.0",
32
+ "eslint-config-prettier": "^10.1.8",
33
+ "eslint-import-resolver-typescript": "^4.4.4",
34
+ "eslint-plugin-header": "^3.1.1",
35
+ "eslint-plugin-import": "^2.32.0",
36
+ "lint-staged": "^15.3.0",
37
+ "prettier": "^3.6.2",
38
+ "jest": "^30.2.0",
39
+ "ts-jest": "^29.4.5",
40
+ "typescript": "^5.6.2"
41
+ },
42
+ "lint-staged": {
43
+ "*.{ts}": "eslint --fix --config eslint.config-staged.cjs"
44
+ },
45
+ "engines": {
46
+ "node": ">=20.18.1"
47
+ },
48
+ "license": "FCL-1.0-ALv2",
49
+ "scripts": {
50
+ "build": "pnpm run build:cjs && pnpm run build:esm",
51
+ "build:cjs": "tsc -p tsconfig.build.cjs.json",
52
+ "build:esm": "tsc -p tsconfig.build.esm.json",
53
+ "dev": "pnpm run build:watch",
54
+ "build:watch": "tsc -p tsconfig.json --watch",
55
+ "lint": "eslint \"{src,examples}/**/*.ts\" \"jest.config.ts\"",
56
+ "lint:fix": "eslint \"{src,examples}/**/*.ts\" \"jest.config.ts\" --fix",
57
+ "typecheck": "tsc --noEmit -p tsconfig.json",
58
+ "test": "jest --config jest.config.ts"
59
+ }
60
+ }
@@ -0,0 +1,95 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { WorkflowDefinitionSchema } from '../dsl.types';
8
+
9
+ describe('WorkflowDefinitionSchema', () => {
10
+ it('parses a minimal valid workflow', () => {
11
+ const minimal = {
12
+ defs: {
13
+ noop: { kind: 'task', action: 'call' },
14
+ },
15
+ flow: [{ do: 'noop' }],
16
+ outputs: { result: '=$output.noop' },
17
+ };
18
+
19
+ expect(() => WorkflowDefinitionSchema.parse(minimal)).not.toThrow();
20
+ });
21
+
22
+ it('rejects workflows with invalid JSONata expressions', () => {
23
+ const invalid = {
24
+ defs: {
25
+ noop: { kind: 'task', action: 'call' },
26
+ },
27
+ flow: [{ do: 'noop' }],
28
+ outputs: { result: '=$sum([1,2' }, // missing closing bracket makes JSONata invalid
29
+ };
30
+
31
+ expect(() => WorkflowDefinitionSchema.parse(invalid)).toThrow(
32
+ /Invalid JSONata expression/,
33
+ );
34
+ });
35
+
36
+ it('rejects task output mappings', () => {
37
+ const invalid = {
38
+ defs: {
39
+ noop: { kind: 'task', action: 'call', outputs: { value: '=1' } },
40
+ },
41
+ flow: [{ do: 'noop' }],
42
+ outputs: { result: '=$output.noop' },
43
+ };
44
+
45
+ expect(() => WorkflowDefinitionSchema.parse(invalid)).toThrow(
46
+ /Unrecognized key/i,
47
+ );
48
+ });
49
+
50
+ it('parses workflows with defs and task bindings', () => {
51
+ const workflow = {
52
+ defs: {
53
+ calculate: {
54
+ kind: 'tools',
55
+ action: 'calculate_score',
56
+ settings: { multiplier: 2 },
57
+ },
58
+ agent_step: {
59
+ kind: 'task',
60
+ action: 'ai_agent',
61
+ bindings: {
62
+ tools: ['calculate'],
63
+ },
64
+ },
65
+ },
66
+ flow: [{ do: 'agent_step' }],
67
+ outputs: { result: '=$output.agent_step' },
68
+ };
69
+
70
+ expect(() => WorkflowDefinitionSchema.parse(workflow)).not.toThrow();
71
+ });
72
+
73
+ it('rejects malformed task bindings definitions', () => {
74
+ const invalid = {
75
+ defs: {
76
+ calculate: {
77
+ kind: 'tools',
78
+ action: 'calculate_score',
79
+ settings: {},
80
+ },
81
+ agent_step: {
82
+ kind: 'task',
83
+ action: 'ai_agent',
84
+ bindings: {
85
+ tools: 42,
86
+ },
87
+ },
88
+ },
89
+ flow: [{ do: 'agent_step' }],
90
+ outputs: { result: '=$output.agent_step' },
91
+ };
92
+
93
+ expect(() => WorkflowDefinitionSchema.parse(invalid)).toThrow();
94
+ });
95
+ });
@@ -0,0 +1,361 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2025 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ import type { Action } from '../action/action.types';
10
+ import { BaseWorkflowContext } from '../context';
11
+ import type {
12
+ Settings,
13
+ TaskDefinition,
14
+ WorkflowDefinition,
15
+ } from '../dsl.types';
16
+ import {
17
+ executeLoop as runLoopExecutor,
18
+ shouldStopLoop,
19
+ updateAccumulator,
20
+ } from '../step-executors/loop-executor';
21
+ import { executeParallel as runParallelExecutor } from '../step-executors/parallel-executor';
22
+ import type { StepExecutorEnv } from '../step-executors/types';
23
+ import {
24
+ parseSuspendedStepId,
25
+ rebuildSuspension,
26
+ type SuspensionRebuilderDeps,
27
+ } from '../suspension-rebuilder';
28
+ import { type EventEmitterLike, StepType } from '../workflow-event-emitter';
29
+ import type {
30
+ CompiledStep,
31
+ CompiledTask,
32
+ CompiledWorkflow,
33
+ ExecutionState,
34
+ } from '../workflow-types';
35
+
36
+ jest.mock('../step-executors/loop-executor', () => ({
37
+ __esModule: true,
38
+ executeLoop: jest.fn(),
39
+ updateAccumulator: jest.fn(),
40
+ shouldStopLoop: jest.fn(),
41
+ }));
42
+
43
+ jest.mock('../step-executors/parallel-executor', () => ({
44
+ __esModule: true,
45
+ executeParallel: jest.fn(),
46
+ }));
47
+
48
+ const mockedRunLoopExecutor = runLoopExecutor as jest.MockedFunction<
49
+ typeof runLoopExecutor
50
+ >;
51
+ const mockedUpdateAccumulator = updateAccumulator as jest.MockedFunction<
52
+ typeof updateAccumulator
53
+ >;
54
+ const mockedShouldStopLoop = shouldStopLoop as jest.MockedFunction<
55
+ typeof shouldStopLoop
56
+ >;
57
+ const mockedRunParallelExecutor = runParallelExecutor as jest.MockedFunction<
58
+ typeof runParallelExecutor
59
+ >;
60
+
61
+ class TestContext extends BaseWorkflowContext {
62
+ public eventEmitter: EventEmitterLike = { emit: jest.fn(), on: jest.fn() };
63
+
64
+ constructor(initial: Record<string, unknown>) {
65
+ super(initial);
66
+ }
67
+ }
68
+
69
+ const dummyAction: Action<unknown, unknown, BaseWorkflowContext, Settings> = {
70
+ name: 'dummy',
71
+ description: 'dummy',
72
+ inputSchema: z.any(),
73
+ outputSchema: z.any(),
74
+ execute: jest.fn(),
75
+ parseInput: (value) => value as unknown,
76
+ parseOutput: (value) => value as unknown,
77
+ parseSettings: (value) => value as Settings,
78
+ run: jest.fn(),
79
+ };
80
+ const buildStepInfo = (step: CompiledStep, iterationStack: number[]) => ({
81
+ id: `${step.id}${
82
+ iterationStack.length > 0 ? `[${iterationStack.join('.')}]` : ''
83
+ }`,
84
+ name: step.label,
85
+ type: step.type,
86
+ });
87
+ const createCompiledWorkflow = (
88
+ flow: CompiledStep[],
89
+ tasks: Record<string, CompiledTask>,
90
+ ): CompiledWorkflow =>
91
+ ({
92
+ definition: {} as WorkflowDefinition,
93
+ tasks,
94
+ flow,
95
+ outputMapping: {},
96
+ inputParser: { parse: (value: unknown) => value },
97
+ }) as CompiledWorkflow;
98
+ const createDeps = (
99
+ compiled: CompiledWorkflow,
100
+ overrides?: Partial<SuspensionRebuilderDeps>,
101
+ ): SuspensionRebuilderDeps => {
102
+ const context = overrides?.context ?? new TestContext({});
103
+ const captureTaskOutput =
104
+ overrides?.captureTaskOutput ?? jest.fn().mockResolvedValue(undefined);
105
+ const markSnapshot =
106
+ overrides?.markSnapshot ??
107
+ (jest.fn() as SuspensionRebuilderDeps['markSnapshot']);
108
+ const emit =
109
+ overrides?.emit ?? (jest.fn() as SuspensionRebuilderDeps['emit']);
110
+ const executeFlow =
111
+ overrides?.executeFlow ??
112
+ (jest
113
+ .fn()
114
+ .mockResolvedValue(undefined) as SuspensionRebuilderDeps['executeFlow']);
115
+ const deps: SuspensionRebuilderDeps = {
116
+ compiled,
117
+ context,
118
+ runId: overrides?.runId ?? 'run-123',
119
+ createExecutorEnv:
120
+ overrides?.createExecutorEnv ??
121
+ jest.fn(
122
+ () =>
123
+ ({
124
+ context,
125
+ }) as unknown as StepExecutorEnv,
126
+ ),
127
+ buildInstanceStepInfo: overrides?.buildInstanceStepInfo ?? buildStepInfo,
128
+ captureTaskOutput,
129
+ markSnapshot,
130
+ emit,
131
+ executeFlow,
132
+ };
133
+
134
+ return deps;
135
+ };
136
+ const createTask = (name: string): CompiledTask =>
137
+ ({
138
+ name,
139
+ definition: { action: name } as TaskDefinition,
140
+ actionName: name,
141
+ action: dummyAction,
142
+ inputs: {},
143
+ settings: {} as Settings,
144
+ bindings: {},
145
+ }) as CompiledTask;
146
+
147
+ afterEach(() => {
148
+ jest.clearAllMocks();
149
+ });
150
+
151
+ describe('parseSuspendedStepId', () => {
152
+ it('extracts path and iteration stack from a nested id', () => {
153
+ expect(parseSuspendedStepId('1.branch.0:conditional[2.4]')).toEqual({
154
+ path: [1, 'branch', 0],
155
+ iterationStack: [2, 4],
156
+ });
157
+ });
158
+
159
+ it('returns empty path and iteration data for root ids', () => {
160
+ expect(parseSuspendedStepId('root:task')).toEqual({
161
+ path: [],
162
+ iterationStack: [],
163
+ });
164
+ });
165
+ });
166
+
167
+ describe('rebuildSuspension', () => {
168
+ it('returns null when no state is available', () => {
169
+ const compiled = createCompiledWorkflow([], {});
170
+ const deps = createDeps(compiled);
171
+ const suspension = rebuildSuspension(deps, {
172
+ state: undefined,
173
+ stepId: '0:task',
174
+ });
175
+
176
+ expect(suspension).toBeNull();
177
+ });
178
+
179
+ it('rebuilds a task suspension and resumes the flow', async () => {
180
+ const task = createTask('first_task');
181
+ const taskStep: CompiledStep = {
182
+ type: StepType.Task,
183
+ id: '0:first_task',
184
+ label: 'first_task',
185
+ taskName: 'first_task',
186
+ };
187
+ const compiled = createCompiledWorkflow([taskStep], {
188
+ first_task: task,
189
+ });
190
+ const state: ExecutionState = {
191
+ input: {},
192
+ output: {},
193
+ iterationStack: [3],
194
+ };
195
+ const deps = createDeps(compiled);
196
+ const suspension = rebuildSuspension(deps, {
197
+ state,
198
+ stepId: '0:first_task',
199
+ reason: 'needs_input',
200
+ data: { foo: 'bar' },
201
+ });
202
+
203
+ expect(suspension?.step.id).toBe('0:first_task[3]');
204
+ expect(suspension?.reason).toBe('needs_input');
205
+ expect(suspension?.data).toEqual({ foo: 'bar' });
206
+
207
+ await suspension?.continue({ reply: 'ok' });
208
+
209
+ expect(deps.captureTaskOutput).toHaveBeenCalledWith(task, state, {
210
+ reply: 'ok',
211
+ });
212
+ expect(deps.markSnapshot).toHaveBeenCalledWith(
213
+ expect.objectContaining({ id: '0:first_task[3]' }),
214
+ 'completed',
215
+ );
216
+ expect(deps.emit).toHaveBeenCalledWith('hook:step:success', {
217
+ runId: 'run-123',
218
+ step: expect.objectContaining({ id: '0:first_task[3]' }),
219
+ });
220
+ expect(deps.executeFlow).toHaveBeenCalledWith(compiled.flow, state, [], 1);
221
+ });
222
+
223
+ it('rebuilds a parallel suspension and continues remaining siblings when needed', async () => {
224
+ const firstTask = createTask('child_a');
225
+ const secondTask = createTask('child_b');
226
+ const childA: CompiledStep = {
227
+ type: StepType.Task,
228
+ id: '0.parallel.0:child_a',
229
+ label: 'child_a',
230
+ taskName: 'child_a',
231
+ };
232
+ const childB: CompiledStep = {
233
+ type: StepType.Task,
234
+ id: '0.parallel.1:child_b',
235
+ label: 'child_b',
236
+ taskName: 'child_b',
237
+ };
238
+ const parallelStep: CompiledStep = {
239
+ type: StepType.Parallel,
240
+ id: '0:parallel',
241
+ label: 'parallel',
242
+ strategy: 'wait_all',
243
+ steps: [childA, childB],
244
+ };
245
+ const compiled = createCompiledWorkflow([parallelStep], {
246
+ child_a: firstTask,
247
+ child_b: secondTask,
248
+ });
249
+ const state: ExecutionState = {
250
+ input: {},
251
+ output: {},
252
+ iterationStack: [],
253
+ };
254
+ const deps = createDeps(compiled);
255
+
256
+ deps.executeFlow = jest
257
+ .fn()
258
+ .mockResolvedValue(undefined) as SuspensionRebuilderDeps['executeFlow'];
259
+ const suspension = rebuildSuspension(deps, {
260
+ state,
261
+ stepId: '0.parallel.1:child_b',
262
+ });
263
+
264
+ mockedRunParallelExecutor.mockResolvedValue(
265
+ 'next-suspension' as unknown as void,
266
+ );
267
+
268
+ const result = await suspension?.continue({ payload: true });
269
+
270
+ expect(deps.captureTaskOutput).toHaveBeenCalledWith(secondTask, state, {
271
+ payload: true,
272
+ });
273
+ expect(mockedRunParallelExecutor).toHaveBeenCalledWith(
274
+ expect.anything(),
275
+ parallelStep,
276
+ state,
277
+ [0],
278
+ 2,
279
+ );
280
+ expect(result).toBe('next-suspension');
281
+ });
282
+
283
+ it('resumes a loop suspension, updating accumulators and continuing execution', async () => {
284
+ const loopTask = createTask('loop_task');
285
+ const childTask: CompiledStep = {
286
+ type: StepType.Task,
287
+ id: '0.collector.0:loop_task',
288
+ label: 'loop_task',
289
+ taskName: 'loop_task',
290
+ };
291
+ const loopStep: CompiledStep = {
292
+ type: StepType.Loop,
293
+ id: '0:collector',
294
+ loopType: 'for_each',
295
+ label: 'collector',
296
+ name: 'collector',
297
+ forEach: { item: 'entry', in: { kind: 'literal', value: [] } },
298
+ steps: [childTask],
299
+ accumulate: {
300
+ as: 'sum',
301
+ initial: 0,
302
+ merge: { kind: 'literal', value: 0 },
303
+ },
304
+ };
305
+ const compiled = createCompiledWorkflow([loopStep], {
306
+ loop_task: loopTask,
307
+ });
308
+ const state: ExecutionState = {
309
+ input: {},
310
+ output: {},
311
+ iterationStack: [1],
312
+ accumulator: 2,
313
+ };
314
+ const deps = createDeps(compiled);
315
+
316
+ deps.executeFlow = jest
317
+ .fn()
318
+ .mockResolvedValue(undefined) as SuspensionRebuilderDeps['executeFlow'];
319
+
320
+ mockedUpdateAccumulator.mockResolvedValue(5);
321
+ mockedShouldStopLoop.mockResolvedValue(false);
322
+ mockedRunLoopExecutor.mockResolvedValue(
323
+ 'loop-continued' as unknown as void,
324
+ );
325
+
326
+ const suspension = rebuildSuspension(deps, {
327
+ state,
328
+ stepId: '0.collector.0:loop_task[1]',
329
+ });
330
+ const result = await suspension?.continue({ resumed: true });
331
+
332
+ expect(deps.captureTaskOutput).toHaveBeenCalledWith(
333
+ loopTask,
334
+ expect.objectContaining({ iteration: { index: 1, item: undefined } }),
335
+ { resumed: true },
336
+ );
337
+ expect(mockedUpdateAccumulator).toHaveBeenCalledWith(
338
+ loopStep,
339
+ expect.objectContaining({
340
+ iteration: { item: undefined, index: 1 },
341
+ accumulator: 2,
342
+ }),
343
+ 2,
344
+ );
345
+ expect(mockedShouldStopLoop).toHaveBeenCalled();
346
+ expect((state.output as { collector: { sum: number } }).collector.sum).toBe(
347
+ 5,
348
+ );
349
+ expect(mockedRunLoopExecutor).toHaveBeenCalledWith(
350
+ expect.anything(),
351
+ loopStep,
352
+ expect.objectContaining({ accumulator: 5, iterationStack: [] }),
353
+ [0],
354
+ 2,
355
+ );
356
+ const loopState = mockedRunLoopExecutor.mock.calls[0]?.[2];
357
+ expect(loopState?.accumulator).toBe(5);
358
+ expect(loopState?.iterationStack).toEqual([]);
359
+ expect(result).toBe('loop-continued');
360
+ });
361
+ });