Skip to content

Commit c4cabaa

Browse files
os-zhuangclaude
andcommitted
fix(automation): accept config.fieldValues as an alias for create/update_record fields
The create_record / update_record node executors read the write map from `config.fields`, but the AI build agent (and some legacy flows) author it under `config.fieldValues`. When the keys mismatch the executor interpolates an empty map and inserts/updates a blank record — required-field validation then fails and the automation silently does nothing. Alias `cfg.fields ?? cfg.fieldValues`, mirroring the existing `cfg.filter ?? cfg.filters` / `cfg.objectName ?? cfg.object` tolerance, so both authoring conventions write real data (`fields` wins when both are present). Tests: crud-output-var proves the alias through the real AutomationEngine (create + update, with interpolation); record-change-integration proves the full "status→lost auto-creates a linked follow-up" scenario end-to-end through the real kernel + record-change trigger + data engine, for both `fields` and `fieldValues`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d710092 commit c4cabaa

3 files changed

Lines changed: 161 additions & 4 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
8888
const objectName = String(cfg.objectName ?? cfg.object ?? '');
8989
if (!objectName) return { success: false, error: 'create_record: objectName required' };
9090

91-
const fields = interpolate(cfg.fields ?? {}, variables, context) as Record<string, unknown>;
91+
// `fieldValues` is a legacy alias some authoring paths (e.g. the AI build
92+
// agent) emit for the write map — accept it like `filter ?? filters` below.
93+
const fields = interpolate(cfg.fields ?? cfg.fieldValues ?? {}, variables, context) as Record<string, unknown>;
9294
const outputVariable = cfg.outputVariable as string | undefined;
9395

9496
const data = getData();
@@ -138,7 +140,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
138140
if (!objectName) return { success: false, error: 'update_record: objectName required' };
139141

140142
const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context) as Record<string, unknown>;
141-
const fields = interpolate(cfg.fields ?? {}, variables, context) as Record<string, unknown>;
143+
// `fieldValues` — legacy authoring alias for the write map (see create_record).
144+
const fields = interpolate(cfg.fields ?? cfg.fieldValues ?? {}, variables, context) as Record<string, unknown>;
142145

143146
const data = getData();
144147
if (!data) {

packages/services/service-automation/src/builtin/crud-output-var.test.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ function makeLogger(): any {
1818

1919
function fakeData() {
2020
const updates: Array<{ obj: string; fields: any; opts: any }> = [];
21+
const inserts: Array<{ obj: string; fields: any; opts: any }> = [];
2122
let n = 0;
2223
const data: any = {
23-
async insert(obj: string, fields: any) { n += 1; return { id: `${obj}_${n}`, ...fields }; },
24+
async insert(obj: string, fields: any, opts: any) { n += 1; inserts.push({ obj, fields, opts }); return { id: `${obj}_${n}`, ...fields }; },
2425
async update(obj: string, fields: any, opts: any) { updates.push({ obj, fields, opts }); return { ok: true }; },
2526
async find() { return []; },
2627
async findOne() { return null; },
2728
};
28-
return { data, updates };
29+
return { data, updates, inserts };
2930
}
3031

3132
const ctxWith = (data: any): any => ({ logger: makeLogger(), getService: (n: string) => (n === 'data' ? data : undefined) });
@@ -80,3 +81,62 @@ describe('create_record outputVariable (#1873)', () => {
8081
expect(updates[0].fields.ref).toBe('X');
8182
});
8283
});
84+
85+
/**
86+
* The framework executor reads `config.fields`, but the AI build agent (and some
87+
* legacy flows) author the write map under `config.fieldValues`. Aliasing
88+
* `cfg.fields ?? cfg.fieldValues` — mirroring the existing `cfg.filter ?? cfg.filters`
89+
* tolerance — makes those flows insert/update real data instead of silently
90+
* writing an empty record.
91+
*/
92+
describe('create_record/update_record `fieldValues` alias (legacy authoring key)', () => {
93+
it('reads config.fieldValues when config.fields is absent (create + update)', async () => {
94+
const engine = new AutomationEngine(makeLogger());
95+
const { data, updates, inserts } = fakeData();
96+
registerCrudNodes(engine, ctxWith(data));
97+
engine.registerFlow('legacy', {
98+
name: 'legacy', label: 'L', type: 'autolaunched',
99+
nodes: [
100+
{ id: 'start', type: 'start', label: 'Start' },
101+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', outputVariable: 'topic', fieldValues: { title: 'Legacy' } } },
102+
{ id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fieldValues: { promoted_topic: '{topic.id}' } } },
103+
{ id: 'end', type: 'end', label: 'End' },
104+
],
105+
edges: [
106+
{ id: 'e1', source: 'start', target: 'mk' },
107+
{ id: 'e2', source: 'mk', target: 'upd' },
108+
{ id: 'e3', source: 'upd', target: 'end' },
109+
],
110+
} as any);
111+
112+
const res = await engine.execute('legacy');
113+
expect(res.success).toBe(true);
114+
// create_record honored the legacy `fieldValues` key…
115+
expect(inserts).toHaveLength(1);
116+
expect(inserts[0].fields.title).toBe('Legacy');
117+
// …and so did update_record, with interpolation still running ({topic.id} → topic_1).
118+
expect(updates).toHaveLength(1);
119+
expect(updates[0].fields.promoted_topic).toBe('topic_1');
120+
});
121+
122+
it('prefers config.fields over config.fieldValues when both are present', async () => {
123+
const engine = new AutomationEngine(makeLogger());
124+
const { data, inserts } = fakeData();
125+
registerCrudNodes(engine, ctxWith(data));
126+
engine.registerFlow('both', {
127+
name: 'both', label: 'B', type: 'autolaunched',
128+
nodes: [
129+
{ id: 'start', type: 'start', label: 'Start' },
130+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', fields: { title: 'Canonical' }, fieldValues: { title: 'Legacy' } } },
131+
{ id: 'end', type: 'end', label: 'End' },
132+
],
133+
edges: [
134+
{ id: 'e1', source: 'start', target: 'mk' },
135+
{ id: 'e2', source: 'mk', target: 'end' },
136+
],
137+
} as any);
138+
const res = await engine.execute('both');
139+
expect(res.success).toBe(true);
140+
expect(inserts[0].fields.title).toBe('Canonical');
141+
});
142+
});

