@hexabot-ai/api 3.2.2-alpha.10 → 3.2.2-alpha.12

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 (172) hide show
  1. package/dist/actions/actions.service.d.ts +1 -0
  2. package/dist/actions/actions.service.js +19 -1
  3. package/dist/actions/actions.service.js.map +1 -1
  4. package/dist/app.module.js +2 -0
  5. package/dist/app.module.js.map +1 -1
  6. package/dist/extensions/actions/ai/mcp.binding.js +2 -0
  7. package/dist/extensions/actions/ai/mcp.binding.js.map +1 -1
  8. package/dist/extensions/actions/ai/memory.binding.js +2 -0
  9. package/dist/extensions/actions/ai/memory.binding.js.map +1 -1
  10. package/dist/extensions/actions/ai/retrieve-content-rag.action.js +2 -0
  11. package/dist/extensions/actions/ai/retrieve-content-rag.action.js.map +1 -1
  12. package/dist/extensions/actions/messaging/list.action.d.ts +2 -2
  13. package/dist/extensions/actions/messaging/list.action.js +7 -0
  14. package/dist/extensions/actions/messaging/list.action.js.map +1 -1
  15. package/dist/extensions/actions/subscriber/update-labels.action.js +3 -0
  16. package/dist/extensions/actions/subscriber/update-labels.action.js.map +1 -1
  17. package/dist/extensions/actions/workflow/call-workflow.action.d.ts +19 -0
  18. package/dist/extensions/actions/workflow/call-workflow.action.js +83 -0
  19. package/dist/extensions/actions/workflow/call-workflow.action.js.map +1 -0
  20. package/dist/extensions/actions/workflow/i18n/en.translations.json +9 -0
  21. package/dist/extensions/actions/workflow/i18n/fr.translations.json +9 -0
  22. package/dist/extensions/index.d.ts +1 -0
  23. package/dist/extensions/index.js +1 -0
  24. package/dist/extensions/index.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp/tools/hexabot-mcp.tools.d.ts +58 -0
  29. package/dist/static/assets/{browser-ponyfill-DN-Mu1Dr.js → browser-ponyfill-jlQAUd0m.js} +1 -1
  30. package/dist/static/assets/{cssMode-BCWxI_Ux.js → cssMode-CmMO_Mso.js} +1 -1
  31. package/dist/static/assets/{freemarker2-CFvevPey.js → freemarker2-DcQVPFyh.js} +1 -1
  32. package/dist/static/assets/{handlebars-BCStA9Eg.js → handlebars-DcnzpFQC.js} +1 -1
  33. package/dist/static/assets/{html-DkIwCA7q.js → html-BFzOjuas.js} +1 -1
  34. package/dist/static/assets/{htmlMode-DHeMkUp8.js → htmlMode-C-vRCUKF.js} +1 -1
  35. package/dist/static/assets/{index-C6VeVxYx.js → index-BKczCDFq.js} +2103 -2103
  36. package/dist/static/assets/{javascript-DLPKJzLc.js → javascript-Cw6lfMAI.js} +1 -1
  37. package/dist/static/assets/{jsonMode-BUIDFK3O.js → jsonMode-5xhIbw7r.js} +1 -1
  38. package/dist/static/assets/{liquid-B0XmwdKh.js → liquid-DRY_tZKy.js} +1 -1
  39. package/dist/static/assets/{lspLanguageFeatures-Fcpw3Ev_.js → lspLanguageFeatures-g0jq7rI8.js} +1 -1
  40. package/dist/static/assets/{mdx-DmJKi-dU.js → mdx-Cj1GGNiS.js} +1 -1
  41. package/dist/static/assets/{python-B1WnWY4I.js → python-Cx_web_-.js} +1 -1
  42. package/dist/static/assets/{razor-CL2s-6qt.js → razor-Bf3Htwpg.js} +1 -1
  43. package/dist/static/assets/{tsMode-DSFhDcf0.js → tsMode-DcrJAKHJ.js} +1 -1
  44. package/dist/static/assets/{typescript-CmHv7Jwl.js → typescript-D_NLpP2s.js} +1 -1
  45. package/dist/static/assets/{xml-Cyy6zXDM.js → xml-DX7g-Nrp.js} +1 -1
  46. package/dist/static/assets/{yaml-D_D6TAHE.js → yaml-BO2WdsT4.js} +1 -1
  47. package/dist/static/index.html +1 -1
  48. package/dist/static/locales/en/translation.json +7 -1
  49. package/dist/static/locales/fr/translation.json +8 -2
  50. package/dist/transfer/adapters/content-type-transfer.adapter.d.ts +14 -0
  51. package/dist/transfer/adapters/content-type-transfer.adapter.js +102 -0
  52. package/dist/transfer/adapters/content-type-transfer.adapter.js.map +1 -0
  53. package/dist/transfer/adapters/credential-transfer.adapter.d.ts +13 -0
  54. package/dist/transfer/adapters/credential-transfer.adapter.js +110 -0
  55. package/dist/transfer/adapters/credential-transfer.adapter.js.map +1 -0
  56. package/dist/transfer/adapters/label-transfer.adapter.d.ts +15 -0
  57. package/dist/transfer/adapters/label-transfer.adapter.js +189 -0
  58. package/dist/transfer/adapters/label-transfer.adapter.js.map +1 -0
  59. package/dist/transfer/adapters/mcp-server-transfer.adapter.d.ts +16 -0
  60. package/dist/transfer/adapters/mcp-server-transfer.adapter.js +169 -0
  61. package/dist/transfer/adapters/mcp-server-transfer.adapter.js.map +1 -0
  62. package/dist/transfer/adapters/memory-definition-transfer.adapter.d.ts +14 -0
  63. package/dist/transfer/adapters/memory-definition-transfer.adapter.js +111 -0
  64. package/dist/transfer/adapters/memory-definition-transfer.adapter.js.map +1 -0
  65. package/dist/transfer/index.d.ts +7 -0
  66. package/dist/transfer/index.js +24 -0
  67. package/dist/transfer/index.js.map +1 -0
  68. package/dist/transfer/workflow-transfer-adapter.registry.d.ts +27 -0
  69. package/dist/transfer/workflow-transfer-adapter.registry.js +164 -0
  70. package/dist/transfer/workflow-transfer-adapter.registry.js.map +1 -0
  71. package/dist/transfer/workflow-transfer-definition.service.d.ts +40 -0
  72. package/dist/transfer/workflow-transfer-definition.service.js +353 -0
  73. package/dist/transfer/workflow-transfer-definition.service.js.map +1 -0
  74. package/dist/transfer/workflow-transfer-resource-adapter.d.ts +33 -0
  75. package/dist/transfer/workflow-transfer-resource-adapter.js +81 -0
  76. package/dist/transfer/workflow-transfer-resource-adapter.js.map +1 -0
  77. package/dist/transfer/workflow-transfer.controller.d.ts +9 -0
  78. package/dist/transfer/workflow-transfer.controller.js +63 -0
  79. package/dist/transfer/workflow-transfer.controller.js.map +1 -0
  80. package/dist/transfer/workflow-transfer.module.d.ts +2 -0
  81. package/dist/transfer/workflow-transfer.module.js +47 -0
  82. package/dist/transfer/workflow-transfer.module.js.map +1 -0
  83. package/dist/transfer/workflow-transfer.service.d.ts +51 -0
  84. package/dist/transfer/workflow-transfer.service.js +509 -0
  85. package/dist/transfer/workflow-transfer.service.js.map +1 -0
  86. package/dist/transfer/workflow-transfer.types.d.ts +31 -0
  87. package/dist/transfer/workflow-transfer.types.js +35 -0
  88. package/dist/transfer/workflow-transfer.types.js.map +1 -0
  89. package/dist/tsconfig.build.tsbuildinfo +1 -1
  90. package/dist/user/dto/user.dto.d.ts +1 -1
  91. package/dist/utils/test/fixtures/label-group.d.ts +1 -1
  92. package/dist/utils/test/fixtures/label.d.ts +1 -1
  93. package/dist/utils/test/fixtures/subscriber.d.ts +1 -1
  94. package/dist/utils/test/fixtures/workflow-run.js +4 -1
  95. package/dist/utils/test/fixtures/workflow-run.js.map +1 -1
  96. package/dist/workflow/contexts/workflow-runtime.context.d.ts +3 -1
  97. package/dist/workflow/contexts/workflow-runtime.context.js +5 -0
  98. package/dist/workflow/contexts/workflow-runtime.context.js.map +1 -1
  99. package/dist/workflow/controllers/workflow-run.controller.js +2 -0
  100. package/dist/workflow/controllers/workflow-run.controller.js.map +1 -1
  101. package/dist/workflow/dto/workflow-run.dto.d.ts +1 -0
  102. package/dist/workflow/dto/workflow-run.dto.js +11 -0
  103. package/dist/workflow/dto/workflow-run.dto.js.map +1 -1
  104. package/dist/workflow/entities/memory-record.entity.d.ts +2 -0
  105. package/dist/workflow/entities/workflow-run.entity.d.ts +62 -0
  106. package/dist/workflow/entities/workflow-run.entity.js +15 -1
  107. package/dist/workflow/entities/workflow-run.entity.js.map +1 -1
  108. package/dist/workflow/index.d.ts +7 -0
  109. package/dist/workflow/index.js +7 -0
  110. package/dist/workflow/index.js.map +1 -1
  111. package/dist/workflow/repositories/workflow-run.repository.js +7 -1
  112. package/dist/workflow/repositories/workflow-run.repository.js.map +1 -1
  113. package/dist/workflow/resource-refs.d.ts +17 -0
  114. package/dist/workflow/resource-refs.js +15 -0
  115. package/dist/workflow/resource-refs.js.map +1 -0
  116. package/dist/workflow/services/agentic.service.d.ts +7 -1
  117. package/dist/workflow/services/agentic.service.js +134 -16
  118. package/dist/workflow/services/agentic.service.js.map +1 -1
  119. package/dist/workflow/services/workflow-run.service.d.ts +3 -0
  120. package/dist/workflow/services/workflow-run.service.js +75 -3
  121. package/dist/workflow/services/workflow-run.service.js.map +1 -1
  122. package/dist/workflow/services/workflow-version.service.d.ts +2 -0
  123. package/dist/workflow/services/workflow-version.service.js +19 -0
  124. package/dist/workflow/services/workflow-version.service.js.map +1 -1
  125. package/dist/workflow/services/workflow.service.d.ts +3 -1
  126. package/dist/workflow/services/workflow.service.js +5 -0
  127. package/dist/workflow/services/workflow.service.js.map +1 -1
  128. package/dist/workflow/types.d.ts +31 -0
  129. package/dist/workflow/types.js +2 -1
  130. package/dist/workflow/types.js.map +1 -1
  131. package/dist/workflow/workflow.module.js +3 -0
  132. package/dist/workflow/workflow.module.js.map +1 -1
  133. package/package.json +4 -4
  134. package/src/actions/actions.service.ts +34 -4
  135. package/src/app.module.ts +2 -0
  136. package/src/extensions/actions/ai/mcp.binding.ts +2 -0
  137. package/src/extensions/actions/ai/memory.binding.ts +2 -0
  138. package/src/extensions/actions/ai/retrieve-content-rag.action.ts +2 -0
  139. package/src/extensions/actions/messaging/list.action.ts +7 -0
  140. package/src/extensions/actions/subscriber/update-labels.action.ts +3 -0
  141. package/src/extensions/actions/workflow/call-workflow.action.ts +112 -0
  142. package/src/extensions/actions/workflow/i18n/en.translations.json +9 -0
  143. package/src/extensions/actions/workflow/i18n/fr.translations.json +9 -0
  144. package/src/extensions/index.ts +2 -0
  145. package/src/index.ts +2 -0
  146. package/src/transfer/adapters/content-type-transfer.adapter.ts +154 -0
  147. package/src/transfer/adapters/credential-transfer.adapter.ts +158 -0
  148. package/src/transfer/adapters/label-transfer.adapter.ts +291 -0
  149. package/src/transfer/adapters/mcp-server-transfer.adapter.ts +255 -0
  150. package/src/transfer/adapters/memory-definition-transfer.adapter.ts +167 -0
  151. package/src/transfer/index.ts +19 -0
  152. package/src/transfer/workflow-transfer-adapter.registry.ts +250 -0
  153. package/src/transfer/workflow-transfer-definition.service.ts +587 -0
  154. package/src/transfer/workflow-transfer-resource-adapter.ts +132 -0
  155. package/src/transfer/workflow-transfer.controller.ts +86 -0
  156. package/src/transfer/workflow-transfer.module.ts +46 -0
  157. package/src/transfer/workflow-transfer.service.ts +858 -0
  158. package/src/transfer/workflow-transfer.types.ts +98 -0
  159. package/src/utils/test/fixtures/workflow-run.ts +4 -1
  160. package/src/workflow/contexts/workflow-runtime.context.ts +12 -1
  161. package/src/workflow/controllers/workflow-run.controller.ts +2 -0
  162. package/src/workflow/dto/workflow-run.dto.ts +10 -0
  163. package/src/workflow/entities/workflow-run.entity.ts +22 -1
  164. package/src/workflow/index.ts +14 -0
  165. package/src/workflow/repositories/workflow-run.repository.ts +7 -1
  166. package/src/workflow/resource-refs.ts +44 -0
  167. package/src/workflow/services/agentic.service.ts +256 -26
  168. package/src/workflow/services/workflow-run.service.ts +120 -7
  169. package/src/workflow/services/workflow-version.service.ts +33 -0
  170. package/src/workflow/services/workflow.service.ts +21 -1
  171. package/src/workflow/types.ts +52 -0
  172. package/src/workflow/workflow.module.ts +3 -0
