Skip to content

Commit ffd2ce2

Browse files
os-zhuangclaude
andauthored
fix(spec,service-automation): registerFlow's remaining validators cover structured regions (#4389) (#4399)
#4347 closed the conversion and predicate halves of "metadata behaves differently depending on how deep it sits". Three validators were left walking `flow.nodes` only, so the same class stayed open one layer over: an ADR-0031 container keeps a whole sub-graph in its open `config`, and each of these checked part of the flow while reporting on all of it. - `validateControlFlow` recurses. A container nested inside another container's region was never validated at registration — it reached run time, where `runRegion` → `findRegionEntry` throws mid-iteration, after the enclosing loop has begun and its side effects have landed. Cannot break a working flow: everything newly rejected was already guaranteed to throw on execution. Also closes cycle detection over nested regions, since region bodies are cycle-checked by `analyzeRegion` here rather than by `detectCycles`. - `validateNodeTypes` covers region nodes (soft-fail). - `validateNodeConfigKeys` covers region nodes (hard-fail), with the region in each violation. No double-reporting from the container side: all three container descriptors declare their region slot as a bare `nodes: { type: 'array' }` with no `items`. Measured before extending the two hard-fail checks, since widening a rejecting validator is a behaviour change rather than a bugfix: registering every flow in app-showcase, app-crm and app-todo through the real `registerFlow` and re-running each validator's own code over all 9 region graphs produced 0 new findings. So they land at existing severity rather than staged through a warning window. `validateNodeInputSchemas` is deliberately NOT extended — 0 uses across all 159 example flow nodes, and it compares a config value's runtime type against the declared one, so extending it would newly fail a region node carrying a `{var}` template string in a `number`-typed slot. Noted on #4389 instead. Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 58a03d2 commit ffd2ce2

4 files changed

Lines changed: 237 additions & 29 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
`registerFlow`'s remaining validators cover structured regions (#4389).
7+
8+
#4347 closed the conversion and predicate halves of "metadata behaves differently
9+
depending on how deep it sits". Three validators were left walking `flow.nodes` only, so
10+
the same class stayed open one layer over: an ADR-0031 container keeps a whole sub-graph
11+
in its open `config`, and each of these checked *part* of the flow while reporting on all
12+
of it.
13+
14+
- **`validateControlFlow` recurses.** A container nested inside another container's region
15+
was never validated at registration — it reached run time, where `runRegion`
16+
`findRegionEntry` throws mid-iteration, after the enclosing loop has begun and its side
17+
effects have landed. This cannot break a working flow: everything newly rejected was
18+
already guaranteed to throw on execution. It also closes cycle detection over nested
19+
regions, since region bodies are cycle-checked by `analyzeRegion` here rather than by
20+
`detectCycles`.
21+
- **`validateNodeTypes` covers region nodes.** Soft-fail. A node in a `loop` body is as
22+
executable as one beside it, so the warning that exists to predict `NO_EXECUTOR` went
23+
quiet on exactly the nodes whose run-time failure is hardest to place.
24+
- **`validateNodeConfigKeys` covers region nodes.** Hard-fail. `visibleIf` is the typo
25+
#4277 exists to catch, and moving the node into a region restored the silence #4277
26+
closed. Violations carry the region (`loop 'sweep' body · node 'w' …`). No
27+
double-reporting from the container side: all three container descriptors declare their
28+
region slot as a bare `nodes: { type: 'array' }` with no `items`, so the schema-lockstep
29+
walk stops there instead of descending twice.
30+
31+
**Measured before extending the two hard-fail checks**, since widening a rejecting
32+
validator is a behaviour change rather than a bugfix: registering every flow in
33+
`app-showcase`, `app-crm` and `app-todo` through the real `registerFlow` and re-running
34+
each validator's own code over all 9 region graphs produced **0 new findings**. Nothing
35+
that registers today stops registering, so the checks land at their existing severity
36+
rather than staged through a warning window.
37+
38+
`validateNodeInputSchemas` is deliberately **not** extended. It declares 0 uses across all
39+
159 example flow nodes, and its check compares a config value's runtime type against the
40+
declared one — so extending it would newly fail a region node carrying a `{var}` template
41+
string in a `number`-typed slot, which is a live authoring shape. Widening a check with a
42+
known false-positive mode and no demonstrated reader is not worth it; the traversal gap is
43+
noted on #4389 instead.

