|
3 | 3 | import { describe, it, expect, beforeEach } from 'vitest'; |
4 | 4 | import { AutomationEngine } from '../engine.js'; |
5 | 5 | import type { NodeExecutor } from '../engine.js'; |
| 6 | +import { InMemorySuspendedRunStore } from '../suspended-run-store.js'; |
6 | 7 | import { registerSubflowNode } from './subflow-node.js'; |
7 | 8 |
|
8 | 9 | function silentLogger() { |
@@ -43,11 +44,31 @@ describe('subflow node executor', () => { |
43 | 44 | return { success: true }; |
44 | 45 | }, |
45 | 46 | } as NodeExecutor); |
46 | | - // A node that suspends (to exercise the nested-pause guard). |
| 47 | + // A node that suspends (to exercise nested durable pause). |
47 | 48 | engine.registerNodeExecutor({ |
48 | 49 | type: 'pauser', |
49 | 50 | async execute() { return { success: true, suspend: true }; }, |
50 | 51 | } as NodeExecutor); |
| 52 | + // A screen-style pauser: suspends surfacing the screen from node config. |
| 53 | + engine.registerNodeExecutor({ |
| 54 | + type: 'screenpauser', |
| 55 | + async execute(node) { |
| 56 | + return { success: true, suspend: true, screen: (node.config as any)?.screen }; |
| 57 | + }, |
| 58 | + } as NodeExecutor); |
| 59 | + // Copies the screen-collected `new_val` (a bare resumed variable) to `result`. |
| 60 | + engine.registerNodeExecutor({ |
| 61 | + type: 'copier', |
| 62 | + async execute(_node, variables) { |
| 63 | + variables.set('result', variables.get('new_val')); |
| 64 | + return { success: true }; |
| 65 | + }, |
| 66 | + } as NodeExecutor); |
| 67 | + // Fails terminally (post-pause failure propagation). |
| 68 | + engine.registerNodeExecutor({ |
| 69 | + type: 'failer', |
| 70 | + async execute() { return { success: false, error: 'boom' }; }, |
| 71 | + } as NodeExecutor); |
51 | 72 |
|
52 | 73 | engine.registerFlow('child_flow', { |
53 | 74 | name: 'child_flow', |
@@ -118,25 +139,207 @@ describe('subflow node executor', () => { |
118 | 139 | expect(captured).toEqual([]); // downstream did not run |
119 | 140 | }); |
120 | 141 |
|
121 | | - it('fails with a clear error when the child suspends (nested pause unsupported)', async () => { |
122 | | - engine.registerFlow('paused_child', { |
123 | | - name: 'paused_child', |
124 | | - label: 'Paused Child', |
| 142 | + // ── Nested durable pause (linked-runs model) ───────────────────────── |
| 143 | + |
| 144 | + /** Child that pauses, then sets its output var when resumed. */ |
| 145 | + const pausedChild = (pauseNodes: Array<{ id: string; type: string; config?: Record<string, unknown> }>) => ({ |
| 146 | + name: 'paused_child', |
| 147 | + label: 'Paused Child', |
| 148 | + type: 'autolaunched', |
| 149 | + variables: [{ name: 'result', type: 'text', isOutput: true }], |
| 150 | + nodes: [ |
| 151 | + { id: 's', type: 'start', label: 'Start' }, |
| 152 | + ...pauseNodes.map((n) => ({ label: n.id, ...n })), |
| 153 | + { id: 'cm', type: 'childmark', label: 'Child Work' }, |
| 154 | + { id: 'e', type: 'end', label: 'End' }, |
| 155 | + ], |
| 156 | + edges: [ |
| 157 | + { id: 'es', source: 's', target: pauseNodes[0].id }, |
| 158 | + ...pauseNodes.map((n, i) => ({ |
| 159 | + id: `ep${i}`, |
| 160 | + source: n.id, |
| 161 | + target: pauseNodes[i + 1]?.id ?? 'cm', |
| 162 | + })), |
| 163 | + { id: 'ee', source: 'cm', target: 'e' }, |
| 164 | + ], |
| 165 | + }); |
| 166 | + |
| 167 | + const registerPausingPair = (pauseNodes: Array<{ id: string; type: string; config?: Record<string, unknown> }>) => { |
| 168 | + engine.registerFlow('paused_child', pausedChild(pauseNodes) as never); |
| 169 | + engine.registerFlow('parent_flow', parentFlow({ flowName: 'paused_child', outputVariable: 'subResult' })); |
| 170 | + }; |
| 171 | + |
| 172 | + const suspendedByFlow = (name: string) => |
| 173 | + engine.listSuspendedRuns().find((r) => r.flowName === name); |
| 174 | + |
| 175 | + it('suspends the parent (not fails) when the child pauses, linking the runs', async () => { |
| 176 | + registerPausingPair([{ id: 'p', type: 'pauser' }]); |
| 177 | + const result = await engine.execute('parent_flow'); |
| 178 | + |
| 179 | + expect(result.success).toBe(true); |
| 180 | + expect(result.status).toBe('paused'); |
| 181 | + const parent = suspendedByFlow('parent_flow'); |
| 182 | + const child = suspendedByFlow('paused_child'); |
| 183 | + expect(parent).toBeDefined(); |
| 184 | + expect(child).toBeDefined(); |
| 185 | + expect(result.runId).toBe(parent!.runId); |
| 186 | + expect(parent!.nodeId).toBe('call'); |
| 187 | + expect(parent!.correlation).toBe(`subflow:${child!.runId}`); |
| 188 | + }); |
| 189 | + |
| 190 | + it('bubbles a directly-resumed child completion up to the parent (approval/wait path)', async () => { |
| 191 | + registerPausingPair([{ id: 'p', type: 'pauser' }]); |
| 192 | + await engine.execute('parent_flow'); |
| 193 | + const child = suspendedByFlow('paused_child')!; |
| 194 | + |
| 195 | + const childRes = await engine.resume(child.runId); |
| 196 | + |
| 197 | + expect(childRes.success).toBe(true); |
| 198 | + expect(childRes.status).toBeUndefined(); // child ran to completion |
| 199 | + // Parent auto-continued: downstream captured the mapped output, both rows gone. |
| 200 | + expect(captured).toEqual([{ result: 'CHILD_DONE' }]); |
| 201 | + expect(engine.listSuspendedRuns()).toHaveLength(0); |
| 202 | + }); |
| 203 | + |
| 204 | + it('delegates a parent resume down to the child (screen-flow path), surfacing the child screen', async () => { |
| 205 | + const screen = { nodeId: 'p', title: 'Collect', fields: [{ name: 'new_val', type: 'text' }] }; |
| 206 | + registerPausingPair([{ id: 'p', type: 'screenpauser', config: { screen } }]); |
| 207 | + // Replace cm: copy the collected input instead of the static marker. |
| 208 | + const flow = pausedChild([{ id: 'p', type: 'screenpauser', config: { screen } }]); |
| 209 | + flow.nodes = flow.nodes.map((n) => (n.id === 'cm' ? { ...n, type: 'copier' } : n)); |
| 210 | + engine.registerFlow('paused_child', flow as never); |
| 211 | + |
| 212 | + const result = await engine.execute('parent_flow'); |
| 213 | + expect(result.status).toBe('paused'); |
| 214 | + expect(result.screen).toEqual(screen); // nested screen surfaces on the parent result |
| 215 | + |
| 216 | + const parentRunId = result.runId!; |
| 217 | + const final = await engine.resume(parentRunId, { variables: { new_val: 'typed-in' } }); |
| 218 | + |
| 219 | + expect(final.success).toBe(true); |
| 220 | + expect(final.status).toBeUndefined(); |
| 221 | + expect(captured).toEqual([{ result: 'typed-in' }]); |
| 222 | + expect(engine.listSuspendedRuns()).toHaveLength(0); |
| 223 | + }); |
| 224 | + |
| 225 | + it('keeps the parent paused across a multi-screen child wizard', async () => { |
| 226 | + const s1 = { nodeId: 'p1', title: 'Step 1', fields: [{ name: 'new_val', type: 'text' }] }; |
| 227 | + const s2 = { nodeId: 'p2', title: 'Step 2', fields: [{ name: 'other', type: 'text' }] }; |
| 228 | + const flow = pausedChild([ |
| 229 | + { id: 'p1', type: 'screenpauser', config: { screen: s1 } }, |
| 230 | + { id: 'p2', type: 'screenpauser', config: { screen: s2 } }, |
| 231 | + ]); |
| 232 | + flow.nodes = flow.nodes.map((n) => (n.id === 'cm' ? { ...n, type: 'copier' } : n)); |
| 233 | + engine.registerFlow('paused_child', flow as never); |
| 234 | + engine.registerFlow('parent_flow', parentFlow({ flowName: 'paused_child', outputVariable: 'subResult' })); |
| 235 | + |
| 236 | + const r1 = await engine.execute('parent_flow'); |
| 237 | + expect(r1.status).toBe('paused'); |
| 238 | + expect(r1.screen).toEqual(s1); |
| 239 | + const parentRunId = r1.runId!; |
| 240 | + |
| 241 | + const r2 = await engine.resume(parentRunId, { variables: { new_val: 'v1' } }); |
| 242 | + expect(r2.status).toBe('paused'); |
| 243 | + expect(r2.runId).toBe(parentRunId); // UI keeps one stable run id |
| 244 | + expect(r2.screen).toEqual(s2); // next wizard screen |
| 245 | + expect(engine.getSuspendedScreen(parentRunId)).toEqual(s2); // refresh-safe re-fetch |
| 246 | + |
| 247 | + const r3 = await engine.resume(parentRunId, { variables: { other: 'x' } }); |
| 248 | + expect(r3.success).toBe(true); |
| 249 | + expect(r3.status).toBeUndefined(); |
| 250 | + expect(captured).toEqual([{ result: 'v1' }]); |
| 251 | + expect(engine.listSuspendedRuns()).toHaveLength(0); |
| 252 | + }); |
| 253 | + |
| 254 | + it('bubbles through two levels of nesting', async () => { |
| 255 | + registerPausingPair([{ id: 'p', type: 'pauser' }]); |
| 256 | + // grandparent → parent_flow → paused_child |
| 257 | + engine.registerNodeExecutor({ |
| 258 | + type: 'grandcheck', |
| 259 | + async execute(_node, variables) { |
| 260 | + captured.push(variables.get('grandResult')); |
| 261 | + return { success: true }; |
| 262 | + }, |
| 263 | + } as NodeExecutor); |
| 264 | + engine.registerFlow('grand_flow', { |
| 265 | + name: 'grand_flow', |
| 266 | + label: 'Grand Flow', |
125 | 267 | type: 'autolaunched', |
126 | 268 | nodes: [ |
127 | | - { id: 's', type: 'start', label: 'Start' }, |
128 | | - { id: 'p', type: 'pauser', label: 'Pause' }, |
129 | | - { id: 'e', type: 'end', label: 'End' }, |
| 269 | + { id: 'gs', type: 'start', label: 'Start' }, |
| 270 | + { id: 'gcall', type: 'subflow', label: 'Call Parent', config: { flowName: 'parent_flow', outputVariable: 'grandResult' } }, |
| 271 | + { id: 'gchk', type: 'grandcheck', label: 'Check' }, |
| 272 | + { id: 'ge', type: 'end', label: 'End' }, |
130 | 273 | ], |
131 | 274 | edges: [ |
132 | | - { id: 'a', source: 's', target: 'p' }, |
133 | | - { id: 'b', source: 'p', target: 'e' }, |
| 275 | + { id: 'g1', source: 'gs', target: 'gcall' }, |
| 276 | + { id: 'g2', source: 'gcall', target: 'gchk' }, |
| 277 | + { id: 'g3', source: 'gchk', target: 'ge' }, |
134 | 278 | ], |
135 | | - }); |
136 | | - engine.registerFlow('parent_flow', parentFlow({ flowName: 'paused_child' })); |
137 | | - const result = await engine.execute('parent_flow'); |
138 | | - expect(result.success).toBe(false); |
139 | | - expect(result.error).toMatch(/suspended/i); |
| 279 | + } as never); |
| 280 | + |
| 281 | + const result = await engine.execute('grand_flow'); |
| 282 | + expect(result.status).toBe('paused'); |
| 283 | + expect(engine.listSuspendedRuns()).toHaveLength(3); // grand + parent + child |
| 284 | + |
| 285 | + const child = suspendedByFlow('paused_child')!; |
| 286 | + const childRes = await engine.resume(child.runId); |
| 287 | + expect(childRes.success).toBe(true); |
| 288 | + // parentcheck captured the child output; grandcheck captured the parent output (its output vars — none declared → {}). |
| 289 | + expect(captured[0]).toEqual({ result: 'CHILD_DONE' }); |
| 290 | + expect(captured).toHaveLength(2); |
| 291 | + expect(engine.listSuspendedRuns()).toHaveLength(0); |
| 292 | + }); |
| 293 | + |
| 294 | + it('survives a process restart: chain persisted, resume on a fresh engine bubbles to the parent', async () => { |
| 295 | + const store = new InMemorySuspendedRunStore(); |
| 296 | + engine.setSuspendedRunStore(store); |
| 297 | + registerPausingPair([{ id: 'p', type: 'pauser' }]); |
| 298 | + await engine.execute('parent_flow'); |
| 299 | + const child = suspendedByFlow('paused_child')!; |
| 300 | + expect((await store.list()).length).toBe(2); |
| 301 | + |
| 302 | + // "Restart": a fresh engine sharing only the durable store + flow registry. |
| 303 | + const engineB = new AutomationEngine(silentLogger(), store); |
| 304 | + registerSubflowNode(engineB, ctx()); |
| 305 | + const capturedB: unknown[] = []; |
| 306 | + engineB.registerNodeExecutor({ |
| 307 | + type: 'childmark', |
| 308 | + async execute(_node, variables) { variables.set('result', 'CHILD_DONE'); return { success: true }; }, |
| 309 | + } as NodeExecutor); |
| 310 | + engineB.registerNodeExecutor({ |
| 311 | + type: 'parentcheck', |
| 312 | + async execute(_node, variables) { capturedB.push(variables.get('subResult')); return { success: true }; }, |
| 313 | + } as NodeExecutor); |
| 314 | + engineB.registerNodeExecutor({ type: 'pauser', async execute() { return { success: true, suspend: true }; } } as NodeExecutor); |
| 315 | + engineB.registerFlow('paused_child', pausedChild([{ id: 'p', type: 'pauser' }]) as never); |
| 316 | + engineB.registerFlow('parent_flow', parentFlow({ flowName: 'paused_child', outputVariable: 'subResult' }) as never); |
| 317 | + |
| 318 | + const res = await engineB.resume(child.runId); |
| 319 | + expect(res.success).toBe(true); |
| 320 | + expect(capturedB).toEqual([{ result: 'CHILD_DONE' }]); |
| 321 | + expect(await store.list()).toHaveLength(0); // both rows consumed |
| 322 | + }); |
| 323 | + |
| 324 | + it('fails the waiting parent when the resumed child fails terminally', async () => { |
| 325 | + const flow = pausedChild([{ id: 'p', type: 'pauser' }]); |
| 326 | + flow.nodes = flow.nodes.map((n) => (n.id === 'cm' ? { ...n, type: 'failer' } : n)); |
| 327 | + engine.registerFlow('paused_child', flow as never); |
| 328 | + engine.registerFlow('parent_flow', parentFlow({ flowName: 'paused_child', outputVariable: 'subResult' })); |
| 329 | + |
| 330 | + const r = await engine.execute('parent_flow'); |
| 331 | + const parentRunId = r.runId!; |
| 332 | + const child = suspendedByFlow('paused_child')!; |
| 333 | + |
| 334 | + const childRes = await engine.resume(child.runId); |
| 335 | + expect(childRes.success).toBe(false); |
| 336 | + |
| 337 | + // The parent is terminally failed, not left suspended forever. |
| 338 | + expect(engine.listSuspendedRuns()).toHaveLength(0); |
| 339 | + const again = await engine.resume(parentRunId); |
| 340 | + expect(again.success).toBe(false); |
| 341 | + expect(again.error).toMatch(/No suspended run/); |
| 342 | + expect(captured).toEqual([]); // parent downstream never ran |
140 | 343 | }); |
141 | 344 |
|
142 | 345 | it('guards against a recursive subflow cycle (clean error, no stack overflow)', async () => { |
|
0 commit comments