Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
// `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<string, unknown>;
const outputVariable = cfg.outputVariable as string | undefined;

const data = getData();
Expand Down Expand Up @@ -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<string, unknown>;
const fields = interpolate(cfg.fields ?? {}, variables, context) as Record<string, unknown>;
// `fieldValues` — legacy authoring alias for the write map (see create_record).
const fields = interpolate(cfg.fields ?? cfg.fieldValues ?? {}, variables, context) as Record<string, unknown>;

const data = getData();
if (!data) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutomationEngine>('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);
}
});