packages/triggers/trigger-record-change/src/record-change-integration.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,97 @@ describe('record-change trigger — end-to-end (#1491)', () => {
193193
expect(row?.stamp).toBe('done');
194194
}, 15000);
195195
});
196+
197+
/**
198+
* The "AI-built record-change flow writes a blank row" defect class.
199+
*
200+
* Once the trigger fires (#1491/#686), a `create_record` action must actually
201+
* populate the new row. The AI build agent authors that write map under the
202+
* `fieldValues` key (the framework executor reads `config.fields`) and links the
203+
* new record back to the trigger row with `{record.id}` / dates with `{TODAY()}`.
204+
* This boots the REAL kernel + trigger and drives the documented scenario —
205+
* "customer status → lost auto-creates a linked follow-up" — end to end, for
206+
* BOTH the canonical `fields` key and the legacy `fieldValues` alias, asserting
207+
* the follow-up row is correctly populated (note + `{TODAY()}` date) and correctly
208+
* linked (`customer` === the triggering record's id).
209+
*/
210+
describe('record-change trigger — create_record write convention (fields/fieldValues alias)', () => {
211+
const customerObj = (name: string) => ({
212+
name, label: name,
213+
fields: { status: { name: 'status', label: 'S', type: 'text' } },
214+
});
215+
const followupObj = (name: string) => ({
216+
name, label: name,
217+
fields: {
218+
customer: { name: 'customer', label: 'C', type: 'text' },
219+
note: { name: 'note', label: 'N', type: 'text' },
220+
due: { name: 'due', label: 'D', type: 'text' },
221+
},
222+
});
223+
224+
const lostFollowupFlow = (name: string, customer: string, followup: string, useLegacyKey: boolean) => {
225+
const writeMap = { customer: '{record.id}', note: 'auto follow-up', due: '{TODAY()}' };
226+
return {
227+
name, label: name, type: 'record_change', status: 'active',
228+
nodes: [
229+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: customer, triggerType: 'record-after-update', condition: "record.status == 'lost'" } },
230+
{ id: 'mk', type: 'create_record', label: 'Follow up', config: { objectName: followup, ...(useLegacyKey ? { fieldValues: writeMap } : { fields: writeMap }) } },
231+
{ id: 'end', type: 'end', label: 'End' },
232+
],
233+
edges: [
234+
{ id: 'e1', source: 'start', target: 'mk' },
235+
{ id: 'e2', source: 'mk', target: 'end' },
236+
],
237+
};
238+
};
239+
240+
for (const variant of [
241+
{ key: 'fields' as const, legacy: false, suffix: 'canonical' },
242+
{ key: 'fieldValues' as const, legacy: true, suffix: 'legacy' },
243+
]) {
244+
it(`status→lost writes a correctly-linked follow-up via config.${variant.key} (+ {record.id} + {TODAY()})`, async () => {
245+
const cust = `cust_${variant.suffix}`;
246+
const fup = `fup_${variant.suffix}`;
247+
const flowName = `lost_followup_${variant.suffix}`;
248+
249+
const kernel = new ObjectKernel({ logLevel: 'silent' });
250+
await kernel.use(new ObjectQLPlugin());
251+
await kernel.use(new AutomationServicePlugin());
252+
await kernel.use(new RecordChangeTriggerPlugin());
253+
await kernel.bootstrap();
254+
255+
const objectql = kernel.getService('objectql') as any;
256+
const data = kernel.getService('data') as any;
257+
const automation = kernel.getService<AutomationEngine>('automation');
258+
259+
objectql.registerDriver(makeMemoryDriver(), true);
260+
objectql.registry.registerObject(customerObj(cust), 'test', 'test');
261+
objectql.registry.registerObject(followupObj(fup), 'test', 'test');
262+
automation.registerFlow(flowName, lostFollowupFlow(flowName, cust, fup, variant.legacy) as any);
263+
264+
expect((automation as any).getActiveTriggerBindings()).toContainEqual({
265+
flowName,
266+
triggerType: 'record_change',
267+
});
268+
269+
// Create a customer that is NOT yet lost — the condition must gate it out.
270+
const created = await data.insert(cust, { status: 'open' });
271+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
272+
await sleep(200);
273+
expect(await data.find(fup, { where: {} })).toHaveLength(0);
274+
275+
// Flip status → lost: fires record-after-update, condition passes, flow runs.
276+
await data.update(cust, { status: 'lost' }, { where: { id } });
277+
const today = new Date().toISOString().slice(0, 10);
278+
await sleep(200);
279+
280+
const followups = await data.find(fup, { where: {} });
281+
expect(followups).toHaveLength(1);
282+
// correctly LINKED: {record.id} resolved to the triggering customer's id…
283+
expect(followups[0].customer).toBe(id);
284+
// …correctly POPULATED: literal + {TODAY()} both interpolated, not stored raw.
285+
expect(followups[0].note).toBe('auto follow-up');
286+
expect(followups[0].due).toBe(today);
287+
}, 15000);
288+
}
289+
});

0 commit comments

Comments
 (0)