diff --git a/.changeset/27327-connector-action-input-value-slot.md b/.changeset/27327-connector-action-input-value-slot.md new file mode 100644 index 00000000000..79cc1479a68 --- /dev/null +++ b/.changeset/27327-connector-action-input-value-slot.md @@ -0,0 +1,16 @@ +--- +'@objectstack/service-automation': minor +--- + +fix(service-automation): a `connector_action` node evaluates a CEL envelope in `connectorConfig.input`, so a flow can send a per-record value to a connector's action + +Clause-②: no +Metadata-protocol: semantic + +`connectorConfig.input` is now a value-slot map, resolved by the same per-key resolver as a `subflow` or `map` `input`, a `script`'s `inputs`, a `screen`'s `defaults` and a `notify` node's `templateData`: + +- **An envelope is evaluated.** A top-level value written as `{ dialect: 'cel', source: '…' }` is evaluated in the run's scope, and the action's handler receives the value it computes, its type kept (a string, a number, a list, a record). Before, the handler received the envelope object itself, so a declarative connector that put the value into a request path sent `[object Object]` and the run reported success. +- **A constant payload is unchanged.** Every other value, an envelope-shaped object nested inside a literal included, reaches the handler as written. The handler now receives a copy, so a handler that writes into its input no longer writes into the stored flow. +- **A fault fails the step.** An envelope that faults on the live variables (an absent key, a type error) fails the node with the slot (`input.`) and its source, and the handler is not called, so nothing is sent upstream. + +To keep a value an author meant as a literal envelope-shaped object, nest it inside another literal; only a top-level value of the map is evaluated. diff --git a/packages/services/service-automation/src/builtin/connector-input-value-slot.test.ts b/packages/services/service-automation/src/builtin/connector-input-value-slot.test.ts new file mode 100644 index 00000000000..0110f6d5027 --- /dev/null +++ b/packages/services/service-automation/src/builtin/connector-input-value-slot.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **A `connector_action` node's `connectorConfig.input` is a value slot** + * (#27327) — evaluated through the one per-key resolver every other value + * map uses (`resolveValueSlotMap`), so a flow can send a per-record value to a + * connector's action. + * + * Before this, the executor handed `cfg.input` to the connector's handler as + * written: an envelope reading a record field reached the provider as the + * object `{ dialect: 'cel', source: '…' }` it spells, and a declarative + * connector interpolating it into a request path sent `[object Object]`, with + * the run reporting success. Pinned through the shipped `AutomationEngine`: + * + * 1. **Evaluated.** A top-level envelope is evaluated in the run's scope and + * reaches the handler as the raw value it computes — a string, a number, + * a record — next to literal keys handed on as written. + * 2. **A constant payload is unchanged.** Every literal reaches the handler + * as it was authored, a nested envelope-shaped object included, and as a + * copy: a handler that writes into its input never writes into the node. + * 3. **A fault fails the step.** An envelope that faults on the live + * variables fails the node with its source, and the handler is never + * called — nothing is sent upstream. + */ + +import { describe, expect, it } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; +import type { Connector } from '@objectstack/spec/integration'; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} +function ctx(): any { + return { logger: silentLogger(), getService() { return undefined; } }; +} + +const CONNECTOR = { + name: 'vendor', + label: 'Vendor', + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'lookup', label: 'Lookup' }], +} as Connector; + +/** An engine with every builtin node and one connector whose action records each input it is handed. */ +function makeEngine(): { engine: AutomationEngine; received: Record[] } { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + const received: Record[] = []; + engine.registerConnector(CONNECTOR, { + async lookup(input) { + received.push(input); + return { ok: true }; + }, + }); + return { engine, received }; +} + +/** start → a `connector_action` with `input` → end, the record an input variable. */ +function flowWith(name: string, input: Record): any { + return { + name, label: name, type: 'autolaunched', + variables: [{ name: 'rec', type: 'object', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'connector_action', label: 'Call', connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], + }; +} + +const REC = { id: 'cus_42', name: 'Acme', seats: 7 }; + +describe('1. an envelope in `connectorConfig.input` is evaluated in the run\'s scope', () => { + it('the handler receives the values it computes, raw, beside literals as written', async () => { + const { engine, received } = makeEngine(); + engine.registerFlow('per_record', flowWith('per_record', { + customerId: { dialect: 'cel', source: 'rec.id' }, + seats: { dialect: 'cel', source: 'rec.seats + 1' }, + record: { dialect: 'cel', source: 'rec' }, + greeting: { dialect: 'cel', source: "'Hi ' + rec.name" }, + region: 'eu', + })); + const run = await engine.execute('per_record', { params: { rec: REC } } as any); + expect(run.success, run.error).toBe(true); + expect(received).toEqual([{ customerId: 'cus_42', seats: 8, record: REC, greeting: 'Hi Acme', region: 'eu' }]); + }); +}); + +describe('2. a constant payload reaches the handler exactly as authored', () => { + it('literals, a nested envelope-shaped object included, are handed on as written, and as a copy', async () => { + const { engine, received } = makeEngine(); + const input = { + path: '/v1/customers', + limit: 10, + tags: ['a', 'b'], + body: { filter: { dialect: 'cel', source: 'rec.id' } }, + }; + const flow = flowWith('constant', input); + engine.registerFlow('constant', flow); + const run = await engine.execute('constant', { params: { rec: REC } } as any); + expect(run.success, run.error).toBe(true); + expect(received).toEqual([input]); + expect(received[0]).not.toBe(flow.nodes[1].connectorConfig.input); + expect(received[0].body).not.toBe(input.body); + }); + + it('a node with no `input` hands the handler an empty map, as before', async () => { + const { engine, received } = makeEngine(); + const flow = flowWith('no_input', {}); + delete flow.nodes[1].connectorConfig.input; + engine.registerFlow('no_input', flow); + const run = await engine.execute('no_input', { params: { rec: REC } } as any); + expect(run.success, run.error).toBe(true); + expect(received).toEqual([{}]); + }); +}); + +describe('3. an envelope that faults fails the step with its source, and nothing is sent', () => { + it('a read of an absent key fails the node, naming the slot and the source; the handler is never called', async () => { + const { engine, received } = makeEngine(); + engine.registerFlow('faulting', flowWith('faulting', { + customerId: { dialect: 'cel', source: 'rec.missing.id' }, + })); + const run = await engine.execute('faulting', { params: { rec: REC } } as any); + expect(run.success).toBe(false); + expect(run.error).toContain('input.customerId'); + expect(run.error).toContain('rec.missing.id'); + expect(received).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/builtin/connector-nodes.ts b/packages/services/service-automation/src/builtin/connector-nodes.ts index de37e063439..8550f1b6a3a 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.ts @@ -4,6 +4,7 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { AutomationEngine, ConnectorActionContext } from '../engine.js'; import { refuseNode } from '../guard-refusal.js'; +import { resolveValueSlotMap } from './value-slot-map.js'; /** * Connector built-in node — `connector_action` (generic integration dispatch). @@ -106,8 +107,17 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont // success and the failure path read one value. const effect = engine.resolveConnectorActionEffect(cfg.connectorId, cfg.actionId); + // #27327 — `input` is a value-slot map: a top-level CEL envelope is + // evaluated in the run's scope, every other value handed on as a + // copy of what was written. Before, the map went to the handler as + // written, so an envelope reading a record field reached the + // provider as the object it spells. Resolved BEFORE the `try`: an + // envelope that faults throws with its source, like every value + // slot's, and nothing is sent upstream. + const input = resolveValueSlotMap(engine, cfg.input, variables, 'input', context); + try { - const output = await handler((cfg.input ?? {}) as Record, handlerCtx); + const output = await handler(input, handlerCtx); return { success: true, output, diff --git a/packages/services/service-automation/src/builtin/value-slot-map.ts b/packages/services/service-automation/src/builtin/value-slot-map.ts index 6fbda213ee5..055ecbf2d2e 100644 --- a/packages/services/service-automation/src/builtin/value-slot-map.ts +++ b/packages/services/service-automation/src/builtin/value-slot-map.ts @@ -22,7 +22,9 @@ import type { VariableMap } from './template.js'; * and an object form's `recordId`); * - `notify` `templateData` (#19939) — handed to the messaging service (and, * through {@link resolveValueSlot}, each `recipients` value or list entry, - * and `sourceId`). + * and `sourceId`); + * - `connector_action` `connectorConfig.input` (#27327) — handed to the + * registered connector's action handler as its input. * * A `loop`'s and a `map`'s `collection` (#19939) is a value slot too, whose * literal is a list: {@link resolveCollectionSlot}. diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 25b717fc8b4..788897de5b2 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -8966,6 +8966,12 @@ export class AutomationEngine implements IAutomationService { * `assignments` * through `builtin/value-slot-map.ts`, the one per-slot resolver * (`resolveValueSlotMap`, `resolveValueSlot`, `resolveCollectionSlot`). + * One slot reaches it undeclared: a `connector_action`'s + * `connectorConfig.input` map (#27327), evaluated through the same + * resolver. The ledger addresses only `node.config`, and `connectorConfig` + * is a sibling block, so registration does not judge an envelope there + * yet — a malformed one registers and faults at run time — until #27968 + * declares it. * The two legacy * `assignment` shapes the executor still normalizes (the * `assignments: [{ variable, value }]` array and the bare