packages/services/service-automation/src/engine.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2930,6 +2930,11 @@ export class AutomationEngine implements IAutomationService {
29302930
* warned about (not rejected) so flows authored against a temporarily
29312931
* absent plugin still register; the runtime surfaces a hard NO_EXECUTOR
29322932
* error if such a node is actually executed.
2933+
*
2934+
* Covers nodes inside ADR-0031 regions (#4389). A node in a `loop` body is
2935+
* as executable as one beside it, so leaving regions out meant the warning
2936+
* that exists to predict NO_EXECUTOR went quiet on exactly the nodes whose
2937+
* failure is hardest to place at run time.
29332938
*/
29342939
private validateNodeTypes(flowName: string, flow: FlowParsed): void {
29352940
const known = new Set<string>([
@@ -2938,7 +2943,9 @@ export class AutomationEngine implements IAutomationService {
29382943
...this.actionDescriptors.keys(),
29392944
]);
29402945
const unknown = [...new Set(
2941-
flow.nodes.map(n => n.type).filter(t => !known.has(t)),
2946+
collectFlowGraphs(flow)
2947+
.flatMap(g => g.nodes.map(n => n.type))
2948+
.filter(t => !known.has(t)),
29422949
)];
29432950
if (unknown.length > 0) {
29442951
this.logger.warn(
@@ -3002,13 +3009,23 @@ export class AutomationEngine implements IAutomationService {
30023009
*/
30033010
private validateNodeConfigKeys(flowName: string, flow: FlowParsed): void {
30043011
const violations: string[] = [];
3005-
for (const node of flow.nodes) {
3006-
// `assignment` config keys are the author's variable names — see the
3007-
// exemption note above.
3008-
if (node.type === 'assignment') continue;
3009-
const schema = this.actionDescriptors.get(node.type)?.configSchema as ConfigSchemaNode | undefined;
3010-
if (!schema) continue;
3011-
this.collectUndeclaredConfigKeys(node, schema, node.config, 'config', violations);
3012+
// #4389 — every graph, not just the flow's own. `visibleIf` is the typo
3013+
// this check exists to catch, and moving the node into a `loop` body
3014+
// used to restore the silence #4277 closed. No double-reporting from the
3015+
// container side: all three container descriptors declare their region
3016+
// slot as a bare `nodes: { type: 'array' }` with no `items`, so the
3017+
// schema-lockstep walk stops there rather than descending twice.
3018+
for (const graph of collectFlowGraphs(flow)) {
3019+
const scoped: string[] = [];
3020+
for (const node of graph.nodes) {
3021+
// `assignment` config keys are the author's variable names — see the
3022+
// exemption note above.
3023+
if (node.type === 'assignment') continue;
3024+
const schema = this.actionDescriptors.get(node.type)?.configSchema as ConfigSchemaNode | undefined;
3025+
if (!schema) continue;
3026+
this.collectUndeclaredConfigKeys(node, schema, node.config, 'config', scoped);
3027+
}
3028+
for (const v of scoped) violations.push(graph.scope ? `${graph.scope} · ${v}` : v);
30123029
}
30133030
if (violations.length > 0) {
30143031
throw new Error(

packages/services/service-automation/src/nested-region-parity.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,21 @@ import { AutomationEngine } from './engine.js';
2121
import type { NodeExecutor } from './engine.js';
2222
import { registerLoopNode } from './builtin/loop-node.js';
2323
import { registerLogicNodes } from './builtin/logic-nodes.js';
24+
import { registerCrudNodes } from './builtin/crud-nodes.js';
2425

2526
function silentLogger() {
2627
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
2728
}
29+
30+
/** A logger that records `warn` lines, for the soft-fail validators. */
31+
function recordingLogger(sink: string[]) {
32+
const l: any = {
33+
info() {}, error() {}, debug() {},
34+
warn(msg: string) { sink.push(String(msg)); },
35+
child() { return l; },
36+
};
37+
return l;
38+
}
2839
function ctx() {
2940
return { logger: silentLogger(), getService() { return undefined; } } as any;
3041
}
@@ -215,6 +226,126 @@ describe('#4347 — predicate validation covers region graphs', () => {
215226
});
216227
});
217228

229+
/**
230+
* #4389 — the three registration validators #4347 left walking `flow.nodes`
231+
* only. Same parity shape: identical bad metadata at the top level and one
232+
* level in must get the identical verdict.
233+
*
234+
* Measured before extending them: 0 new findings across app-showcase / app-crm
235+
* / app-todo (9 region graphs), so nothing that registers today stops.
236+
*/
237+
describe('#4389 — registration validators cover region graphs', () => {
238+
/** Wrap `nodes` in a loop body when `nested`, else splice them in flat. */
239+
const flowWith = (nested: boolean, bodyNodes: Array<Record<string, unknown>>) => ({
240+
name: 'sweep', label: 'Sweep', type: 'schedule' as const, status: 'active' as const, runAs: 'system' as const,
241+
nodes: [
242+
{ id: 'start', type: 'start', label: 'Start', config: { schedule: '0 0 * * *' } },
243+
...(nested
244+
? [{
245+
id: 'loop', type: 'loop', label: 'Loop',
246+
config: {
247+
collection: [1], iteratorVariable: 'row',
248+
body: { nodes: bodyNodes, edges: [] },
249+
},
250+
}]
251+
: bodyNodes),
252+
],
253+
edges: [{ id: 'e1', source: 'start', target: nested ? 'loop' : String(bodyNodes[0]!.id), type: 'default' as const }],
254+
});
255+
256+
describe('validateNodeConfigKeys (hard-fail)', () => {
257+
let engine: AutomationEngine;
258+
beforeEach(() => {
259+
engine = new AutomationEngine(silentLogger());
260+
registerLoopNode(engine, ctx());
261+
registerCrudNodes(engine, ctx());
262+
});
263+
264+
// `fieldz` is not on the `create_record` descriptor — the #4277 typo class.
265+
const typoNode = [{ id: 'w', type: 'create_record', label: 'W', config: { objectName: 'lead', fieldz: { a: 1 } } }];
266+
267+
it('rejects an undeclared config key at the top level (unchanged)', () => {
268+
expect(() => engine.registerFlow('sweep', flowWith(false, typoNode))).toThrow(/undeclared config key/);
269+
});
270+
271+
it('rejects the SAME key inside a loop body, naming the region', () => {
272+
expect(() => engine.registerFlow('sweep', flowWith(true, typoNode))).toThrow(/undeclared config key/);
273+
expect(() => engine.registerFlow('sweep', flowWith(true, typoNode))).toThrow(/loop 'loop' body · node 'w'/);
274+
});
275+
276+
it('reports each nested key ONCE — the container config physically contains it', () => {
277+
try {
278+
engine.registerFlow('sweep', flowWith(true, typoNode));
279+
throw new Error('expected a rejection');
280+
} catch (err) {
281+
const msg = (err as Error).message;
282+
expect(msg).toMatch(/1 undeclared config key/);
283+
expect(msg.match(/`fieldz`/g)).toHaveLength(1);
284+
}
285+
});
286+
287+
it('leaves a correct region alone', () => {
288+
const ok = [{ id: 'w', type: 'create_record', label: 'W', config: { objectName: 'lead', fields: { a: 1 } } }];
289+
expect(() => engine.registerFlow('sweep', flowWith(true, ok))).not.toThrow();
290+
});
291+
});
292+
293+
describe('validateNodeTypes (soft-fail)', () => {
294+
const unknownNode = [{ id: 'x', type: 'no_such_node_type', label: 'X' }];
295+
296+
const warningsFor = (nested: boolean) => {
297+
const warnings: string[] = [];
298+
const engine = new AutomationEngine(recordingLogger(warnings));
299+
registerLoopNode(engine, ctx());
300+
engine.registerFlow('sweep', flowWith(nested, unknownNode));
301+
return warnings.filter(w => w.includes('no registered executor'));
302+
};
303+
304+
it('warns about an unknown node type at the top level (unchanged)', () => {
305+
expect(warningsFor(false)).toHaveLength(1);
306+
});
307+
308+
it('warns about the SAME type inside a loop body', () => {
309+
const warnings = warningsFor(true);
310+
expect(warnings).toHaveLength(1);
311+
expect(warnings[0]).toContain('no_such_node_type');
312+
});
313+
});
314+
315+
describe('validateControlFlow (hard-fail)', () => {
316+
let engine: AutomationEngine;
317+
beforeEach(() => {
318+
engine = new AutomationEngine(silentLogger());
319+
registerLoopNode(engine, ctx());
320+
engine.registerNodeExecutor({ type: 'mark', async execute() { return { success: true }; } } as NodeExecutor);
321+
});
322+
323+
/** A two-entry (malformed) region — `analyzeRegion`'s single-entry rule. */
324+
const malformed = { nodes: [{ id: 'a', type: 'mark', label: 'A' }, { id: 'b', type: 'mark', label: 'B' }], edges: [] };
325+
const innerLoop = { id: 'inner', type: 'loop', label: 'Inner', config: { collection: [1], body: malformed } };
326+
327+
it('rejects a malformed region on a top-level container (unchanged)', () => {
328+
expect(() => engine.registerFlow('sweep', flowWith(false, [innerLoop]))).toThrow(/single-entry/);
329+
});
330+
331+
it('rejects a malformed region on a NESTED container, naming both levels', () => {
332+
// Previously this registered clean and threw mid-run from `findRegionEntry`
333+
// — after the outer loop had begun iterating.
334+
expect(() => engine.registerFlow('sweep', flowWith(true, [innerLoop]))).toThrow(/single-entry/);
335+
expect(() => engine.registerFlow('sweep', flowWith(true, [innerLoop])))
336+
.toThrow(/loop 'loop' body loop 'inner' body/);
337+
});
338+
339+
it('leaves a well-formed nested container alone', () => {
340+
const wellFormed = {
341+
id: 'inner', type: 'loop', label: 'Inner',
342+
config: { collection: [1], body: { nodes: [{ id: 'a', type: 'mark', label: 'A' }], edges: [] } },
343+
};
344+
expect(() => engine.registerFlow('sweep', flowWith(true, [wellFormed]))).not.toThrow();
345+
});
346+
});
347+
});
348+
218349
describe("#4347 — the legacy `{var}` path refuses an unresolved reference", () => {
219350
let engine: AutomationEngine;
220351
beforeEach(() => { engine = new AutomationEngine(silentLogger()); });

packages/spec/src/automation/control-flow.zod.ts

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -375,36 +375,53 @@ const MAX_REGION_DEPTH = 32;
375375
/**
376376
* Validate every structured control-flow construct in a flow, throwing on the
377377
* first malformed region (ADR-0031 — "reject the malformed before run"). Covers
378-
* `loop` bodies, `parallel` branches, and `try_catch` try/catch regions. Only
379-
* validates the *nested structure* when present, so legacy flat-graph `loop`
380-
* nodes (no `config.body`) are untouched — the constructs are additive.
378+
* `loop` bodies, `parallel` branches, and `try_catch` try/catch regions —
379+
* **at every depth** (#4389), so a container nested inside another container's
380+
* region is checked too. Only validates the *nested structure* when present, so
381+
* legacy flat-graph `loop` nodes (no `config.body`) are untouched — the
382+
* constructs are additive.
381383
*
382-
* Intended to be called from `registerFlow()` after DAG cycle detection.
384+
* The recursion is not extra strictness looking for work: a malformed nested
385+
* region already failed, just later and worse. `runRegion` calls
386+
* `findRegionEntry`, which throws mid-run — after the enclosing container has
387+
* begun iterating and its side effects have landed. Refusing it at
388+
* `registerFlow` is what ADR-0031's "reject the malformed before it can run"
389+
* asks for, and it cannot break a flow that works today: everything newly
390+
* rejected here was already guaranteed to throw on execution.
391+
*
392+
* Intended to be called from `registerFlow()` after DAG cycle detection. Region
393+
* bodies are cycle-checked here rather than by `detectCycles` — `analyzeRegion`
394+
* carries its own DAG pass — so this recursion is also what closes cycle
395+
* detection over nested regions.
383396
*/
384397
export function validateControlFlow(flow: { nodes: FlowNodeParsed[] }): void {
385-
for (const node of flow.nodes) {
386-
const cfg = node.config as Record<string, unknown> | undefined;
387-
if (!cfg) continue;
388-
if (node.type === PARALLEL_NODE_TYPE && Array.isArray(cfg.branches) && cfg.branches.length < 2) {
389-
throw new Error(`parallel '${node.id}': a parallel block needs at least 2 branches`);
390-
}
391-
for (const slot of regionSlotsOf(node)) {
392-
const parsed = slot.schema.safeParse(slot.raw);
393-
if (!parsed.success) {
394-
throw new Error(
395-
`${slot.label}: invalid region — ${parsed.error.issues.map(i => i.message).join('; ')}`,
396-
);
398+
for (const graph of collectFlowGraphs(flow)) {
399+
for (const node of graph.nodes) {
400+
const cfg = node.config as Record<string, unknown> | undefined;
401+
if (!cfg) continue;
402+
if (node.type === PARALLEL_NODE_TYPE && Array.isArray(cfg.branches) && cfg.branches.length < 2) {
403+
throw new Error(`parallel '${node.id}': a parallel block needs at least 2 branches`);
397404
}
398-
// Both region schemas produce `{ nodes, edges }` (a parallel branch adds
399-
// `name` — a superset); `z.ZodTypeAny` just cannot say so.
400-
const analysis = analyzeRegion(parsed.data as { nodes: FlowNodeParsed[]; edges?: FlowEdgeParsed[] });
401-
if (analysis.errors.length > 0) {
402-
throw new Error(`${slot.label}: ${analysis.errors.join('; ')}`);
405+
for (const slot of regionSlotsOf(node)) {
406+
const where = graph.scope ? `${graph.scope}${slot.label}` : slot.label;
407+
const parsed = slot.schema.safeParse(slot.raw);
408+
if (!parsed.success) {
409+
throw new Error(
410+
`${where}: invalid region — ${parsed.error.issues.map(i => i.message).join('; ')}`,
411+
);
412+
}
413+
// Both region schemas produce `{ nodes, edges }` (a parallel branch adds
414+
// `name` — a superset); `z.ZodTypeAny` just cannot say so.
415+
const analysis = analyzeRegion(parsed.data as { nodes: FlowNodeParsed[]; edges?: FlowEdgeParsed[] });
416+
if (analysis.errors.length > 0) {
417+
throw new Error(`${where}: ${analysis.errors.join('; ')}`);
418+
}
403419
}
404420
}
405421
}
406422
}
407423

424+
408425
// ─── Region normalization ────────────────────────────────────────────
409426

410427
/**

0 commit comments

Comments
 (0)