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
10 changes: 10 additions & 0 deletions .changeset/27601-container-carries-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/service-automation': patch
---

fix(service-automation): a refused write inside a `map` item or a `subflow` reaches the flow's caller as the structured refusal

Clause-②: no
Metadata-protocol: none

A flow whose data node met a refusal (a permission, a validation rule, a duplicate, an app hook's declared refusal) already reported it on the run's result as `innerRefusal` (`{ code, message }`) and the producer-marked `userMessage`, and every flow door served them on the `FLOW_FAILED` details. When that write sat inside a `map` item or a `subflow`, the container built a new failure of its own and the two members were lost, so the caller got only the whole node chain as text (`Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow '…') failed: …`). Both containers now carry the child run's members onto the failure they return, so the refusal reaches the top run however deep it was met, nested `map` → `subflow` included. A `map` stops at its first failing item and carries that item's refusal. A child that failed with no refusal carries none, as before, and the `error` text is unchanged.
5 changes: 5 additions & 0 deletions packages/services/service-automation/src/builtin/map-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { MapConfigParsed } from '@objectstack/spec/automation';
import type { AutomationContext } from '@objectstack/spec/contracts';
import type { AutomationEngine } from '../engine.js';
import { refuseNode } from '../guard-refusal.js';
import { carriedRefusalOfResult } from '../inner-refusal.js';
import { parseNodeConfig } from './parse-config.js';
import { collectionNotAList, resolveCollectionSlot, resolveValueSlotMap } from './value-slot-map.js';

Expand Down Expand Up @@ -220,6 +221,10 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
return {
success: false,
error: `map '${node.id}': item ${idx} (subflow '${flowName}') failed: ${child.error ?? 'unknown error'}`,
// The failing item's refusal, carried beside the re-worded `error`
// (`inner-refusal.ts`); `{}` for a child that met none. The batch
// stops here, so it is this — the first failing — item's.
...carriedRefusalOfResult(child),
// Items that already succeeded wrote real rows; a later item's
// failure must not erase them from the run's totals — and the
// failing item's own writes count too, for the same reason.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts';
import type { AutomationEngine } from '../engine.js';
import { resolveValueSlotMap } from './value-slot-map.js';
import { refuseNode } from '../guard-refusal.js';
import { carriedRefusalOfResult } from '../inner-refusal.js';
import { parseNodeConfig } from './parse-config.js';

/** Hard cap on subflow nesting — turns an accidental cycle into a clean error. */
Expand Down Expand Up @@ -169,6 +170,9 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext
return {
success: false,
error: `subflow '${flowName}' failed: ${child.error ?? 'unknown error'}`,
// The child's refusal, carried beside the re-worded `error`
// (`inner-refusal.ts`); `{}` for a child that met none.
...carriedRefusalOfResult(child),
...(rolled ? { metrics: rolled } : {}),
};
}
Expand Down
141 changes: 140 additions & 1 deletion packages/services/service-automation/src/flow-inner-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
* `resumeInternal` stranded exit (a write behind a screen — the card's
* measured case) and the retry exits; a `loop` frame the failure unwinds
* through does not lose them; a `fault` edge that handles the failure
* leaves the run with none;
* leaves the run with none; a `map` item's or a `subflow` child's
* refusal crosses that container (nested too), and a child that met no
* refusal carries none;
* 4. ⛔ `error` is byte-identical to the composition it always had — the
* diagnostic channel does not move.
*
Expand All @@ -39,6 +41,8 @@ import { AutomationEngine } from './engine.js';
import type { NodeExecutor } from './engine.js';
import { registerCrudNodes } from './builtin/crud-nodes.js';
import { registerLoopNode } from './builtin/loop-node.js';
import { registerMapNode } from './builtin/map-node.js';
import { registerSubflowNode } from './builtin/subflow-node.js';
import { DuplicateRecordError, ValidationError } from '@objectstack/objectql';
import { PermissionDeniedError } from '@objectstack/plugin-security';
import { defineActionDescriptor } from '@objectstack/spec/automation';
Expand Down Expand Up @@ -359,3 +363,138 @@ describe('#24914 — every terminal exit the ruling names carries the members',
expect('userMessage' in result).toBe(false);
});
});

describe('a `map` item and a `subflow` child carry the refusal across the container', () => {
/** start → mk (create_record) → end — the per-item / called flow that meets the refusal. */
const WRITER = 'write_one';

/** start → seed (rows) → each (map over `flowName`) → end. */
function mapFlow(name: string, flowName: string): any {
return {
name, label: name, type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'query', type: 'seed', label: 'Seed' },
{
id: 'each', type: 'map', label: 'Each',
config: { collection: { dialect: 'cel', source: 'rows' }, flowName, iteratorVariable: 'row' },
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'query' },
{ id: 'e2', source: 'query', target: 'each' },
{ id: 'e3', source: 'each', target: 'end' },
],
};
}

/** start → call (subflow `flowName`) → end. */
function subflowFlow(name: string, flowName: string): any {
return {
name, label: name, type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: 'Call', config: { flowName } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'end' },
],
};
}

