Skip to content

Commit b6a4972

Browse files
os-zhuangclaude
andauthored
fix(automation): honor the assignments wrapper shape on assignment nodes (#2250)
The built-in assignment node executor treated every top-level config key as a flow variable, but Studio's visual Assignment editor and the bundled example flows all emit an `assignments` wrapper (map or array). A node designed in Studio therefore set a single variable literally named `assignments` and never set the intended variables — passing build, no-oping at run time. Normalize the three authoring shapes (assignments map, assignments array of {variable,value}, legacy flat keys) and interpolate {var} templates in values, matching the CRUD/screen nodes. Add logic-nodes.test.ts as a regression guard. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a619a3a commit b6a4972

3 files changed

Lines changed: 144 additions & 4 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): honor the `assignments` wrapper shape on assignment nodes
6+
7+
The built-in `assignment` node executor set each TOP-LEVEL `config` key as a flow
8+
variable. But the surfaces that author these nodes all emit an `assignments`
9+
wrapper instead:
10+
11+
- Studio's visual Assignment editor → `config: { assignments: { <var>: <value> } }`
12+
- bundled example flows (app-crm, showcase) → `config: { assignments: [{ variable, value }] }`
13+
14+
So a node designed in Studio (or any of the shipped examples) silently set a
15+
single variable literally named `assignments` to the whole map/array and never
16+
set the intended variables — it passed build and no-oped at run time, leaving
17+
every downstream reference unresolved.
18+
19+
The executor now normalizes all three shapes (`assignments` map, `assignments`
20+
array of `{ variable | name | key, value }`, and the legacy flat
21+
`{ <var>: <value> }`) and interpolates `{var}` templates in the values, matching
22+
the CRUD / screen nodes. Adds `logic-nodes.test.ts` covering each shape as a
23+
regression guard.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { AutomationEngine } from '../engine.js';
5+
import { registerLogicNodes } from './logic-nodes.js';
6+
7+
function createTestLogger() {
8+
return {
9+
info: () => {},
10+
warn: () => {},
11+
error: () => {},
12+
debug: () => {},
13+
child: () => createTestLogger(),
14+
} as any;
15+
}
16+
17+
function createCtx() {
18+
return { logger: createTestLogger(), getService: () => undefined } as any;
19+
}
20+
21+
/**
22+
* A one-`assignment`-node flow. `outputs` are declared as flow output variables
23+
* so the assigned values surface on {@link AutomationResult.output}.
24+
*/
25+
function assignmentFlow(config: Record<string, unknown>, outputs: string[] = ['approval_path']) {
26+
return {
27+
name: 'assign_flow',
28+
label: 'Assign Flow',
29+
type: 'autolaunched' as const,
30+
variables: outputs.map((name) => ({ name, type: 'text', isOutput: true })),
31+
nodes: [
32+
{ id: 'start', type: 'start' as const, label: 'Start' },
33+
{ id: 'assign', type: 'assignment' as const, label: 'Set variables', config },
34+
{ id: 'end', type: 'end' as const, label: 'End' },
35+
],
36+
edges: [
37+
{ id: 'e1', source: 'start', target: 'assign' },
38+
{ id: 'e2', source: 'assign', target: 'end' },
39+
],
40+
};
41+
}
42+
43+
describe('assignment node — config-shape parity (Studio + examples)', () => {
44+
let engine: AutomationEngine;
45+
46+
beforeEach(() => {
47+
engine = new AutomationEngine(createTestLogger());
48+
registerLogicNodes(engine, createCtx());
49+
});
50+
51+
// The shape the Studio visual builder's Assignment editor emits:
52+
// config: { assignments: { <var>: <value> } }
53+
it('sets the variable from the Studio `assignments` map shape', async () => {
54+
engine.registerFlow('assign_flow', assignmentFlow({ assignments: { approval_path: 'Manager OK' } }));
55+
const result = await engine.execute('assign_flow', {} as any);
56+
expect(result.success).toBe(true);
57+
expect(result.output).toEqual({ approval_path: 'Manager OK' });
58+
});
59+
60+
// The shape the bundled example flows emit (app-crm, showcase):
61+
// config: { assignments: [{ variable, value }] }
62+
it('sets variables from the `assignments` array shape', async () => {
63+
engine.registerFlow('assign_flow', assignmentFlow({
64+
assignments: [{ variable: 'approval_path', value: 'Director sign-off' }],
65+
}));
66+
const result = await engine.execute('assign_flow', {} as any);
67+
expect(result.output).toEqual({ approval_path: 'Director sign-off' });
68+
});
69+
70+
// The legacy flat top-level shape (config keys ARE the variables) still works.
71+
it('still supports the flat key->value shape', async () => {
72+
engine.registerFlow('assign_flow', assignmentFlow({ approval_path: 'Flat works' }));
73+
const result = await engine.execute('assign_flow', {} as any);
74+
expect(result.output).toEqual({ approval_path: 'Flat works' });
75+
});
76+
77+
// Values interpolate {var} against live flow variables, like CRUD/screen nodes.
78+
it('interpolates {var} references in assignment values', async () => {
79+
const flow = assignmentFlow({ assignments: { greeting: 'Hello {name}' } }, ['greeting']);
80+
flow.variables.push({ name: 'name', type: 'text', isInput: true } as any);
81+
engine.registerFlow('assign_flow', flow);
82+
const result = await engine.execute('assign_flow', { params: { name: 'Ada' } } as any);
83+
expect(result.output).toEqual({ greeting: 'Hello Ada' });
84+
});
85+
});