@@ -11,7 +11,7 @@ import {
11
11
  StepExecutionRecord,
12
12
  WorkflowRunner,
13
13
  } from '@hexabot-ai/agentic';
14
- import { WorkflowRunFull, WorkflowFull } from '@hexabot-ai/types';
14
+ import { WorkflowFull, WorkflowRunFull } from '@hexabot-ai/types';
15
15
  import { Injectable } from '@nestjs/common';
16
16
 
17
17
  import { ActionService } from '@/actions/actions.service';
@@ -20,16 +20,30 @@ import { I18nService } from '@/i18n/services/i18n.service';
20
20
  import { LoggerService } from '@/logger/logger.service';
21
21
 
22
22
  import { WorkflowContextFactory } from '../contexts/workflow-context-factory';
23
- import { WorkflowRuntimeContext } from '../contexts/workflow-runtime.context';
23
+ import type { WorkflowRuntimeContext } from '../contexts/workflow-runtime.context';
24
24
  import { TriggerEventWrapper } from '../lib/trigger-event-wrapper';
25
25
  import { parseWorkflowDefinition } from '../lib/workflow-definition';
26
- import { RunStrategy, RunWorkflowOptions, WorkflowResult } from '../types';
26
+ import type {
27
+ CallWorkflowOptions,
28
+ CallWorkflowResult,
29
+ RunStrategy,
30
+ RunWorkflowOptions,
31
+ WorkflowCallService,
32
+ WorkflowResult,
33
+ } from '../types';
27
34
 
