Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signal-less-resume-screen-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-automation': patch
---

`AutomationEngine.resume(runId)` called with no signal object is now held to the suspended `screen` node's declared field contract exactly like a signal-carrying resume: a run paused on a screen with an unconditional `required` field is refused with `INVALID_SCREEN_INPUT` and stays paused, instead of proceeding with that variable unbound. The bare `if (!signal) return null` early return in `refuseInvalidScreenInput` is gone — an absent signal is an empty submission, the same shape the HTTP resume route has always assembled for an empty body — and the engine's own continuations (subflow output mapping, `map` item handoff) remain exempt only through the existing engine-built-signal mechanism. Pauses that declare no input contract are unaffected: `wait` and `approval` nodes, message-only and object-form screens, and screens whose fields are all optional or hidden resume without a signal exactly as before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A signal-less `resume(runId)` is held to the suspended screen's declared
* field contract exactly like a signal-carrying one (#13648).
*
* `refuseInvalidScreenInput` (#4477) used to open with `if (!signal) return
* null;` — so `resume(runId, { variables: {} })` was refused with
* `INVALID_SCREEN_INPUT` while `resume(runId)` completed the run with every
* unconditional `required` field unbound. The engine already had a NAMED
* exemption for the one legitimate case — its own continuations, tagged
* `ENGINE_BUILT_SIGNAL` — and the bare early return was a second, unnamed
* spelling of an exemption nobody had asked for. Ruling (triage, 2026-08-31):
* the governed side wins — the early return is gone, an absent signal is an
* empty submission, and the engine-built flag is the only exemption left.
*
* The HTTP door (`POST …/runs/:runId/resume`) never reached the hole — it
* assembles `{}` for an empty body — so these pins sit on the in-process door
* `AutomationEngine.resume`, which is also what the wait node's timer wake
* calls with no signal (and must keep doing: a `wait` pause declares no
* screen contract, so an empty submission against it is conformant;
* `wait-node.test.ts` owns that half).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from '../engine.js';
import type { NodeExecutor } from '../engine.js';
import { installBuiltinNodes } from './index.js';

function silentLogger() {
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
}
function ctx() {
return { logger: silentLogger(), getService() { return undefined; } } as any;
}

/** A one-screen flow whose screen declares exactly `fields`. */
function screenFlow(name: string, fields: Array<Record<string, unknown>>, screenConfig: Record<string, unknown> = {}) {
return {
name,
label: name,
type: 'screen',
status: 'active',
version: 1,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { ...screenConfig, ...(fields.length ? { fields } : {}) } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask', type: 'default' },
{ id: 'e2', source: 'ask', target: 'end', type: 'default' },
],
};
}

const REQUIRED_KIND = [{ name: 'kind', label: 'Kind', type: 'text', required: true }];

describe('signal-less resume of a screen with a required field (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
engine.registerFlow('triage', screenFlow('triage', REQUIRED_KIND) as any);
});

async function pause(): Promise<string> {
const started = await engine.execute('triage', {} as any);
expect(started.status).toBe('paused');
expect(started.screen?.nodeId).toBe('ask');
return started.runId!;
}

it('refuses `resume(runId)` with INVALID_SCREEN_INPUT and leaves the run paused', async () => {
const runId = await pause();

const res = await engine.resume(runId);

// The ADR-0112 envelope, not a bare "it failed": the same code and the
// same first sentence the signal-carrying refusal answers.
expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(res.error).toMatch(/^Invalid screen input: /);
expect(res.error).toContain('"kind"');
expect(res.error).toMatch(/required/i);
// The pause was NOT consumed — the run is exactly where it was.
expect(await engine.hasSuspendedRun(runId)).toBe(true);
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
});

it('answers the signal-less and the empty-bag resume with the SAME envelope', async () => {
const runId = await pause();
const bare = await engine.resume(runId);
const empty = await engine.resume(runId, { variables: {} });
expect(bare).toEqual(empty);
});

it('resumes the same run once the field is supplied', async () => {
const runId = await pause();
expect((await engine.resume(runId)).code).toBe('INVALID_SCREEN_INPUT');

const good = await engine.resume(runId, { variables: { kind: 'normal' } });

expect(good.success).toBe(true);
expect(good.code).toBeUndefined();
expect(good.status).toBeUndefined(); // ran to completion
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});
});

describe('signal-less resume of a pause that declares no contract proceeds (#13648)', () => {
let engine: AutomationEngine;

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
});

async function pauseOn(flow: Record<string, unknown>): Promise<string> {
engine.registerFlow(flow.name as string, flow as any);
const started = await engine.execute(flow.name as string, {} as any);
expect(started.status).toBe('paused');
return started.runId!;
}

it('a screen whose fields are all optional', async () => {
const runId = await pauseOn(screenFlow('optional_only', [
{ name: 'note', label: 'Note', type: 'text' },
{ name: 'flag', label: 'Flag', type: 'boolean', required: false },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
expect(await engine.hasSuspendedRun(runId)).toBe(false);
});

it('a MESSAGE-ONLY screen — no keys declared, none constrained', async () => {
const runId = await pauseOn(screenFlow('message_only', [], { title: 'Confirm', waitForInput: true }));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('an OBJECT-FORM screen — the record write path enforces its own required fields', async () => {
const runId = await pauseOn(screenFlow('object_form', [], {
objectName: 'crm_account', mode: 'create', idVariable: 'account_id',
}));
expect((await engine.getSuspendedScreen(runId))?.kind).toBe('object-form');
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});

it('a required field the screen HIDES (`visibleWhen` false) — the visibility layer applies to an empty bag too', async () => {
const runId = await pauseOn(screenFlow('hidden_required', [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'false' },
]));
const res = await engine.resume(runId);
expect(res.success).toBe(true);
expect(res.code).toBeUndefined();
});
});

describe("engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)", () => {
let engine: AutomationEngine;
let captured: unknown[];

beforeEach(() => {
engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, ctx());
captured = [];
// Copies the screen-collected `kind` into the child's declared output.
engine.registerNodeExecutor({
type: 'copier',
async execute(_node, variables) {
variables.set('result', variables.get('kind'));
return { success: true };
},
} as NodeExecutor);
// Parent step after the subflow: captures the mapped output variable.
engine.registerNodeExecutor({
type: 'parentcheck',
async execute(_node, variables) {
captured.push(variables.get('subResult'));
return { success: true };
},
} as NodeExecutor);
engine.registerFlow('child', {
name: 'child',
label: 'Child',
type: 'autolaunched',
variables: [{ name: 'result', type: 'text', isOutput: true }],
nodes: [
{ id: 's', type: 'start', label: 'Start' },
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: REQUIRED_KIND } },
{ id: 'copy', type: 'copier', label: 'Copy' },
{ id: 'e', type: 'end', label: 'End' },
],
edges: [
{ id: 'c1', source: 's', target: 'ask' },
{ id: 'c2', source: 'ask', target: 'copy' },
{ id: 'c3', source: 'copy', target: 'e' },
],
} as any);
engine.registerFlow('parent', {
name: 'parent',
label: 'Parent',
type: 'autolaunched',
nodes: [
{ id: 'ps', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call Child', config: { flowName: 'child', outputVariable: 'subResult' } },
{ id: 'chk', type: 'parentcheck', label: 'Check' },
{ id: 'pe', type: 'end', label: 'End' },
],
edges: [
{ id: 'p1', source: 'ps', target: 'call' },
{ id: 'p2', source: 'call', target: 'chk' },
{ id: 'p3', source: 'chk', target: 'pe' },
],
} as any);
});

it("a child's completion bubbles up through an engine-built signal whose bag lacks the parent's surfaced required field", async () => {
const started = await engine.execute('parent', {} as any);
expect(started.status).toBe('paused');
const parentRunId = started.runId!;
// The parent surfaces the CHILD's screen — required `kind` included —
// so the up-bubble below is judged against a screen with a required
// field, and only the engine-built flag lets it through.
expect((await engine.getSuspendedScreen(parentRunId))?.fields?.map((f) => f.name)).toEqual(['kind']);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;
expect(child).toBeDefined();

// Resume the CHILD directly (the approval/wait-style path) with the
// field it asked for; its completion resumes the parent with the
// engine's own output-mapping signal, which never carries `kind`.
const childRes = await engine.resume(child.runId, { variables: { kind: 'escalate' } });

expect(childRes.success).toBe(true);
expect(childRes.status).toBeUndefined();
expect(captured).toEqual([{ result: 'escalate' }]);
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

it("a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run", async () => {
const started = await engine.execute('parent', {} as any);
const child = engine.listSuspendedRuns().find((r) => r.flowName === 'child')!;

const res = await engine.resume(child.runId);

expect(res.success).toBe(false);
expect(res.code).toBe('INVALID_SCREEN_INPUT');
expect(await engine.hasSuspendedRun(child.runId)).toBe(true);
expect(await engine.hasSuspendedRun(started.runId!)).toBe(true);
expect(captured).toEqual([]);
});
});
42 changes: 33 additions & 9 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1004,14 +1004,15 @@ function isEngineVariable(name: string): boolean {
* @returns the rejected key names (already in their final, prefixed form).
* Empty ⇒ every write was applied. An engine-built signal
* ({@link ENGINE_BUILT_SIGNAL}) is exempt: `bubbleToParent` legitimately
* writes the handoff keys, and it is not reachable from a transport.
* writes the handoff keys, and it is not reachable from a transport. The
* signal is never absent here — `resume` normalises a missing one to `{}`
* (#13648), which folds nothing and rejects nothing.
*/
function applyResumeSignal(
variables: Map<string, unknown>,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
nodeId: string,
): string[] {
if (!signal) return [];
const trusted = (signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true;
const rejected: string[] = [];
const writes: Array<[string, unknown]> = [];
Expand DownExpand Up@@ -4207,7 +4208,21 @@ export class AutomationEngine implements IAutomationService {
async resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult> {
const refusal = await this.refuseGatedResume(runId, signal);
if (refusal) return refusal;
return this.resumeInternal(runId, signal, false);
// An ABSENT signal is an EMPTY caller submission, never an exemption
// (#13648). This is the in-process door, and `resume(runId)` used to
// skip the screen contract that `resume(runId, {})` is held to:
// `refuseInvalidScreenInput` short-circuited on a falsy signal — a
// second, unnamed spelling of the exemption the engine already states
// through `ENGINE_BUILT_SIGNAL`, so a run parked on a screen with an
// unconditional `required` field proceeded with that variable unbound.
// The HTTP door has always assembled `{}` for an empty body; this makes
// the two doors agree, and the only exemption left is the engine's own
// continuation, which proves itself by BUILDING an engine-built signal.
// A pause with no screen contract — `wait`, `approval`, a message-only
// or object-form screen — is untouched: an empty submission against no
// declared fields is conformant, so the wait node's timer wake
// (`engine.resume(runId)`) continues exactly as before.
return this.resumeInternal(runId, signal ?? {}, false);
}

/**
Expand DownExpand Up@@ -4668,7 +4683,12 @@ export class AutomationEngine implements IAutomationService {
*/
private async resumeInternal(
runId: string,
signal: ResumeSignal | undefined,
// Never `undefined` past the public door: `resume` normalises an
// absent caller signal to `{}` (#13648), and the engine's own
// continuations (subflow delegation / up-bubble, `map` re-entry)
// always hand over a built signal. Typed so, the chokepoints below
// cannot grow a falsy-signal branch again.
signal: ResumeSignal,
skipBubble: boolean,
childSummary?: FlowRunSummary,
): Promise<AutomationResult> {
Expand DownExpand Up@@ -4893,7 +4913,7 @@ export class AutomationEngine implements IAutomationService {
if (typeof run.correlation === 'string' && run.correlation.startsWith('map:')) {
await this.executeNode(node, flow, variables, context, steps);
} else {
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
await this.traverseNext(node, flow, variables, context, steps, signal.branchLabel);
}

// Collect output variables
Expand DownExpand Up@@ -5047,7 +5067,12 @@ export class AutomationEngine implements IAutomationService {
* pass-through `enforceActionParams` gives a param-less action).
* - **Never an engine-built signal.** The subflow output mapping and the
* `map` item handoff are the engine's own continuations; they carry
* author-named output variables, not a screen submission.
* author-named output variables, not a screen submission. This is the
* ONLY exemption, and it is spelled once: an absent signal is not a
* case here — `resume` normalises it to `{}` (#13648) — because a bare
* `if (!signal)` beside the flag was a second, unnamed spelling of the
* same exemption that let `resume(runId)` skip every `required` the
* author wrote.
*
* `visibleWhen` is evaluated against the SUBMITTED values first (layered
* over the run's variables, so a predicate may reference a prior node),
Expand All@@ -5059,9 +5084,8 @@ export class AutomationEngine implements IAutomationService {
private refuseInvalidScreenInput(
run: SuspendedRun,
runId: string,
signal: ResumeSignal | undefined,
signal: ResumeSignal,
): AutomationResult | null {
if (!signal) return null;
if ((signal as Record<symbol, unknown>)[ENGINE_BUILT_SIGNAL] === true) return null;
if (!screenDeclaresInputContract(run.screen)) return null;
const fields = run.screen!.fields;
Expand Down
Loading