packages/services/service-automation/src/builtin/logic-nodes.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { AutomationEngine } from '../engine.js';
6+
import { interpolate } from './template.js';
67

78
/**
89
* Logic built-in nodes — decision / assignment.
@@ -38,18 +39,49 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext)
3839
},
3940
});
4041

41-
// assignment node — set variables
42+
// assignment node — set variables.
43+
//
44+
// Authors reach this node through three surfaces that each emit a
45+
// DIFFERENT config shape, so the executor normalizes all three (a
46+
// mismatch here silently sets a variable literally named `assignments`
47+
// instead of the intended ones — passes build, no-ops at run time):
48+
// • Studio visual builder → `{ assignments: { <var>: <value> } }`
49+
// • bundled example flows → `{ assignments: [{ variable, value }] }`
50+
// • legacy / hand-authored → `{ <var>: <value> }` (config keys ARE
51+
// the variables).
52+
// Values interpolate `{var}` against the live flow variables, matching
53+
// the CRUD / screen nodes (so `value: '{record.amount}'` resolves).
4254
engine.registerNodeExecutor({
4355
type: 'assignment',
4456
descriptor: defineActionDescriptor({
4557
type: 'assignment', version: '1.0.0', name: 'Assignment',
4658
description: 'Set flow variables.',
4759
icon: 'variable', category: 'logic', source: 'builtin',
4860
}),
49-
async execute(node, variables, _context) {
61+
async execute(node, variables, context) {
5062
const config = (node.config ?? {}) as Record<string, unknown>;
51-
for (const [key, value] of Object.entries(config)) {
52-
variables.set(key, value);
63+
const raw = config.assignments;
64+
const pairs: Array<[string, unknown]> = [];
65+
66+
if (Array.isArray(raw)) {
67+
// [{ variable | name | key, value }, …]
68+
for (const item of raw) {
69+
if (item && typeof item === 'object') {
70+
const e = item as Record<string, unknown>;
71+
const name = (e.variable ?? e.name ?? e.key) as unknown;
72+
if (typeof name === 'string' && name) pairs.push([name, e.value]);
73+
}
74+
}
75+
} else if (raw && typeof raw === 'object') {
76+
// { <var>: <value>, … }
77+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) pairs.push([k, v]);
78+
} else {
79+
// No `assignments` wrapper — top-level config keys ARE the variables.
80+
for (const [k, v] of Object.entries(config)) pairs.push([k, v]);
81+
}
82+
83+
for (const [key, value] of pairs) {
84+
variables.set(key, interpolate(value, variables, context));
5385
}
5486
return { success: true };
5587
},

0 commit comments

Comments
 (0)