diff --git a/.changeset/flow-assignment-config-shape.md b/.changeset/flow-assignment-config-shape.md new file mode 100644 index 0000000000..5feabee84f --- /dev/null +++ b/.changeset/flow-assignment-config-shape.md @@ -0,0 +1,23 @@ +--- +"@objectstack/service-automation": patch +--- + +fix(automation): honor the `assignments` wrapper shape on assignment nodes + +The built-in `assignment` node executor set each TOP-LEVEL `config` key as a flow +variable. But the surfaces that author these nodes all emit an `assignments` +wrapper instead: + +- Studio's visual Assignment editor → `config: { assignments: { : } }` +- bundled example flows (app-crm, showcase) → `config: { assignments: [{ variable, value }] }` + +So a node designed in Studio (or any of the shipped examples) silently set a +single variable literally named `assignments` to the whole map/array and never +set the intended variables — it passed build and no-oped at run time, leaving +every downstream reference unresolved. + +The executor now normalizes all three shapes (`assignments` map, `assignments` +array of `{ variable | name | key, value }`, and the legacy flat +`{ : }`) and interpolates `{var}` templates in the values, matching +the CRUD / screen nodes. Adds `logic-nodes.test.ts` covering each shape as a +regression guard. diff --git a/packages/services/service-automation/src/builtin/logic-nodes.test.ts b/packages/services/service-automation/src/builtin/logic-nodes.test.ts new file mode 100644 index 0000000000..b903b52477 --- /dev/null +++ b/packages/services/service-automation/src/builtin/logic-nodes.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { registerLogicNodes } from './logic-nodes.js'; + +function createTestLogger() { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => createTestLogger(), + } as any; +} + +function createCtx() { + return { logger: createTestLogger(), getService: () => undefined } as any; +} + +/** + * A one-`assignment`-node flow. `outputs` are declared as flow output variables + * so the assigned values surface on {@link AutomationResult.output}. + */ +function assignmentFlow(config: Record, outputs: string[] = ['approval_path']) { + return { + name: 'assign_flow', + label: 'Assign Flow', + type: 'autolaunched' as const, + variables: outputs.map((name) => ({ name, type: 'text', isOutput: true })), + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'assign', type: 'assignment' as const, label: 'Set variables', config }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'assign' }, + { id: 'e2', source: 'assign', target: 'end' }, + ], + }; +} + +describe('assignment node — config-shape parity (Studio + examples)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + registerLogicNodes(engine, createCtx()); + }); + + // The shape the Studio visual builder's Assignment editor emits: + // config: { assignments: { : } } + it('sets the variable from the Studio `assignments` map shape', async () => { + engine.registerFlow('assign_flow', assignmentFlow({ assignments: { approval_path: 'Manager OK' } })); + const result = await engine.execute('assign_flow', {} as any); + expect(result.success).toBe(true); + expect(result.output).toEqual({ approval_path: 'Manager OK' }); + }); + + // The shape the bundled example flows emit (app-crm, showcase): + // config: { assignments: [{ variable, value }] } + it('sets variables from the `assignments` array shape', async () => { + engine.registerFlow('assign_flow', assignmentFlow({ + assignments: [{ variable: 'approval_path', value: 'Director sign-off' }], + })); + const result = await engine.execute('assign_flow', {} as any); + expect(result.output).toEqual({ approval_path: 'Director sign-off' }); + }); + + // The legacy flat top-level shape (config keys ARE the variables) still works. + it('still supports the flat key->value shape', async () => { + engine.registerFlow('assign_flow', assignmentFlow({ approval_path: 'Flat works' })); + const result = await engine.execute('assign_flow', {} as any); + expect(result.output).toEqual({ approval_path: 'Flat works' }); + }); + + // Values interpolate {var} against live flow variables, like CRUD/screen nodes. + it('interpolates {var} references in assignment values', async () => { + const flow = assignmentFlow({ assignments: { greeting: 'Hello {name}' } }, ['greeting']); + flow.variables.push({ name: 'name', type: 'text', isInput: true } as any); + engine.registerFlow('assign_flow', flow); + const result = await engine.execute('assign_flow', { params: { name: 'Ada' } } as any); + expect(result.output).toEqual({ greeting: 'Hello Ada' }); + }); +}); diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index 647d00f9dd..31e71b89b0 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -3,6 +3,7 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; +import { interpolate } from './template.js'; /** * Logic built-in nodes — decision / assignment. @@ -38,7 +39,18 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) }, }); - // assignment node — set variables + // assignment node — set variables. + // + // Authors reach this node through three surfaces that each emit a + // DIFFERENT config shape, so the executor normalizes all three (a + // mismatch here silently sets a variable literally named `assignments` + // instead of the intended ones — passes build, no-ops at run time): + // • Studio visual builder → `{ assignments: { : } }` + // • bundled example flows → `{ assignments: [{ variable, value }] }` + // • legacy / hand-authored → `{ : }` (config keys ARE + // the variables). + // Values interpolate `{var}` against the live flow variables, matching + // the CRUD / screen nodes (so `value: '{record.amount}'` resolves). engine.registerNodeExecutor({ type: 'assignment', descriptor: defineActionDescriptor({ @@ -46,10 +58,30 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) description: 'Set flow variables.', icon: 'variable', category: 'logic', source: 'builtin', }), - async execute(node, variables, _context) { + async execute(node, variables, context) { const config = (node.config ?? {}) as Record; - for (const [key, value] of Object.entries(config)) { - variables.set(key, value); + const raw = config.assignments; + const pairs: Array<[string, unknown]> = []; + + if (Array.isArray(raw)) { + // [{ variable | name | key, value }, …] + for (const item of raw) { + if (item && typeof item === 'object') { + const e = item as Record; + const name = (e.variable ?? e.name ?? e.key) as unknown; + if (typeof name === 'string' && name) pairs.push([name, e.value]); + } + } + } else if (raw && typeof raw === 'object') { + // { : , … } + for (const [k, v] of Object.entries(raw as Record)) pairs.push([k, v]); + } else { + // No `assignments` wrapper — top-level config keys ARE the variables. + for (const [k, v] of Object.entries(config)) pairs.push([k, v]); + } + + for (const [key, value] of pairs) { + variables.set(key, interpolate(value, variables, context)); } return { success: true }; },