|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A signal-less `resume(runId)` is held to the suspended screen's declared |
| 5 | + * field contract exactly like a signal-carrying one (#13648). |
| 6 | + * |
| 7 | + * `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return |
| 8 | + * null;` — so `resume(runId, { variables: {} })` was refused with |
| 9 | + * `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every |
| 10 | + * unconditional `required` field unbound. The engine already had a NAMED |
| 11 | + * exemption for the one legitimate case — its own continuations, tagged |
| 12 | + * `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed |
| 13 | + * spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31): |
| 14 | + * the governed side wins — the early return is gone, an absent signal is an |
| 15 | + * empty submission, and the engine-built flag is the only exemption left. |
| 16 | + * |
| 17 | + * The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it |
| 18 | + * assembles `{}` for an empty body — so these pins sit on the in-process door |
| 19 | + * `AutomationEngine.resume`, which is also what the wait node's timer wake |
| 20 | + * calls with no signal (and must keep doing: a `wait` pause declares no |
| 21 | + * screen contract, so an empty submission against it is conformant; |
| 22 | + * `wait-node.test.ts` owns that half). |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 26 | +import { AutomationEngine } from '../engine.js'; |
| 27 | +import type { NodeExecutor } from '../engine.js'; |
| 28 | +import { installBuiltinNodes } from './index.js'; |
| 29 | + |
| 30 | +function silentLogger() { |
| 31 | + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; |
| 32 | +} |
| 33 | +function ctx() { |
| 34 | + return { logger: silentLogger(), getService() { return undefined; } } as any; |
| 35 | +} |
| 36 | + |
| 37 | +/** A one-screen flow whose screen declares exactly `fields`. */ |
| 38 | +function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) { |
| 39 | + return { |
| 40 | + name, |
| 41 | + label: name, |
| 42 | + type: 'screen', |
| 43 | + status: 'active', |
| 44 | + version: 1, |
| 45 | + nodes: [ |
| 46 | + { id: 'start', type: 'start', label: 'Start' }, |
| 47 | + { id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } }, |
| 48 | + { id: 'end', type: 'end', label: 'End' }, |
| 49 | + ], |
| 50 | + edges: [ |
| 51 | + { id: 'e1', source: 'start', target: 'ask', type: 'default' }, |
| 52 | + { id: 'e2', source: 'ask', target: 'end', type: 'default' }, |
| 53 | + ], |
| 54 | + }; |
| 55 | +} |
| 56 | + |
| 57 | +const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }]; |
| 58 | + |
| 59 | +describe('signal-less resume of a screen with a required field (#13648)', () => { |
| 60 | + let engine: AutomationEngine; |
| 61 | + |
| 62 | + beforeEach(() => { |
| 63 | + engine = new AutomationEngine(silentLogger()); |
| 64 | + installBuiltinNodes(engine, ctx()); |
| 65 | + engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any); |
| 66 | + }); |
| 67 | + |
| 68 | + async function pause(): Promise<string> { |
| 69 | + const started = await engine.execute('triage', {} as any); |
| 70 | + expect(started.status).toBe('paused'); |
| 71 | + expect(started.screen?.nodeId).toBe('ask'); |
| 72 | + return started.runId!; |
| 73 | + } |
| 74 | + |
| 75 | + it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => { |
| 76 | + const runId = await pause(); |
| 77 | + |
| 78 | + const res = await engine.resume(runId); |
| 79 | + |
| 80 | + // The ADR-0112 envelope, not a bare "it failed": the same code and the |
| 81 | + // same first sentence the signal-carrying refusal answers. |
| 82 | + expect(res.success).toBe(false); |
| 83 | + expect(res.code).toBe('INVALID_SCREEN_INPUT'); |
| 84 | + expect(res.error).toMatch(/^Invalid screen input: /); |
| 85 | + expect(res.error).toContain('"kind"'); |
| 86 | + expect(res.error).toMatch(/required/i); |
| 87 | + // The pause was NOT consumed — the run is exactly where it was. |
| 88 | + expect(await engine.hasSuspendedRun(runId)).toBe(true); |
| 89 | + expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask'); |
| 90 | + }); |
| 91 | + |
| 92 | + it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => { |
| 93 | + const runId = await pause(); |
| 94 | + const bare = await engine.resume(runId); |
| 95 | + const empty = await engine.resume(runId, { variables: {} }); |
| 96 | + expect(bare).toEqual(empty); |
| 97 | + }); |
| 98 | + |
| 99 | + it('resumes the same run once the field is supplied', async () => { |
| 100 | + const runId = await pause(); |
| 101 | + expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT'); |
| 102 | + |
| 103 | + const good = await engine.resume(runId, { variables: { kind: 'normal' } }); |
| 104 | + |
| 105 | + expect(good.success).toBe(true); |
| 106 | + expect(good.code).toBeUndefined(); |
| 107 | + expect(good.status).toBeUndefined(); // ran to completion |
| 108 | + expect(await engine.hasSuspendedRun(runId)).toBe(false); |
| 109 | + }); |
| 110 | +}); |
| 111 | + |
| 112 | +describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => { |
| 113 | + let engine: AutomationEngine; |
| 114 | + |
| 115 | + beforeEach(() => { |
| 116 | + engine = new AutomationEngine(silentLogger()); |
| 117 | + installBuiltinNodes(engine, ctx()); |
| 118 | + }); |
| 119 | + |
| 120 | + async function pauseOn(flow: Record<string, unknown>): Promise<string> { |
| 121 | + engine.registerFlow(flow.name as string, flow as any); |
| 122 | + const started = await engine.execute(flow.name as string, {} as any); |
| 123 | + expect(started.status).toBe('paused'); |
| 124 | + return started.runId!; |
| 125 | + } |
| 126 | + |
| 127 | + it('a screen whose fields are all optional', async () => { |
| 128 | + const runId = await pauseOn(screenFlow('optional_only', [ |
| 129 | + { name: 'note', label: 'Note', type: 'text' }, |
| 130 | + { name: 'flag', label: 'Flag', type: 'boolean', required: false }, |
| 131 | + ])); |
| 132 | + const res = await engine.resume(runId); |
| 133 | + expect(res.success).toBe(true); |
| 134 | + expect(res.code).toBeUndefined(); |
| 135 | + expect(await engine.hasSuspendedRun(runId)).toBe(false); |
| 136 | + }); |
| 137 | + |
| 138 | + it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => { |
| 139 | + const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true })); |
| 140 | + const res = await engine.resume(runId); |
| 141 | + expect(res.success).toBe(true); |
| 142 | + expect(res.code).toBeUndefined(); |
| 143 | + }); |
| 144 | + |
| 145 | + it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => { |
| 146 | + const runId = await pauseOn(screenFlow('object_form', [], { |
| 147 | + objectName: 'crm_account', mode: 'create', idVariable: 'account_id', |
| 148 | + })); |
| 149 | + expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form'); |
| 150 | + const res = await engine.resume(runId); |
| 151 | + expect(res.success).toBe(true); |
| 152 | + expect(res.code).toBeUndefined(); |
| 153 | + }); |
| 154 | + |
| 155 | + it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => { |
| 156 | + const runId = await pauseOn(screenFlow('hidden_required', [ |
| 157 | + { name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' }, |
| 158 | + ])); |
| 159 | + const res = await engine.resume(runId); |
| 160 | + expect(res.success).toBe(true); |
| 161 | + expect(res.code).toBeUndefined(); |
| 162 | + }); |
| 163 | +}); |
| 164 | + |
| 165 | +describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => { |
| 166 | + let engine: AutomationEngine; |
| 167 | + let captured: unknown[]; |
| 168 | + |
| 169 | + beforeEach(() => { |
| 170 | + engine = new AutomationEngine(silentLogger()); |
| 171 | + installBuiltinNodes(engine, ctx()); |
| 172 | + captured = []; |
| 173 | + // Copies the screen-collected `kind` into the child's declared output. |
| 174 | + engine.registerNodeExecutor({ |
| 175 | + type: 'copier', |
| 176 | + async execute(_node, variables) { |
| 177 | + variables.set('result', variables.get('kind')); |
| 178 | + return { success: true }; |
| 179 | + }, |
| 180 | + } as NodeExecutor); |
| 181 | + // Parent step after the subflow: captures the mapped output variable. |
| 182 | + engine.registerNodeExecutor({ |
| 183 | + type: 'parentcheck', |
| 184 | + async execute(_node, variables) { |
| 185 | + captured.push(variables.get('subResult')); |
| 186 | + return { success: true }; |
| 187 | + }, |
| 188 | + } as NodeExecutor); |
| 189 | + engine.registerFlow('child', { |
| 190 | + name: 'child', |
| 191 | + label: 'Child', |
| 192 | + type: 'autolaunched', |
| 193 | + variables: [{ name: 'result', type: 'text', isOutput: true }], |
| 194 | + nodes: [ |
| 195 | + { id: 's', type: 'start', label: 'Start' }, |
| 196 | + { id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } }, |
| 197 | + { id: 'copy', type: 'copier', label: 'Copy' }, |
| 198 | + { id: 'e', type: 'end', label: 'End' }, |
| 199 | + ], |
| 200 | + edges: [ |
| 201 | + { id: 'c1', source: 's', target: 'ask' }, |
| 202 | + { id: 'c2', source: 'ask', target: 'copy' }, |
| 203 | + { id: 'c3', source: 'copy', target: 'e' }, |
| 204 | + ], |
| 205 | + } as any); |
| 206 | + engine.registerFlow('parent', { |
| 207 | + name: 'parent', |
| 208 | + label: 'Parent', |
| 209 | + type: 'autolaunched', |
| 210 | + nodes: [ |
| 211 | + { id: 'ps', type: 'start', label: 'Start' }, |
| 212 | + { id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } }, |
| 213 | + { id: 'chk', type: 'parentcheck', label: 'Check' }, |
| 214 | + { id: 'pe', type: 'end', label: 'End' }, |
| 215 | + ], |
| 216 | + edges: [ |
| 217 | + { id: 'p1', source: 'ps', target: 'call' }, |
| 218 | + { id: 'p2', source: 'call', target: 'chk' }, |
| 219 | + { id: 'p3', source: 'chk', target: 'pe' }, |
| 220 | + ], |
| 221 | + } as any); |
| 222 | + }); |
| 223 | + |
| 224 | + it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => { |
| 225 | + const started = await engine.execute('parent', {} as any); |
| 226 | + expect(started.status).toBe('paused'); |
| 227 | + const parentRunId = started.runId!; |
| 228 | + // The parent surfaces the CHILD's screen — required `kind` included — |
| 229 | + // so the up-bubble below is judged against a screen with a required |
| 230 | + // field, and only the engine-built flag lets it through. |
| 231 | + expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']); |
| 232 | + const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!; |
| 233 | + expect(child).toBeDefined(); |
| 234 | + |
| 235 | + // Resume the CHILD directly (the approval/wait-style path) with the |
| 236 | + // field it asked for; its completion resumes the parent with the |
| 237 | + // engine's own output-mapping signal, which never carries `kind`. |
| 238 | + const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } }); |
| 239 | + |
| 240 | + expect(childRes.success).toBe(true); |
| 241 | + expect(childRes.status).toBeUndefined(); |
| 242 | + expect(captured).toEqual([{ result: 'escalate' }]); |
| 243 | + expect(engine.listSuspendedRuns()).toHaveLength(0); |
| 244 | + }); |
| 245 | + |
| 246 | + it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => { |
| 247 | + const started = await engine.execute('parent', {} as any); |
| 248 | + const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!; |
| 249 | + |
| 250 | + const res = await engine.resume(child.runId); |
| 251 | + |
| 252 | + expect(res.success).toBe(false); |
| 253 | + expect(res.code).toBe('INVALID_SCREEN_INPUT'); |
| 254 | + expect(await engine.hasSuspendedRun(child.runId)).toBe(true); |
| 255 | + expect(await engine.hasSuspendedRun(started.runId!)).toBe(true); |
| 256 | + expect(captured).toEqual([]); |
| 257 | + }); |
| 258 | +}); |
0 commit comments