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
23 changes: 23 additions & 0 deletions .changeset/flow-assignment-config-shape.md
Original file line numberDiff line numberDiff line change
@@ -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: { <var>: <value> } }`
- 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
`{ <var>: <value> }`) and interpolates `{var}` templates in the values, matching
the CRUD / screen nodes. Adds `logic-nodes.test.ts` covering each shape as a
regression guard.
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, 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: { <var>: <value> } }
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' });
});
});
40 changes: 36 additions & 4 deletions packages/services/service-automation/src/builtin/logic-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -38,18 +39,49 @@ 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: { <var>: <value> } }`
// • bundled example flows → `{ assignments: [{ variable, value }] }`
// • legacy / hand-authored → `{ <var>: <value> }` (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({
type: 'assignment', version: '1.0.0', name: 'Assignment',
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<string, unknown>;
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<string, unknown>;
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') {
// { <var>: <value>, … }
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) 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 };
},
Expand Down