|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#28846] The subflow guards see a `subflow` / `map` node inside a region |
| 4 | +// body (a `loop` body, a `parallel` branch, a `try_catch` slot). |
| 5 | +// |
| 6 | +// ADR-0126 §7.3 keeps a packaged caller from being armed onto a disabled |
| 7 | +// packaged child, and keeps that child from being switched off or removed |
| 8 | +// while an armed packaged caller still calls it. Each guard reads the caller's |
| 9 | +// calls through `subflowTargets`, which read only the top-level `nodes`: a |
| 10 | +// call inside a region body was invisible, so the caller armed onto a disabled |
| 11 | +// child and failed mid-run at that node, the late, inexplicable failure §7.3 |
| 12 | +// refuses to cause. `subflowTargets` now reads every region depth through |
| 13 | +// `collectFlowGraphs`, the spec's one region walk, so every guard sees the |
| 14 | +// same calls. |
| 15 | +// |
| 16 | +// The save-notice half of the arming gate (#28840) is deliberately NOT moved |
| 17 | +// by this: a region body cannot carry a durable pause, so a region-nested call |
| 18 | +// whose child suspends fails the run at the region boundary before anything |
| 19 | +// after the container runs. That walk keeps reading top-level calls only, and |
| 20 | +// two pins below hold it there. |
| 21 | + |
| 22 | +import { describe, it, expect, vi } from 'vitest'; |
| 23 | +import { AutomationEngine } from './engine.js'; |
| 24 | +import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; |
| 25 | +import { InMemoryFlowActivationStore } from './flow-activation-store.js'; |
| 26 | +import { registerSubflowNode } from './builtin/subflow-node.js'; |
| 27 | +import { registerMapNode } from './builtin/map-node.js'; |
| 28 | +import type { AutomationContext } from '@objectstack/spec/contracts'; |
| 29 | +import { withLoaderSetFromPull } from './loader-set.test-support.js'; |
| 30 | + |
| 31 | +function createTestLogger(): any { |
| 32 | + const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; |
| 33 | + l.child = () => l; |
| 34 | + return l; |
| 35 | +} |
| 36 | + |
| 37 | +const declineWarnings = (logger: any, caller: string): string[] => |
| 38 | + logger.warn.mock.calls |
| 39 | + .map((c: unknown[]) => String(c[0])) |
| 40 | + .filter((m: string) => m.includes(`Flow '${caller}'`) && m.includes('NOT armed')); |
| 41 | + |
| 42 | +function recordingTrigger() { |
| 43 | + const bound = new Set<string>(); |
| 44 | + const trigger: FlowTrigger = { |
| 45 | + type: 'record_change', |
| 46 | + start(binding: FlowTriggerBinding, _cb: (ctx: AutomationContext) => Promise<void>) { |
| 47 | + bound.add(binding.flowName); |
| 48 | + }, |
| 49 | + stop(flowName: string) { |
| 50 | + bound.delete(flowName); |
| 51 | + }, |
| 52 | + }; |
| 53 | + return { trigger, isBound: (n: string) => bound.has(n) }; |
| 54 | +} |
| 55 | + |
| 56 | +const nodeCtx = { logger: createTestLogger(), getService: () => undefined } as any; |
| 57 | + |
| 58 | +function engineWithLedger() { |
| 59 | + const logger = createTestLogger(); |
| 60 | + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); |
| 61 | + engine.setFlowActivationStore(new InMemoryFlowActivationStore()); |
| 62 | + registerSubflowNode(engine, nodeCtx); |
| 63 | + registerMapNode(engine, nodeCtx); |
| 64 | + const trigger = recordingTrigger(); |
| 65 | + engine.registerTrigger(trigger.trigger); |
| 66 | + return { engine, logger, isBound: trigger.isBound }; |
| 67 | +} |
| 68 | + |
| 69 | +const stateOf = (engine: AutomationEngine, name: string) => |
| 70 | + engine.getFlowRuntimeStates().find((s) => s.name === name); |
| 71 | + |
| 72 | +type Node = Record<string, unknown>; |
| 73 | +type Region = 'loop' | 'parallel' | 'try_catch'; |
| 74 | +type CallType = 'subflow' | 'map'; |
| 75 | + |
| 76 | +const END: Node = { id: 'end', type: 'end', label: 'End' }; |
| 77 | +const start = (trigger: boolean): Node => ({ |
| 78 | + id: 'start', type: 'start', label: 'Start', |
| 79 | + config: trigger ? { objectName: 'lead', triggerType: 'record-after-create' } : {}, |
| 80 | +}); |
| 81 | +const call = (flowName: string, type: CallType = 'subflow'): Node => ({ |
| 82 | + id: 'step', type, label: `Call ${flowName}`, |
| 83 | + config: { flowName, ...(type === 'map' ? { collection: [1] } : {}) }, |
| 84 | +}); |
| 85 | +const NOOP: Node = { id: 'noop', type: 'assignment', label: 'Nothing', config: {} }; |
| 86 | + |
| 87 | +/** `inner` as the one node of a region body of `kind`, in a container node `id`. */ |
| 88 | +function region(kind: Region, inner: Node, id = 'box'): Node { |
| 89 | + const body = { nodes: [inner], edges: [] }; |
| 90 | + if (kind === 'loop') { |
| 91 | + return { id, type: 'loop', label: 'Box', config: { collection: [1], iteratorVariable: 'item', body } }; |
| 92 | + } |
| 93 | + if (kind === 'parallel') { |
| 94 | + return { |
| 95 | + id, type: 'parallel', label: 'Box', |
| 96 | + config: { branches: [{ name: 'a', ...body }, { name: 'b', nodes: [{ ...NOOP }], edges: [] }] }, |
| 97 | + }; |
| 98 | + } |
| 99 | + return { id, type: 'try_catch', label: 'Box', config: { try: body, catch: { nodes: [{ ...NOOP }], edges: [] } } }; |
| 100 | +} |
| 101 | + |
| 102 | +function chain(name: string, nodes: Node[], extra: Record<string, unknown> = {}) { |
| 103 | + return { |
| 104 | + name, |
| 105 | + label: name, |
| 106 | + type: 'autolaunched', |
| 107 | + nodes, |
| 108 | + edges: nodes.slice(1).map((n, i) => ({ id: `e${i}`, source: nodes[i].id as string, target: n.id as string })), |
| 109 | + ...extra, |
| 110 | + }; |
| 111 | +} |
| 112 | + |
| 113 | +/** Shipped by a code package (ADR-0029 D9.6 provenance). */ |
| 114 | +const PACKAGED = { _packageId: 'crm' }; |
| 115 | + |
| 116 | +/** A packaged child flow `start → end`, active unless `status` says otherwise. */ |
| 117 | +const child = (name: string, status?: string) => |
| 118 | + chain(name, [start(false), END], { ...PACKAGED, ...(status !== undefined ? { status } : {}) }); |
| 119 | + |
| 120 | +/** A packaged record-triggered caller whose only call to `target` sits inside a region body. */ |
| 121 | +const regionCaller = (name: string, target: string, kind: Region, type: CallType = 'subflow') => |
| 122 | + chain(name, [start(true), region(kind, call(target, type)), END], PACKAGED); |
| 123 | + |
| 124 | +const CASES: Array<[Region, CallType]> = [ |
| 125 | + ['loop', 'subflow'], ['loop', 'map'], |
| 126 | + ['parallel', 'subflow'], ['parallel', 'map'], |
| 127 | + ['try_catch', 'subflow'], ['try_catch', 'map'], |
| 128 | +]; |
| 129 | + |
| 130 | +describe('the subflow guards read a call inside a region body (#28846)', () => { |
| 131 | + it.each(CASES)('arming: a caller whose %s body holds a `%s` call to a disabled packaged child is declined, naming the child', (kind, type) => { |
| 132 | + const { engine, logger, isBound } = engineWithLedger(); |
| 133 | + engine.registerFlow('shared_step', child('shared_step', 'obsolete')); |
| 134 | + engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', kind, type)); |
| 135 | + |
| 136 | + expect(isBound('vendor_process')).toBe(false); |
| 137 | + const row = stateOf(engine, 'vendor_process'); |
| 138 | + expect(row).toMatchObject({ enabled: true, bound: false, unboundCause: 'disabled_subflow' }); |
| 139 | + expect(row?.reason).toContain("'shared_step' (its definition's status is 'obsolete')"); |
| 140 | + expect(declineWarnings(logger, 'vendor_process')).toHaveLength(1); |
| 141 | + }); |
| 142 | + |
| 143 | + it('arming: a child republished `obsolete` under an armed caller that calls it from a loop body disarms the caller; republished `active` arms it again', () => { |
| 144 | + const { engine, isBound } = engineWithLedger(); |
| 145 | + engine.registerFlow('shared_step', child('shared_step')); |
| 146 | + engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'loop')); |
| 147 | + expect(isBound('vendor_process')).toBe(true); |
| 148 | + |
| 149 | + engine.registerFlow('shared_step', child('shared_step', 'obsolete')); |
| 150 | + expect(isBound('vendor_process')).toBe(false); |
| 151 | + expect(stateOf(engine, 'vendor_process')?.unboundCause).toBe('disabled_subflow'); |
| 152 | + |
| 153 | + engine.registerFlow('shared_step', child('shared_step')); |
| 154 | + expect(isBound('vendor_process')).toBe(true); |
| 155 | + expect(stateOf(engine, 'vendor_process')?.unboundCause).toBeUndefined(); |
| 156 | + }); |
| 157 | + |
| 158 | + it('arming: a call two regions deep (a `parallel` branch inside a `loop` body) is read too', () => { |
| 159 | + const { engine, isBound } = engineWithLedger(); |
| 160 | + engine.registerFlow('shared_step', child('shared_step', 'obsolete')); |
| 161 | + engine.registerFlow( |
| 162 | + 'vendor_process', |
| 163 | + chain('vendor_process', [start(true), region('loop', region('parallel', call('shared_step'), 'fan')), END], PACKAGED), |
| 164 | + ); |
| 165 | + expect(isBound('vendor_process')).toBe(false); |
| 166 | + expect(stateOf(engine, 'vendor_process')?.unboundCause).toBe('disabled_subflow'); |
| 167 | + }); |
| 168 | + |
| 169 | + it('removal: a child an armed packaged caller calls from a try_catch slot is refused with DELETE_RESTRICTED / 409, naming the caller', () => { |
| 170 | + const { engine } = engineWithLedger(); |
| 171 | + engine.registerFlow('shared_step', child('shared_step')); |
| 172 | + engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'try_catch')); |
| 173 | + |
| 174 | + let thrown: any; |
| 175 | + try { |
| 176 | + engine.unregisterFlow('shared_step'); |
| 177 | + } catch (e) { |
| 178 | + thrown = e; |
| 179 | + } |
| 180 | + expect(thrown, 'removing the child was accepted').toBeDefined(); |
| 181 | + expect(thrown.code).toBe('DELETE_RESTRICTED'); |
| 182 | + expect(thrown.status).toBe(409); |
| 183 | + expect(thrown.subflowCallers).toEqual(['vendor_process']); |
| 184 | + expect(thrown.message).toContain("Flow 'shared_step' cannot be removed"); |
| 185 | + expect(engine.getFlow('shared_step'), 'nothing was removed').toBeDefined(); |
| 186 | + }); |
| 187 | + |
| 188 | + it('switch-off: a child an enabled packaged caller calls from a parallel branch is refused with DELETE_RESTRICTED / 409, naming the caller', async () => { |
| 189 | + const { engine } = engineWithLedger(); |
| 190 | + engine.registerFlow('shared_step', child('shared_step')); |
| 191 | + engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'parallel', 'map')); |
| 192 | + |
| 193 | + const thrown: any = await engine.toggleFlow('shared_step', false).then(() => undefined, (e: unknown) => e); |
| 194 | + expect(thrown, 'switching the child off was accepted').toBeDefined(); |
| 195 | + expect(thrown.code).toBe('DELETE_RESTRICTED'); |
| 196 | + expect(thrown.status).toBe(409); |
| 197 | + expect(thrown.subflowCallers).toEqual(['vendor_process']); |
| 198 | + expect(thrown.message).toContain("Flow 'shared_step' cannot be disabled"); |
| 199 | + }); |
| 200 | + |
| 201 | + it('CONTROL: a caller whose loop body calls an ACTIVE packaged child is armed, with no warning', () => { |
| 202 | + const { engine, logger, isBound } = engineWithLedger(); |
| 203 | + engine.registerFlow('shared_step', child('shared_step')); |
| 204 | + engine.registerFlow('vendor_process', regionCaller('vendor_process', 'shared_step', 'loop')); |
| 205 | + expect(isBound('vendor_process')).toBe(true); |
| 206 | + expect(stateOf(engine, 'vendor_process')?.unboundCause).toBeUndefined(); |
| 207 | + expect(declineWarnings(logger, 'vendor_process')).toEqual([]); |
| 208 | + }); |
| 209 | + |
| 210 | + it('CONTROL: a top-level call to a disabled packaged child is declined as before', () => { |
| 211 | + const { engine, isBound } = engineWithLedger(); |
| 212 | + engine.registerFlow('shared_step', child('shared_step', 'obsolete')); |
| 213 | + engine.registerFlow('vendor_process', chain('vendor_process', [start(true), call('shared_step'), END], PACKAGED)); |
| 214 | + expect(isBound('vendor_process')).toBe(false); |
| 215 | + expect(stateOf(engine, 'vendor_process')?.unboundCause).toBe('disabled_subflow'); |
| 216 | + }); |
| 217 | +}); |
| 218 | + |
| 219 | +describe('the save-notice half of the arming gate keeps reading top-level calls (#28840, #28846)', () => { |
| 220 | + const WAIT: Node = { id: 'hold', type: 'wait', label: 'Hold', waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } }; |
| 221 | + const NOTICE: Node = { id: 'tell', type: 'save_notice', label: 'Tell the saver', config: { message: 'Saved' } }; |
| 222 | + |
| 223 | + it('a save_notice after a region whose body calls a suspending child is not declined on that call: the region cannot carry the pause, so the run fails at the region, before the notice', () => { |
| 224 | + const { engine, isBound } = engineWithLedger(); |
| 225 | + engine.registerFlow('deal_review', chain('deal_review', [start(false), WAIT, END])); |
| 226 | + engine.registerFlow('deal_submit', chain('deal_submit', [start(true), region('loop', call('deal_review')), NOTICE, END])); |
| 227 | + expect(isBound('deal_submit')).toBe(true); |
| 228 | + expect(stateOf(engine, 'deal_submit')?.unboundCause).toBeUndefined(); |
| 229 | + }); |
| 230 | + |
| 231 | + it('a child whose only call to a suspending flow sits in a region body does not count as suspending', () => { |
| 232 | + const { engine, isBound } = engineWithLedger(); |
| 233 | + engine.registerFlow('deal_ask', chain('deal_ask', [start(false), WAIT, END])); |
| 234 | + engine.registerFlow('deal_review', chain('deal_review', [start(false), region('loop', call('deal_ask')), END])); |
| 235 | + engine.registerFlow('deal_submit', chain('deal_submit', [start(true), call('deal_review'), NOTICE, END])); |
| 236 | + expect(isBound('deal_submit')).toBe(true); |
| 237 | + expect(stateOf(engine, 'deal_submit')?.unboundCause).toBeUndefined(); |
| 238 | + }); |
| 239 | +}); |
0 commit comments