function containerEngine(data: any): AutomationEngine {
const engine = new AutomationEngine(makeLogger());
const ctx = ctxWith(data);
registerCrudNodes(engine, ctx);
registerMapNode(engine, ctx);
registerSubflowNode(engine, ctx);
engine.registerNodeExecutor({
type: 'seed',
async execute(_node, variables) {
variables.set('rows', [{ n: 1 }, { n: 2 }]);
return { success: true };
},
} as NodeExecutor);
engine.registerFlow(WRITER, createFlow(WRITER));
return engine;
}

const childText = (err: Error) => `Node 'mk' failed: create_record(crm_account) failed: ${err.message}`;

it('map: the failing item\'s refusal reaches the run — code and userMessage — and the text keeps its chain', async () => {
const err = markedHook();
const engine = containerEngine(refusingData(err));
engine.registerFlow('recalc', mapFlow('recalc', WRITER));

const result = await engine.execute('recalc', { userId: 'u1' });

expect(result.status).toBe('failed');
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
expect(result.userMessage).toBe(HOOK_USER_TEXT);
expect(result.error).toBe(`Node 'each' failed: map 'each': item 0 (subflow '${WRITER}') failed: ${childText(err)}`);
});

it('map: the batch stops at the first failing item, so the run carries THAT item\'s refusal', async () => {
const first = validation();
const second = permission();
let calls = 0;
const data = refusingData(undefined);
data.insert = async () => { calls += 1; throw calls === 1 ? first : second; };
const engine = containerEngine(data);
engine.registerFlow('recalc', mapFlow('recalc', WRITER));

const result = await engine.execute('recalc', { userId: 'u1' });

expect(calls).toBe(1);
expect(result.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: 'Amount must be positive' });
});

it('subflow: the child\'s refusal reaches the parent run — code and userMessage', async () => {
const err = markedHook();
const engine = containerEngine(refusingData(err));
engine.registerFlow('outer', subflowFlow('outer', WRITER));

const result = await engine.execute('outer', { userId: 'u1' });

expect(result.status).toBe('failed');
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
expect(result.userMessage).toBe(HOOK_USER_TEXT);
expect(result.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(err)}`);
});

it('map → subflow nested: the refusal two containers deep reaches the top run', async () => {
const err = permission();
const engine = containerEngine(refusingData(err));
engine.registerFlow('item_flow', subflowFlow('item_flow', WRITER));
engine.registerFlow('recalc', mapFlow('recalc', 'item_flow'));

const result = await engine.execute('recalc', { userId: 'u1' });

expect(result.status).toBe('failed');
expect(result.innerRefusal).toEqual({ code: 'PERMISSION_DENIED', message: err.message });
expect(result.error).toBe(
`Node 'each' failed: map 'each': item 0 (subflow 'item_flow') failed: `
+ `Node 'call' failed: subflow '${WRITER}' failed: ${childText(err)}`,
);
});

it('control: a child that failed with NO refusal (a fault) leaves both containers carrying neither member', async () => {
const fault = new Error('ECONNREFUSED: could not reach the store');
const engine = containerEngine(refusingData(fault));
engine.registerFlow('item_flow', subflowFlow('item_flow', WRITER));
engine.registerFlow('recalc', mapFlow('recalc', 'item_flow'));
engine.registerFlow('outer', subflowFlow('outer', WRITER));

for (const name of ['recalc', 'outer']) {
const result = await engine.execute(name, { userId: 'u1' });
expect(result.status).toBe('failed');
expect('innerRefusal' in result).toBe(false);
expect('userMessage' in result).toBe(false);
}
const outer = await engine.execute('outer', { userId: 'u1' });
expect(outer.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(fault)}`);
});
});
20 changes: 14 additions & 6 deletions packages/services/service-automation/src/inner-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,17 @@ export function refusalCarriedBy(err: unknown): CarriedRefusal {
* through rethrows the SAME object, so the brand reaches the terminal exit
* unchanged.
*
* A container that re-words a child failure into a NEW returned failure of
* its own (`try_catch` with no catch region, `subflow`) builds a new error,
* and the brand does not cross it; the run's result then carries no member,
* which the spec's "present only when a refusal was met" allows and which
* never invents one.
* A container that runs a CHILD flow and re-words its failure into a NEW
* returned failure of its own — `subflow`, and `map` for its failing item —
* carries the child run's members onto that returned failure
* (`carriedRefusalOfResult(child)`), so the engine brands its throw like any
* data node's and the refusal reaches the top run however deep it was met.
* The child's result is the only source: a child that met no refusal spreads
* `{}`, and a `map` stops at its first failing item, so it carries that
* item's. A `try_catch` with no catch region still builds a new error the
* brand does not cross; the run's result then carries no member, which the
* spec's "present only when a refusal was met" allows and which never
* invents one.
*/
export const CARRIED_REFUSAL: unique symbol = Symbol.for(
'objectstack.automation.carriedRefusal',
Expand Down Expand Up @@ -158,7 +164,9 @@ export function readCarriedRefusal(err: unknown): CarriedRefusal {
/**
* The members an attempt's RESULT carries — for `retryExecution`, whose
* exhausted exit reports the last attempt's failure and must report that
* attempt's refusal beside it, never an earlier attempt's.
* attempt's refusal beside it, never an earlier attempt's — and for the
* `subflow` / `map` failure arms, which carry their child run's refusal onto
* the failure they return.
*/
export function carriedRefusalOfResult(result: AutomationResult): CarriedRefusal {
return pickCarried(result);
Expand Down
Loading