28
35
  import { WorkflowRunService } from './workflow-run.service';
29
36
  import { WorkflowService } from './workflow.service';
30
37
 
38
+ const MAX_CALL_STACK_DEPTH = 10;
39
+
40
+ type RunWorkflowExecution = {
41
+ run: WorkflowRunFull;
42
+ result: WorkflowResult;
43
+ };
44
+
31
45
  @Injectable()
32
- export class AgenticService {
46
+ export class AgenticService implements WorkflowCallService {
33
47
  constructor(
34
48
  private readonly workflowService: WorkflowService,
35
49
  private readonly workflowRunService: WorkflowRunService,
@@ -42,7 +56,7 @@ export class AgenticService {
42
56
 
43
57
  /**
44
58
  * Process an event by resuming a suspended workflow run if it exists,
45
- * otherwise start a new run using the latest configured workflow (or the default fallback).
59
+ * otherwise start a new run using the latest configured workflow.
46
60
  */
47
61
  async handleEvent(
48
62
  event: TriggerEventWrapper,
@@ -75,11 +89,13 @@ export class AgenticService {
75
89
  suspensionReason: suspendedRun.suspensionReason,
76
90
  });
77
91
 
78
- return await this.runWorkflow({
92
+ const execution = await this.runWorkflow({
79
93
  mode: 'resume',
80
94
  run: suspendedRun,
81
95
  event,
82
96
  });
97
+
98
+ return execution.run;
83
99
  }
84
100
 
85
101
  const workflowToRun = requestedWorkflowId
@@ -104,11 +120,13 @@ export class AgenticService {
104
120
  workflowId: workflowToRun.id,
105
121
  });
106
122
 
107
- return await this.runWorkflow({
123
+ const execution = await this.runWorkflow({
108
124
  mode: 'start',
109
125
  workflow: workflowToRun,
110
126
  event,
111
127
  });
128
+
129
+ return execution.run;
112
130
  } catch (err) {
113
131
  this.logger.error(
114
132
  'Unable to process incoming event through agentic workflow',
@@ -120,20 +138,70 @@ export class AgenticService {
120
138
  }
121
139
 
122
140
  /**
123
- * Shared runner lifecycle for starting or resuming a workflow.
141
+ * Call another workflow from a running parent workflow.
142
+ *
143
+ * This is intentionally an API-level operation: workflows are database
144
+ * records, so type enforcement, call-stack validation, child run creation,
145
+ * and durable parent/child linkage all live here instead of in the agentic
146
+ * DSL runner.
147
+ */
148
+ async callWorkflow({
149
+ workflowId,
150
+ input,
151
+ parentContext,
152
+ }: CallWorkflowOptions): Promise<CallWorkflowResult> {
153
+ const parentRun = await this.workflowRunService.findOneAndPopulate(
154
+ parentContext.workflowRunId,
155
+ );
156
+ if (!parentRun) {
157
+ throw new Error(
158
+ `Unable to load parent workflow run ${parentContext.workflowRunId}`,
159
+ );
160
+ }
161
+
162
+ const workflow = await this.workflowService.findOneAndPopulate(workflowId);
163
+ if (!workflow) {
164
+ throw new Error(`Workflow with ID ${workflowId} not found`);
165
+ }
166
+ if (!workflow.definition) {
167
+ throw new Error(`Workflow ${workflowId} is missing a definition`);
168
+ }
169
+ if (workflow.type !== parentRun.workflow.type) {
170
+ throw new Error(
171
+ `Workflow ${workflowId} has type "${workflow.type}" and cannot be called from a "${parentRun.workflow.type}" workflow`,
172
+ );
173
+ }
174
+
175
+ await this.assertCanCallWorkflow(parentRun, workflow.id);
176
+
177
+ const execution = await this.runWorkflow({
178
+ mode: 'start',
179
+ workflow,
180
+ event: parentContext.event,
181
+ parentRun,
182
+ input,
183
+ });
184
+
185
+ return this.toCallWorkflowResult(execution.run, execution.result);
186
+ }
187
+
188
+ /**
189
+ * Shared runner lifecycle for starts, external resumes, and internal parent
190
+ * resumes. When a resumed child reaches a terminal state, this method also
191
+ * unwinds the suspended parent with the child result payload.
124
192
  */
125
193
  private async runWorkflow(
126
194
  options: RunWorkflowOptions,
127
- ): Promise<WorkflowRunFull> {
195
+ ): Promise<RunWorkflowExecution> {
128
196
  const { event, mode } = options;
129
197
  const run =
130
198
  mode === 'start'
131
- ? await this.createRun(options.workflow, event)
199
+ ? await this.createRun(options.workflow, event, {
200
+ parentRun: options.parentRun,
201
+ input: options.input,
202
+ })
132
203
  : options.run;
133
- const workflow = await this.workflowService.findOneAndPopulate(
134
- run.workflow.id,
135
- );
136
- const definition = this.resolveRunDefinition(run, workflow);
204
+ const definition = this.resolveRunDefinition(run);
137
205
  if (!definition) {
138
206
  throw new Error('Workflow definition is required to run the workflow');
139
207
  }
@@ -151,7 +219,7 @@ export class AgenticService {
151
219
  this.logger.debug('Preparing workflow runner', {
152
220
  mode,
153
221
  runId: run.id,
154
- workflowId: workflow?.id,
222
+ workflowId: run.workflow?.id,
155
223
  triggeredById: run.triggeredBy?.id,
156
224
  });
157
225
 
@@ -160,6 +228,7 @@ export class AgenticService {
160
228
  run,
161
229
  context,
162
230
  workflowInstance,
231
+ mode === 'resume' ? options.resumeData : undefined,
163
232
  );
164
233
 
165
234
  this.logger.debug('Marking workflow run as running', {
@@ -189,6 +258,17 @@ export class AgenticService {
189
258
  } catch (err) {
190
259
  this.logger.error('Workflow runner threw during execution', err);
191
260
  await this.markRunFailed(run, strategy.runner, context.state, err);
261
+ if (mode === 'resume') {
262
+ await this.resumeParentRunFromChild(
263
+ run,
264
+ {
265
+ status: 'failed',
266
+ error: err,
267
+ snapshot: strategy.runner.getSnapshot(),
268
+ },
269
+ event,
270
+ );
271
+ }
192
272
 
193
273
  throw err;
194
274
  }
@@ -208,7 +288,15 @@ export class AgenticService {
208
288
  context,
209
289
  );
210
290
 
211
- return (await this.workflowRunService.findOneAndPopulate(run.id)) ?? run;
291
+ if (mode === 'resume') {
292
+ await this.resumeParentRunFromChild(run, result, event);
293
+ }
294
+
295
+ const persistedRun = await this.workflowRunService.findOneAndPopulate(
296
+ run.id,
297
+ );
298
+
299
+ return { run: persistedRun ?? run, result };
212
300
  }
213
301
 
214
302
  /**
@@ -231,12 +319,16 @@ export class AgenticService {
231
319
 
232
320
  /**
233
321
  * Build the execution strategy for starting a new workflow or resuming an existing one.
322
+ *
323
+ * `resumeData` is supplied only for internal child-to-parent unwinds; normal
324
+ * event resumes use the current event input.
234
325
  */
235
326
  private async createRunStrategy(
236
327
  mode: 'start' | 'resume',
237
328
  run: WorkflowRunFull,
238
329
  context: WorkflowRuntimeContext,
239
330
  workflowInstance: AgenticWorkflow,
331
+ resumeData?: unknown,
240
332
  ): Promise<RunStrategy> {
241
333
  if (mode === 'start') {
242
334
  const runner = await workflowInstance.buildAsyncRunner({
@@ -256,7 +348,7 @@ export class AgenticService {
256
348
  };
257
349
  }
258
350
 
259
- const latestInput = context.event.buildInput();
351
+ const latestInput = resumeData ?? context.event.buildInput();
260
352
  const runner = await workflowInstance.buildRunnerFromState({
261
353
  state: this.buildExecutionState(run),
262
354
  context,
@@ -289,10 +381,18 @@ export class AgenticService {
289
381
 
290
382
  /**
291
383
  * Create a workflow run record and load it with relations.
384
+ *
385
+ * Child calls inherit the same event metadata, initiator, and thread as the
386
+ * parent, but store an explicit `parentRun` relation so future events can
387
+ * resume the deepest suspended child before returning to the parent.
292
388
  */
293
389
  private async createRun(
294
390
  workflow: WorkflowFull,
295
391
  event: TriggerEventWrapper,
392
+ options: {
393
+ parentRun?: WorkflowRunFull;
394
+ input?: Record<string, unknown>;
395
+ } = {},
296
396
  ): Promise<WorkflowRunFull> {
297
397
  const initiator = event.getInitiator();
298
398
  if (!workflow.definition) {
@@ -307,7 +407,8 @@ export class AgenticService {
307
407
  workflowVersion: workflow.currentVersion?.id ?? null,
308
408
  triggeredBy: initiator.id,
309
409
  thread: event.getThreadId() ?? null,
310
- input: event.buildInput(),
410
+ parentRun: options.parentRun?.id ?? null,
411
+ input: options.input ?? event.buildInput(),
311
412
  context: Object.keys(initialContext).length > 0 ? initialContext : null,
312
413
  metadata: event.getMetadata(),
313
414
  });
@@ -321,18 +422,147 @@ export class AgenticService {
321
422
  }
322
423
 
323
424
  /**
324
- * Resolve the workflow definition used by the current run.
425
+ * Validate that a workflow call does not create a recursive or overly deep stack.
426
+ *
427
+ * The walk follows active parent links, so only the current call stack is
428
+ * rejected; a workflow can still be called again after an earlier stack has
429
+ * completed.
430
+ */
431
+ private async assertCanCallWorkflow(
432
+ parentRun: WorkflowRunFull,
433
+ targetWorkflowId: string,
434
+ ): Promise<void> {
435
+ let current: WorkflowRunFull | null = parentRun;
436
+ let depth = 0;
437
+
438
+ while (current) {
439
+ depth += 1;
440
+ if (depth >= MAX_CALL_STACK_DEPTH) {
441
+ throw new Error(
442
+ `Workflow call stack depth cannot exceed ${MAX_CALL_STACK_DEPTH}`,
443
+ );
444
+ }
445
+
446
+ if (current.workflow.id === targetWorkflowId) {
447
+ throw new Error(
448
+ `Workflow call cycle detected for workflow ${targetWorkflowId}`,
449
+ );
450
+ }
451
+
452
+ const parentRunId = this.resolveRunId(current.parentRun);
453
+ current = parentRunId
454
+ ? await this.workflowRunService.findOneAndPopulate(parentRunId)
455
+ : null;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Convert a child run terminal/suspended result into the `call_workflow`
461
+ * contract. The same payload shape is used for immediate returns and for
462
+ * resuming a parent after the child completed in a later event.
325
463
  */
326
- private resolveRunDefinition(
464
+ private toCallWorkflowResult(
327
465
  run: WorkflowRunFull,
328
- workflow?: WorkflowFull | null,
329
- ) {
466
+ result: WorkflowResult,
467
+ ): CallWorkflowResult {
468
+ if (result.status === 'finished') {
469
+ return {
470
+ status: 'finished',
471
+ workflow_id: run.workflow.id,
472
+ workflow_run_id: run.id,
473
+ output: result.output,
474
+ };
475
+ }
476
+
477
+ if (result.status === 'suspended') {
478
+ return {
479
+ status: 'suspended',
480
+ workflow_id: run.workflow.id,
481
+ workflow_run_id: run.id,
482
+ };
483
+ }
484
+
485
+ return {
486
+ status: 'failed',
487
+ workflow_id: run.workflow.id,
488
+ workflow_run_id: run.id,
489
+ error: this.stringifyError(result.error),
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Resume a suspended parent run after its child run completes or fails.
495
+ *
496
+ * This may cascade through multiple ancestors because resuming the parent can
497
+ * itself finish a child call that belongs to another suspended parent.
498
+ */
499
+ private async resumeParentRunFromChild(
500
+ childRun: WorkflowRunFull,
501
+ result: WorkflowResult,
502
+ event: TriggerEventWrapper,
503
+ ): Promise<void> {
504
+ if (result.status === 'suspended') {
505
+ return;
506
+ }
507
+
508
+ const parentRunId = this.resolveRunId(childRun.parentRun);
509
+ if (!parentRunId) {
510
+ return;
511
+ }
512
+
513
+ const parentRun =
514
+ await this.workflowRunService.findOneAndPopulate(parentRunId);
515
+ if (!parentRun) {
516
+ this.logger.warn('Unable to resume missing parent workflow run', {
517
+ childRunId: childRun.id,
518
+ parentRunId,
519
+ });
520
+
521
+ return;
522
+ }
523
+
524
+ if (parentRun.status !== 'suspended') {
525
+ this.logger.warn(
526
+ 'Skipping parent workflow resume because it is not suspended',
527
+ {
528
+ childRunId: childRun.id,
529
+ parentRunId,
530
+ parentStatus: parentRun.status,
531
+ },
532
+ );
533
+
534
+ return;
535
+ }
536
+
537
+ await this.runWorkflow({
538
+ mode: 'resume',
539
+ run: parentRun,
540
+ event,
541
+ resumeData: this.toCallWorkflowResult(childRun, result),
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Resolve a workflow run relation from either a populated object or id.
547
+ */
548
+ private resolveRunId(
549
+ run: string | { id?: string | null } | null | undefined,
550
+ ): string | null {
551
+ if (typeof run === 'string') {
552
+ return run;
553
+ }
554
+
555
+ return run?.id ?? null;
556
+ }
557
+
558
+ /**
559
+ * Resolve the workflow definition snapshot used by the current run.
560
+ */
561
+ private resolveRunDefinition(run: WorkflowRunFull) {
330
562
  const definitionYml = run.workflowVersion?.definitionYml;
331
563
  if (typeof definitionYml === 'string' && definitionYml.trim() !== '') {
332
564
  return parseWorkflowDefinition(definitionYml);
333
565
  }
334
-
335
- return workflow?.definition;
336
566
  }
337
567
 
338
568
  /**
@@ -471,7 +701,7 @@ export class AgenticService {
471
701
  private pickOutput(
472
702
  result: WorkflowResult,
473
703
  state: ExecutionState | undefined,
474
- fallback?: Record<string, unknown> | null,
704
+ previousOutput?: Record<string, unknown> | null,
475
705
  ): Record<string, unknown> | null {
476
706
  if (result.status === 'finished' && result.output) {
477
707
  return result.output;
@@ -481,7 +711,7 @@ export class AgenticService {
481
711
  return state.output;
482
712
  }
483
713
 
484
- return fallback ?? null;
714
+ return previousOutput ?? null;
485
715
  }
486
716
 
487
717
  /**
@@ -4,9 +4,10 @@
4
4
  * Full terms: see LICENSE.md.
5
5
  */
6
6
 
7
- import { WorkflowSnapshot } from '@hexabot-ai/agentic';
7
+ import { EWorkflowRunStatus, WorkflowSnapshot } from '@hexabot-ai/agentic';
8
8
  import { WorkflowRun, WorkflowRunFull } from '@hexabot-ai/types';
9
9
  import { Injectable } from '@nestjs/common';
10
+ import { In } from 'typeorm';
10
11
 
11
12
  import { BaseOrmService } from '@/utils/generics/base-orm.service';
12
13
 
@@ -18,6 +19,12 @@ type StateUpdate = {
18
19
  context?: Record<string, unknown> | null;
19
20
  };
20
21
 
22
+ const ACTIVE_WORKFLOW_RUN_STATUSES = [
23
+ EWorkflowRunStatus.IDLE,
24
+ EWorkflowRunStatus.RUNNING,
25
+ EWorkflowRunStatus.SUSPENDED,
26
+ ];
27
+
21
28
  @Injectable()
22
29
  export class WorkflowRunService extends BaseOrmService<WorkflowRunOrmEntity> {
23
30
  /**
@@ -130,25 +137,131 @@ export class WorkflowRunService extends BaseOrmService<WorkflowRunOrmEntity> {
130
137
  }
131
138
 
132
139
  /**
133
- * Find the latest suspended run for a triggering subscriber.
140
+ * Find the suspended run that should receive the next event.
141
+ *
142
+ * For call-and-return stacks, the parent can be suspended while the child is
143
+ * either running or suspended for the same initiator/thread. The next external
144
+ * event must resume the deepest suspended leaf first; once that child
145
+ * completes, AgenticService resumes its parent internally with the child
146
+ * result.
134
147
  *
135
148
  * @param triggeredById - Identifier of the subscriber whose suspended run should be fetched.
136
- * @param workflowId - Optional workflow identifier to scope the suspended run lookup.
137
- * @returns The most recently suspended run populated with relations, or `null` when none exists.
149
+ * @param threadId - Optional thread id for conversational continuity.
150
+ * @param workflowId - Optional workflow id; a parent match still resolves to its child leaf.
151
+ * @returns The suspended leaf run populated with relations, or `null` when none exists.
138
152
  */
139
153
  async findSuspendedRunByInitiator(
140
154
  triggeredById: string,
141
155
  threadId?: string,
142
156
  workflowId?: string,
143
157
  ): Promise<WorkflowRunFull | null> {
144
- return await this.findOneAndPopulate({
158
+ const runs = await this.findAndPopulate({
145
159
  where: {
146
160
  triggeredBy: { id: triggeredById },
147
161
  ...(threadId ? { thread: { id: threadId } } : {}),
148
- status: 'suspended',
149
- ...(workflowId ? { workflow: { id: workflowId } } : {}),
162
+ status: In(ACTIVE_WORKFLOW_RUN_STATUSES),
150
163
  },
151
164
  order: { suspendedAt: 'DESC', createdAt: 'DESC' },
152
165
  });
166
+
167
+ if (runs.length === 0) {
168
+ return null;
169
+ }
170
+
171
+ const runsById = new Map(runs.map((run) => [run.id, run]));
172
+ const parentRunIds = new Set(
173
+ runs
174
+ .map((run) => this.resolveRunId(run.parentRun))
175
+ .filter((value): value is string => typeof value === 'string'),
176
+ );
177
+ // Leaves are active runs that are not the parent of another unfinished
178
+ // candidate. Only suspended leaves can consume external events; running or
179
+ // idle leaves are still in flight and block their suspended ancestors.
180
+ const leaves = runs.filter((run) => !parentRunIds.has(run.id));
181
+ const candidates = (leaves.length > 0 ? leaves : runs)
182
+ .filter((run) => run.status === EWorkflowRunStatus.SUSPENDED)
183
+ .filter((run) =>
184
+ workflowId
185
+ ? this.isRunInWorkflowStack(run, workflowId, runsById)
186
+ : true,
187
+ );
188
+
189
+ return this.pickDeepestSuspendedRun(candidates, runsById);
190
+ }
191
+
192
+ private resolveRunId(
193
+ run: string | { id?: string | null } | null | undefined,
194
+ ): string | null {
195
+ if (typeof run === 'string') {
196
+ return run;
197
+ }
198
+
199
+ return run?.id ?? null;
200
+ }
201
+
202
+ private isRunInWorkflowStack(
203
+ run: WorkflowRunFull,
204
+ workflowId: string,
205
+ runsById: Map<string, WorkflowRunFull>,
206
+ ): boolean {
207
+ let current: WorkflowRunFull | undefined = run;
208
+ const visited = new Set<string>();
209
+
210
+ while (current && !visited.has(current.id)) {
211
+ visited.add(current.id);
212
+ if (current.workflow.id === workflowId) {
213
+ return true;
214
+ }
215
+ const parentRunId = this.resolveRunId(current.parentRun);
216
+ current = parentRunId ? runsById.get(parentRunId) : undefined;
217
+ }
218
+
219
+ return false;
220
+ }
221
+
222
+ private pickDeepestSuspendedRun(
223
+ runs: WorkflowRunFull[],
224
+ runsById: Map<string, WorkflowRunFull>,
225
+ ): WorkflowRunFull | null {
226
+ if (runs.length === 0) {
227
+ return null;
228
+ }
229
+
230
+ const getDepth = (run: WorkflowRunFull): number => {
231
+ let depth = 0;
232
+ let current: WorkflowRunFull | undefined = run;
233
+ const visited = new Set<string>();
234
+
235
+ while (current && !visited.has(current.id)) {
236
+ visited.add(current.id);
237
+ depth += 1;
238
+ const parentRunId = this.resolveRunId(current.parentRun);
239
+ current = parentRunId ? runsById.get(parentRunId) : undefined;
240
+ }
241
+
242
+ return depth;
243
+ };
244
+ const getTime = (value?: Date | string | null): number => {
245
+ if (!value) {
246
+ return 0;
247
+ }
248
+
249
+ const date = value instanceof Date ? value : new Date(value);
250
+ const timestamp = date.getTime();
251
+
252
+ return Number.isNaN(timestamp) ? 0 : timestamp;
253
+ };
254
+
255
+ return [...runs].sort((a, b) => {
256
+ const depthDiff = getDepth(b) - getDepth(a);
257
+ if (depthDiff !== 0) {
258
+ return depthDiff;
259
+ }
260
+
261
+ return (
262
+ getTime(b.suspendedAt) - getTime(a.suspendedAt) ||
263
+ getTime(b.createdAt) - getTime(a.createdAt)
264
+ );
265
+ })[0];
153
266
  }
154
267
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { WorkflowVersion } from '@hexabot-ai/types';
8
8
  import { Injectable, NotFoundException } from '@nestjs/common';
9
+ import { EntityManager } from 'typeorm';
9
10
 
10
11
  import { BaseOrmService } from '@/utils/generics/base-orm.service';
11
12
 
@@ -63,6 +64,38 @@ export class WorkflowVersionService extends BaseOrmService<WorkflowVersionOrmEnt
63
64
  });
64
65
  }
65
66
 
67
+ /**
68
+ * Persist a workflow version snapshot with the provided transaction manager.
69
+ *
70
+ * This method mirrors createSnapshot's version-number behavior without
71
+ * escaping the caller's transaction or emitting repository-level events.
72
+ */
73
+ public async createSnapshotWithManager(
74
+ manager: EntityManager,
75
+ params: WorkflowNewVersionDto,
76
+ ): Promise<WorkflowVersionOrmEntity> {
77
+ const latestVersion = await manager.findOne(WorkflowVersionOrmEntity, {
78
+ where: { workflow: { id: params.workflow } },
79
+ order: { version: 'DESC' },
80
+ });
81
+ const nextVersion = latestVersion ? latestVersion.version + 1 : 1;
82
+
83
+ return await manager.save(
84
+ WorkflowVersionOrmEntity,
85
+ manager.create(WorkflowVersionOrmEntity, {
86
+ workflow: { id: params.workflow },
87
+ version: nextVersion,
88
+ definitionYml: params.definitionYml,
89
+ action: params.action,
90
+ message: params.message,
91
+ parentVersion: params.parentVersion
92
+ ? { id: params.parentVersion }
93
+ : null,
94
+ createdBy: params.createdBy ? { id: params.createdBy } : null,
95
+ }),
96
+ );
97
+ }
98
+
66
99
  /**
67
100
  * Restore a workflow to a prior version by creating a new snapshot.
68
101
  *
@@ -12,11 +12,12 @@ import {
12
12
  InternalServerErrorException,
13
13
  } from '@nestjs/common';
14
14
  import { HookEventKey, OnEvent } from '@nestjs/event-emitter';
15
- import { FindOneOptions } from 'typeorm';
15
+ import { EntityManager, FindOneOptions } from 'typeorm';
16
16
  import { z, ZodType } from 'zod';
17
17
 
18
18
  import { UpdateOneOptions } from '@/utils/generics/base-orm.repository';
19
19
  import { BaseOrmService } from '@/utils/generics/base-orm.service';
20
+ import { InferCreateDto } from '@/utils/types/dto.types';
20
21
  import {
21
22
  Room,
22
23
  SocketGet,
@@ -68,6 +69,25 @@ export class WorkflowService extends BaseOrmService<WorkflowOrmEntity> {
68
69
  return latest ?? null;
69
70
  }
70
71
 
72
+ /**
73
+ * Persist a workflow using the provided transaction manager.
74
+ *
75
+ * This method intentionally does not emit BaseOrmRepository mutation events;
76
+ * callers that own a larger transaction must emit app-level events after
77
+ * commit.
78
+ */
79
+ async createWithManager(
80
+ manager: EntityManager,
81
+ payload: InferCreateDto<WorkflowOrmEntity>,
82
+ ): Promise<WorkflowOrmEntity> {
83
+ const entity = manager.create(
84
+ WorkflowOrmEntity,
85
+ this.repository.actionDtoToEntity(payload),
86
+ );
87
+
88
+ return await manager.save(WorkflowOrmEntity, entity);
89
+ }
90
+
71
91
  /**
72
92
  * Update a workflow while enforcing immutability of its trigger type.
73
93
  */