diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index c2e7dd744a..193929caa1 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -88,7 +88,9 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const objectName = String(cfg.objectName ?? cfg.object ?? ''); if (!objectName) return { success: false, error: 'create_record: objectName required' }; - const fields = interpolate(cfg.fields ?? {}, variables, context) as Record; + // `fieldValues` is a legacy alias some authoring paths (e.g. the AI build + // agent) emit for the write map — accept it like `filter ?? filters` below. + const fields = interpolate(cfg.fields ?? cfg.fieldValues ?? {}, variables, context) as Record; const outputVariable = cfg.outputVariable as string | undefined; const data = getData(); @@ -138,7 +140,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): if (!objectName) return { success: false, error: 'update_record: objectName required' }; const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context) as Record; - const fields = interpolate(cfg.fields ?? {}, variables, context) as Record; + // `fieldValues` — legacy authoring alias for the write map (see create_record). + const fields = interpolate(cfg.fields ?? cfg.fieldValues ?? {}, variables, context) as Record; const data = getData(); if (!data) { diff --git a/packages/services/service-automation/src/builtin/crud-output-var.test.ts b/packages/services/service-automation/src/builtin/crud-output-var.test.ts index 097a48158e..b25281cfc0 100644 --- a/packages/services/service-automation/src/builtin/crud-output-var.test.ts +++ b/packages/services/service-automation/src/builtin/crud-output-var.test.ts @@ -18,14 +18,15 @@ function makeLogger(): any { function fakeData() { const updates: Array<{ obj: string; fields: any; opts: any }> = []; + const inserts: Array<{ obj: string; fields: any; opts: any }> = []; let n = 0; const data: any = { - async insert(obj: string, fields: any) { n += 1; return { id: `${obj}_${n}`, ...fields }; }, + async insert(obj: string, fields: any, opts: any) { n += 1; inserts.push({ obj, fields, opts }); return { id: `${obj}_${n}`, ...fields }; }, async update(obj: string, fields: any, opts: any) { updates.push({ obj, fields, opts }); return { ok: true }; }, async find() { return []; }, async findOne() { return null; }, }; - return { data, updates }; + return { data, updates, inserts }; } const ctxWith = (data: any): any => ({ logger: makeLogger(), getService: (n: string) => (n === 'data' ? data : undefined) }); @@ -80,3 +81,62 @@ describe('create_record outputVariable (#1873)', () => { expect(updates[0].fields.ref).toBe('X'); }); }); + +/** + * The framework executor reads `config.fields`, but the AI build agent (and some + * legacy flows) author the write map under `config.fieldValues`. Aliasing + * `cfg.fields ?? cfg.fieldValues` — mirroring the existing `cfg.filter ?? cfg.filters` + * tolerance — makes those flows insert/update real data instead of silently + * writing an empty record. + */ +describe('create_record/update_record `fieldValues` alias (legacy authoring key)', () => { + it('reads config.fieldValues when config.fields is absent (create + update)', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, updates, inserts } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('legacy', { + name: 'legacy', label: 'L', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', outputVariable: 'topic', fieldValues: { title: 'Legacy' } } }, + { id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fieldValues: { promoted_topic: '{topic.id}' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'upd' }, + { id: 'e3', source: 'upd', target: 'end' }, + ], + } as any); + + const res = await engine.execute('legacy'); + expect(res.success).toBe(true); + // create_record honored the legacy `fieldValues` key… + expect(inserts).toHaveLength(1); + expect(inserts[0].fields.title).toBe('Legacy'); + // …and so did update_record, with interpolation still running ({topic.id} → topic_1). + expect(updates).toHaveLength(1); + expect(updates[0].fields.promoted_topic).toBe('topic_1'); + }); + + it('prefers config.fields over config.fieldValues when both are present', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, inserts } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('both', { + name: 'both', label: 'B', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', fields: { title: 'Canonical' }, fieldValues: { title: 'Legacy' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'end' }, + ], + } as any); + const res = await engine.execute('both'); + expect(res.success).toBe(true); + expect(inserts[0].fields.title).toBe('Canonical'); + }); +}); diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index f0af24038e..fc1b2eced5 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -193,3 +193,97 @@ describe('record-change trigger — end-to-end (#1491)', () => { expect(row?.stamp).toBe('done'); }, 15000); }); + +/** + * The "AI-built record-change flow writes a blank row" defect class. + * + * Once the trigger fires (#1491/#686), a `create_record` action must actually + * populate the new row. The AI build agent authors that write map under the + * `fieldValues` key (the framework executor reads `config.fields`) and links the + * new record back to the trigger row with `{record.id}` / dates with `{TODAY()}`. + * This boots the REAL kernel + trigger and drives the documented scenario — + * "customer status → lost auto-creates a linked follow-up" — end to end, for + * BOTH the canonical `fields` key and the legacy `fieldValues` alias, asserting + * the follow-up row is correctly populated (note + `{TODAY()}` date) and correctly + * linked (`customer` === the triggering record's id). + */ +describe('record-change trigger — create_record write convention (fields/fieldValues alias)', () => { + const customerObj = (name: string) => ({ + name, label: name, + fields: { status: { name: 'status', label: 'S', type: 'text' } }, + }); + const followupObj = (name: string) => ({ + name, label: name, + fields: { + customer: { name: 'customer', label: 'C', type: 'text' }, + note: { name: 'note', label: 'N', type: 'text' }, + due: { name: 'due', label: 'D', type: 'text' }, + }, + }); + + const lostFollowupFlow = (name: string, customer: string, followup: string, useLegacyKey: boolean) => { + const writeMap = { customer: '{record.id}', note: 'auto follow-up', due: '{TODAY()}' }; + return { + name, label: name, type: 'record_change', status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: customer, triggerType: 'record-after-update', condition: "record.status == 'lost'" } }, + { id: 'mk', type: 'create_record', label: 'Follow up', config: { objectName: followup, ...(useLegacyKey ? { fieldValues: writeMap } : { fields: writeMap }) } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'end' }, + ], + }; + }; + + for (const variant of [ + { key: 'fields' as const, legacy: false, suffix: 'canonical' }, + { key: 'fieldValues' as const, legacy: true, suffix: 'legacy' }, + ]) { + it(`status→lost writes a correctly-linked follow-up via config.${variant.key} (+ {record.id} + {TODAY()})`, async () => { + const cust = `cust_${variant.suffix}`; + const fup = `fup_${variant.suffix}`; + const flowName = `lost_followup_${variant.suffix}`; + + const kernel = new ObjectKernel({ logLevel: 'silent' }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new RecordChangeTriggerPlugin()); + await kernel.bootstrap(); + + const objectql = kernel.getService('objectql') as any; + const data = kernel.getService('data') as any; + const automation = kernel.getService('automation'); + + objectql.registerDriver(makeMemoryDriver(), true); + objectql.registry.registerObject(customerObj(cust), 'test', 'test'); + objectql.registry.registerObject(followupObj(fup), 'test', 'test'); + automation.registerFlow(flowName, lostFollowupFlow(flowName, cust, fup, variant.legacy) as any); + + expect((automation as any).getActiveTriggerBindings()).toContainEqual({ + flowName, + triggerType: 'record_change', + }); + + // Create a customer that is NOT yet lost — the condition must gate it out. + const created = await data.insert(cust, { status: 'open' }); + const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; + await sleep(200); + expect(await data.find(fup, { where: {} })).toHaveLength(0); + + // Flip status → lost: fires record-after-update, condition passes, flow runs. + await data.update(cust, { status: 'lost' }, { where: { id } }); + const today = new Date().toISOString().slice(0, 10); + await sleep(200); + + const followups = await data.find(fup, { where: {} }); + expect(followups).toHaveLength(1); + // correctly LINKED: {record.id} resolved to the triggering customer's id… + expect(followups[0].customer).toBe(id); + // …correctly POPULATED: literal + {TODAY()} both interpolated, not stored raw. + expect(followups[0].note).toBe('auto follow-up'); + expect(followups[0].due).toBe(today); + }, 15000); + } +});