From ac2e380a2249d7816793d8b46a72aa2afdad1566 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:56:04 +0000 Subject: [PATCH] fix(app-shell): treat connector_action connectorConfig.input values as value slots in Studio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow expression ledger roots connector_action input.* in the node's connectorConfig sibling block. Studio's readers ignored the entry's block, so the Input section offered no CEL-expression editor and no in-form refusal of a {…} token, and isValueEnvelopeSlot answered for the unreachable config.input instead. - isValueEnvelopeSlot matches the entry's block (flowNodeExpressionBlock). - FlowNodeConfigField judges a typed input field through the value-slot judge with the node's sibling blocks, so it renders the text / expression control and the spec's refusal. - The Debug run judges a connector_action node's input before mocking it, and names the block in the refusal. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxVHVAdKszs18owvD6 --- .../28164-connector-input-value-slot.md | 14 ++ .../inspectors/FlowNodeConfigField.tsx | 67 ++++++- ...ctor.connectorInputEnvelope-28164.test.tsx | 186 ++++++++++++++++++ .../inspectors/flow-value-envelope.test.ts | 34 +++- .../inspectors/flow-value-envelope.ts | 35 ++-- ...low-simulator.connectorInput-28164.test.ts | 56 ++++++ .../previews/simulator/flow-simulator.ts | 22 ++- 7 files changed, 380 insertions(+), 34 deletions(-) create mode 100644 .changeset/28164-connector-input-value-slot.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.connectorInputEnvelope-28164.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.connectorInput-28164.test.ts diff --git a/.changeset/28164-connector-input-value-slot.md b/.changeset/28164-connector-input-value-slot.md new file mode 100644 index 00000000000..1bad5ed8d85 --- /dev/null +++ b/.changeset/28164-connector-input-value-slot.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): Studio's `connector_action` form treats each `connectorConfig.input` value as a value slot + +Clause-②: no +Metadata-protocol: none + +The flow expression ledger roots `connector_action` `input.*` in the node's `connectorConfig` sibling block, and `registerFlow` refuses a `{…}` token there. Studio's readers ignored the entry's block, so the Input section offered no CEL-expression editor and showed no refusal: an author met the refusal only at publish. + +- Each input value now has the text / CEL-expression toggle that other value slots have, in both the typed fields an action's `inputSchema` produces and the key/value repeater. An expression is stored as a CEL value envelope, `{ dialect: 'cel', source }`, at the same key. +- A `{…}` token typed into an input value shows the spec's refusal under the value as it is typed. The text is the same sentence the publish door gives. +- The Debug run fails a `connector_action` step whose input holds a `{…}` token, with the spec's refusal naming `connectorConfig.input.`, because the executor refuses that input before it dispatches. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx index f57e9a80e39..6413297e98e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -23,11 +23,13 @@ import { Button, Label, cn } from '@object-ui/components'; import { Code2 } from 'lucide-react'; import { FLOW_NODE_EXPRESSION_PATHS, + FLOW_NODE_SIBLING_EXPRESSION_BLOCKS, flowNodeConfigRefusals, flowNodeValueTemplateRefusals, isExpressionEnvelopeShaped, resolveFlowNodeValueSlots, valueSlotTemplateRefusals, + type FlowNodeExpressionBlock, } from '@objectstack/spec/automation'; import { FlowKeyValueField, type FlowKeyValueFieldProps } from './FlowKeyValueField.js'; import { FlowExprIssue } from './FlowExprIssue.js'; @@ -264,16 +266,49 @@ function configOf(node: Record | null | undefined): Record) : {}; } -/** Whether `path` on `node` is one `value`-role slot of the spec's ledger (objectui#12372). */ +/** + * `node` with `value` written at `path`, every object on the way copied: the + * node the spec's judges are asked about, as it would stand after the edit. + */ +function withValueAt(node: Record, path: readonly string[], value: unknown): Record { + const [head, ...rest] = path; + if (head === undefined) return node; + const cur = node[head]; + const inner = cur && typeof cur === 'object' && !Array.isArray(cur) ? (cur as Record) : {}; + return { ...node, [head]: rest.length === 0 ? value : withValueAt(inner, rest, value) }; +} + +/** + * The node block a field at `path` is rooted in, when the ledger can root a + * slot there: `config` (one key deep, a `['config', KEY]` field), or a declared + * sibling block (`FLOW_NODE_SIBLING_EXPRESSION_BLOCKS` — a `connector_action` + * node's `connectorConfig`, whose `input.` fields the connector's own + * input schema types). + */ +function slotBlockOf(path: readonly string[]): FlowNodeExpressionBlock | null { + const [block] = path; + if (block === 'config') return path.length === 2 ? 'config' : null; + return path.length >= 2 && Object.prototype.hasOwnProperty.call(FLOW_NODE_SIBLING_EXPRESSION_BLOCKS, block) + ? (block as FlowNodeExpressionBlock) + : null; +} + +/** + * Whether `path` on `node` is one `value`-role slot of the spec's ledger + * (objectui#12372), in the block the ledger roots it in. + */ function isScalarValueSlot( node: Record | null | undefined, path: readonly string[], ): boolean { const type = node?.type; - if (typeof type !== 'string' || path.length !== 2 || path[0] !== 'config') return false; - const key = path[1]; - const probe = { ...configOf(node), [key]: 'probe' }; - return resolveFlowNodeValueSlots(type, probe).some((found) => found.path === key && found.entry.eachElement !== true); + const block = slotBlockOf(path); + if (!node || typeof type !== 'string' || block === null) return false; + const slot = path.slice(1).join('.'); + const probe = withValueAt(node, path, 'probe'); + return resolveFlowNodeValueSlots(type, configOf(probe), probe).some( + (found) => found.block === block && found.path === slot && found.entry.eachElement !== true, + ); } /** @@ -298,10 +333,22 @@ function filterOperandRefusal(value: unknown): string[] | null { return refusals.length > 0 ? refusals.map((r) => r.message) : null; } -/** What the spec's config judges refuse at `key` of `config` on a node of `nodeType`. */ -function valueSlotRefusals(nodeType: string, config: Record, key: string): string[] { - const at = (path: string) => path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`); - const messages = [...flowNodeValueTemplateRefusals(nodeType, config), ...flowNodeConfigRefusals(nodeType, config)] +/** + * What the spec's judges refuse at the field `path` of `node` on a node of + * `nodeType`. The value-slot judge is handed the node's sibling blocks, so a + * slot rooted outside `config` (a `connector_action` node's + * `connectorConfig.input`) is judged there; the contract judge reads `config` + * only, so it answers for a `config` field alone. + */ +function valueSlotRefusals(nodeType: string, node: Record, path: readonly string[]): string[] { + const [block, ...rest] = path; + const key = rest.join('.'); + const at = (p: string) => p === key || p.startsWith(`${key}.`) || p.startsWith(`${key}[`); + const config = configOf(node); + const messages = [ + ...flowNodeValueTemplateRefusals(nodeType, config, node).filter((refusal) => refusal.block === block), + ...(block === 'config' ? flowNodeConfigRefusals(nodeType, config) : []), + ] .filter((refusal) => at(refusal.path)) .map((refusal) => refusal.message); return [...new Set(messages)]; @@ -599,7 +646,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, isScalarValueSlot(context?.node, field.path); const valueSlotIssues = valueSlot && typeof context?.node?.type === 'string' && !isUnsetFieldValue(value) - ? valueSlotRefusals(context.node.type, { ...configOf(context.node), [field.path[1]]: value }, field.path[1]) + ? valueSlotRefusals(context.node.type, withValueAt(context.node, field.path, value), field.path) : []; // objectui#6226 — the row-based condition builder, on the fields that opted in // AND at a site that declares the vocabulary. All three conjuncts are load diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.connectorInputEnvelope-28164.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.connectorInputEnvelope-28164.test.tsx new file mode 100644 index 00000000000..1e6a5efb1b0 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.connectorInputEnvelope-28164.test.tsx @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * FlowNodeInspector — a `connector_action` node's input values are value slots + * (objectstack#28164). + * + * The spec's expression ledger roots `connector_action` `input.*` in the node's + * `connectorConfig` sibling block, and `registerFlow` refuses a `{…}` token + * there. The form gives each input value what every other value slot has: the + * text / CEL-expression toggle, and the spec's refusal beside the value as it + * is typed, read from the same judge the publish door calls. Driven through the + * real render path, for both editors the Input section uses: a typed field + * (the action declares an `inputSchema`) and the key/value repeater (it + * declares none). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import { flowNodeValueTemplateRefusals } from '@objectstack/spec/automation'; + +vi.mock('../previews/useFlowNodePalette', async (orig) => ({ + ...(await orig>()), + useActionConfigSchemas: () => ({}), + useFlowNodePalette: () => [], +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { FlowNodeInspector } from './FlowNodeInspector'; +import type { MetadataSelection } from '../preview-registry'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const TOGGLE = 'Write as a CEL expression'; + +const SLACK_POST_MESSAGE = { + type: 'object', + required: ['channel'], + properties: { + channel: { type: 'string', description: 'Channel id, user id, or #name' }, + text: { type: 'string', description: 'Message text' }, + }, +}; + +function mockRegistry(actions: unknown[]) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response( + JSON.stringify({ + success: true, + data: { connectors: [{ name: 'slack', label: 'Slack', type: 'saas', origin: 'plugin', state: 'ready', actions }] }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); +} + +function connectorNode(input: Record, actionId = 'chat.postMessage') { + return { id: 'post', type: 'connector_action', label: 'Post to Slack', connectorConfig: { connectorId: 'slack', actionId, input } }; +} + +const SELECTION: MetadataSelection = { kind: 'node', id: 'post' }; + +/** The inspector over a draft that takes each patch, as the designer host does. */ +function Host({ initial, onPatch }: { initial: Record; onPatch: (patch: Record) => void }) { + const [draft, setDraft] = React.useState(initial); + return ( + ) => { + onPatch(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + onClearSelection={vi.fn()} + locale="en-US" + readOnly={false} + /> + ); +} + +function renderInspector(input: Record, actionId?: string) { + const onPatch = vi.fn(); + const initial = { nodes: [{ id: 'start', type: 'start' }, connectorNode(input, actionId)], edges: [{ source: 'start', target: 'post' }] }; + render(); + return { onPatch }; +} + +/** The spec's refusal of `input` on the node, at `connectorConfig.input.KEY`. */ +function specRefusal(input: Record, key: string): string { + const node = connectorNode(input); + const found = flowNodeValueTemplateRefusals('connector_action', {}, node).find( + (r) => r.block === 'connectorConfig' && r.path === `input.${key}`, + ); + expect(found, `the spec refuses connectorConfig.input.${key}`).toBeDefined(); + return found!.message; +} + +/** The control group a typed field's label heads. */ +function typedField(labelText: string): HTMLElement { + const group = screen.getByText(labelText, { selector: 'label' }).parentElement; + if (!group) throw new Error(`no group under the "${labelText}" label`); + return group; +} + +function lastInput(onPatch: ReturnType): unknown { + expect(onPatch).toHaveBeenCalled(); + const patch = onPatch.mock.calls.at(-1)![0] as { nodes?: Array> }; + const node = (patch.nodes ?? []).find((n) => n.id === 'post')!; + return (node.connectorConfig as Record).input; +} + +describe('#28164 — a typed connector input is a value slot', () => { + it('shows the spec’s refusal in the form when a `{…}` token is typed into the value', async () => { + mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]); + const { onPatch } = renderInspector({ channel: 'C123' }); + + await screen.findByText('Text', { selector: 'label' }); + const field = typedField('Text'); + expect(within(field).queryByRole('alert')).toBeNull(); + fireEvent.change(within(field).getByRole('textbox'), { target: { value: 'Hello {record.name}' } }); + + expect(lastInput(onPatch)).toEqual({ channel: 'C123', text: 'Hello {record.name}' }); + const alert = await within(typedField('Text')).findByRole('alert'); + expect(alert.textContent).toContain(specRefusal({ channel: 'C123', text: 'Hello {record.name}' }, 'text')); + }); + + it('shows the refusal for a stored token, and none for a plain literal', async () => { + mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]); + const stored = { channel: 'C123', text: 'Hi {name}' }; + renderInspector(stored); + + await screen.findByText('Text', { selector: 'label' }); + expect(within(typedField('Text')).getByRole('alert').textContent).toContain(specRefusal(stored, 'text')); + expect(within(typedField('Channel')).queryByRole('alert')).toBeNull(); + }); + + it('offers the expression toggle, which stores a CEL value envelope at the same key', async () => { + mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]); + const { onPatch } = renderInspector({ channel: 'C123' }); + + await screen.findByText('Text', { selector: 'label' }); + const field = typedField('Text'); + fireEvent.change(within(field).getByRole('textbox'), { target: { value: "'Hello ' + record.name" } }); + fireEvent.click(within(field).getByRole('button', { name: TOGGLE })); + + expect(lastInput(onPatch)).toEqual({ channel: 'C123', text: { dialect: 'cel', source: "'Hello ' + record.name" } }); + expect(within(typedField('Text')).queryByRole('alert')).toBeNull(); + }); +}); + +describe('#28164 — the Input repeater’s values are value slots', () => { + it('shows the spec’s refusal under a repeater value holding a `{…}` token, and offers the toggle', async () => { + mockRegistry([{ key: 'plain', label: 'Plain' }]); + const stored = { channel: 'C123', note: 'Hi {name}' }; + renderInspector(stored, 'plain'); + + await screen.findByDisplayValue('note'); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain(specRefusal(stored, 'note')); + expect(screen.getAllByRole('button', { name: TOGGLE })).toHaveLength(2); + }); + + it('a token typed into a repeater value is refused in place', async () => { + mockRegistry([{ key: 'plain', label: 'Plain' }]); + renderInspector({ channel: 'C123' }, 'plain'); + + const cell = await screen.findByDisplayValue('C123'); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.change(cell, { target: { value: '{record.channel}' } }); + fireEvent.blur(cell); + + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain(specRefusal({ channel: '{record.channel}' }, 'channel')), + ); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts index f06e961ad39..d569dd65949 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts @@ -12,6 +12,7 @@ import { ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, CreateRecordConfigSchema, FLOW_NODE_EXPRESSION_PATHS, + FlowValueSlotSchema, ScreenConfigSchema, ScriptConfigSchema, SubflowConfigSchema, @@ -38,7 +39,17 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui# expect(isValueEnvelopeSlot(undefined, ['config', 'assignments'])).toBe(false); }); - it('offers the envelope on exactly the six hand-table key/value maps the ledger names', () => { + it('reads the block an entry is rooted in: a connector_action node’s input map is `connectorConfig.input`', () => { + // The ledger roots `connector_action` `input.*` in the node's + // `connectorConfig` sibling block. The same key under `config` is not the + // map the executor reads (the flow parse refuses it as a loose key), so it + // is not offered there. + expect(isValueEnvelopeSlot('connector_action', ['connectorConfig', 'input'])).toBe(true); + expect(isValueEnvelopeSlot('connector_action', ['config', 'input'])).toBe(false); + expect(isValueEnvelopeSlot('subflow', ['connectorConfig', 'input'])).toBe(false); + }); + + it('offers the envelope on exactly the seven hand-table key/value maps the ledger names', () => { // The key/value editor serves many maps (params, headers, filters, inputs) // where an object naming a `dialect` is plain data. Every keyValue field in // the designer table is enumerated here. If the ledger declares another @@ -51,6 +62,11 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui# // again at 18.0.0-next.0 (ADR-0032 §3), which declares `screen` // `defaults.*`, `script` `inputs.*` and `subflow` `input.*` as well; the // next case measures the same confirmation for those three (objectui#12030). + // It grew to seven when `isValueEnvelopeSlot` started reading an entry's + // block, so the `connector_action` input map the ledger roots in + // `connectorConfig` is offered at the path the designer table writes; the + // next case measures its confirmation against the judge `registerFlow` + // applies there. const keyValueFields = FLOW_NODE_CONFIG_TYPES.flatMap((type) => fieldsForNodeType(type) .filter((f) => f.kind === 'keyValue') @@ -62,18 +78,18 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui# .map(({ type, path }) => `${type}:${path.join('.')}`); expect(offered.sort()).toEqual([ 'assignment:config.assignments', + 'connector_action:connectorConfig.input', 'create_record:config.fields', 'screen:config.defaults', 'script:config.inputs', 'subflow:config.input', 'update_record:config.fields', ]); - // The ledger's whole `value`-role list at 18.0.0-next.0. The six `KEY.*` - // maps above are the ones a key/value cell serves; the rest are single - // values (a filter, a collection, a recipient list, a record id) or a - // nested path, which this editor does not render per entry. The list grew - // at #27968 / #28150 with the one entry rooted in `connectorConfig` - // (`connector_action:input.*`), which `isValueEnvelopeSlot` does not offer. + // The ledger's whole `value`-role list. The seven `KEY.*` maps above are + // the ones a key/value cell serves; the rest are single values (a filter, + // a collection, a recipient list, a record id) or a nested path, which this + // editor does not render per entry. The list grew at #27968 / #28150 with + // the one entry rooted in `connectorConfig` (`connector_action:input.*`). expect( FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value').map((e) => `${e.nodeType}:${e.path}`).sort(), ).toEqual([ @@ -110,6 +126,10 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui# 'screen:defaults': (v: unknown) => ScreenConfigSchema.safeParse({ defaults: { name: v } }), 'script:inputs': (v: unknown) => ScriptConfigSchema.safeParse({ function: 'my_fn', inputs: { name: v } }), 'subflow:input': (v: unknown) => SubflowConfigSchema.safeParse({ flowName: 'child_flow', input: { name: v } }), + // The input map's block schema does not judge its values: `registerFlow` + // judges each envelope the ledger resolves there with the value-slot + // schema, and the `{…}` token with `flowNodeValueTemplateRefusals`. + 'connector_action:connectorConfig.input': (v: unknown) => FlowValueSlotSchema.safeParse(v), } as const; const MALFORMED = [{ dialect: 'cel' }, { dialect: 'js', source: 'a' }]; for (const [slot, parse] of Object.entries(slots)) { diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts index f9ccb52ba8a..d21a260a289 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts @@ -19,11 +19,12 @@ * restated: * * - WHICH slot takes an envelope: the expression ledger - * (`FLOW_NODE_EXPRESSION_PATHS`), whose `value`-role entries name a node type - * and a `KEY.*` path. The key/value editor serves many other maps (an - * action's `params`, a request's `headers`, a subflow's `input` …), where an - * object naming a `dialect` is just data, so the affordance is offered only - * where the ledger declares the role. + * (`FLOW_NODE_EXPRESSION_PATHS`), whose `value`-role entries name a node + * type, the node block the path is rooted in (`config`, or a declared + * sibling block) and a `KEY.*` path. The key/value editor serves many other + * maps (an action's `params`, a request's `headers` …), where an object + * naming a `dialect` is just data, so the affordance is offered only where + * the ledger declares the role. * - WHAT is an envelope: `isExpressionEnvelopeShaped`, the predicate the * ledger resolver and the executor both use. * - WHEN a value is refused: `AssignmentValueSchema`, whose issues lead with @@ -44,6 +45,7 @@ import { AssignmentExpressionValueSchema, AssignmentValueSchema, FLOW_NODE_EXPRESSION_PATHS, + flowNodeExpressionBlock, isExpressionEnvelopeShaped, type AssignmentExpressionValue, } from '@objectstack/spec/automation'; @@ -60,17 +62,26 @@ const VALUE_DIALECT = AssignmentExpressionValueSchema.shape.dialect.value; export type ValueEnvelope = Pick & Record; /** - * Whether the config field at `path` on a node of `nodeType` is a `value`-role - * slot, meaning its map values may be CEL value envelopes. + * Whether the field at `path` on a node of `nodeType` is a `value`-role map, + * meaning its values may be CEL value envelopes. * - * Only a `['config', KEY]` field can be one: the ledger's paths are dot paths - * into `node.config`, and a value-role map is written as `KEY.*`. + * Only a `[BLOCK, KEY]` field can be one: a ledger path is a dot path into the + * node block its entry is rooted in (`flowNodeExpressionBlock` — `config`, or + * a declared sibling block such as a `connector_action` node's + * `connectorConfig`), and a value-role map is written as `KEY.*`. The block is + * part of the answer: `['config', 'input']` on a `connector_action` node is + * not its input map, which lives at `['connectorConfig', 'input']`. */ export function isValueEnvelopeSlot(nodeType: unknown, path: readonly string[]): boolean { - if (typeof nodeType !== 'string' || path.length !== 2 || path[0] !== 'config') return false; - const ledgerPath = `${path[1]}.*`; + if (typeof nodeType !== 'string' || path.length !== 2) return false; + const [block, key] = path; + const ledgerPath = `${key}.*`; return FLOW_NODE_EXPRESSION_PATHS.some( - (entry) => entry.nodeType === nodeType && entry.role === 'value' && entry.path === ledgerPath, + (entry) => + entry.nodeType === nodeType && + entry.role === 'value' && + entry.path === ledgerPath && + flowNodeExpressionBlock(entry) === block, ); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.connectorInput-28164.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.connectorInput-28164.test.ts new file mode 100644 index 00000000000..30986988bc8 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.connectorInput-28164.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28164 — the Debug run judges a `connector_action` node's + * `connectorConfig.input` like every other value slot. The executor resolves + * that map before it dispatches and refuses a `{…}` token there, so the action + * is never called: the step fails with the spec's own refusal, which names the + * block. A literal and a CEL value envelope pass, and the node is mocked as + * before. + * + * Expected refusals are read from the installed spec, never restated. + */ + +import { describe, it, expect } from 'vitest'; +import { flowNodeValueTemplateRefusals } from '@objectstack/spec/automation'; +import { FlowSimulator } from '../flow-simulator'; +import type { SimNode } from '../flow-sim-types'; + +const connector = (input: Record): SimNode => ({ + id: 'post', + type: 'connector_action', + label: 'Post', + connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input }, +}); + +function runNode(node: SimNode) { + const sim = new FlowSimulator( + [{ id: 's', type: 'start' }, node, { id: 'e', type: 'end' }], + [ + { source: 's', target: node.id }, + { source: node.id, target: 'e' }, + ], + ); + sim.reset({ who: 'Ada' }); + sim.runToEnd(); + return { sim, step: sim.state.steps.find((st) => st.nodeId === node.id) }; +} + +describe('connector_action — connectorConfig.input is a value slot (objectstack#28164)', () => { + it('fails the step with the spec’s refusal of a `{…}` token, naming the block', () => { + const node = connector({ channel: 'C1', text: 'hi {who}' }); + const expected = flowNodeValueTemplateRefusals('connector_action', {}, node)[0]; + expect(expected?.block).toBe('connectorConfig'); + const { sim, step } = runNode(node); + expect(step?.status).toBe('error'); + expect(step?.error).toContain(`at connectorConfig.${expected!.path}`); + expect(step?.error).toContain(expected!.message); + expect(sim.state.steps.some((st) => st.nodeId === 'e')).toBe(false); + }); + + it('mocks the node when every input value is a literal or a CEL value envelope', () => { + const { sim, step } = runNode(connector({ channel: 'C1', text: { dialect: 'cel', source: "'hi ' + who" } })); + expect(step?.status).toBe('mocked'); + expect(sim.state.steps.some((st) => st.nodeId === 'e')).toBe(true); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-simulator.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-simulator.ts index e8a55b54330..eb70cf35a51 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-simulator.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-simulator.ts @@ -113,15 +113,20 @@ function copyLiteral(value: unknown): unknown { } /** - * The first refusal the spec's two config judges make of `config` on a node of + * The first refusal the spec's two config judges make of `node`, a node of * `type` — `flowNodeValueTemplateRefusals` (a `{…}` token at a value slot) and * `flowNodeConfigRefusals` (the node's contract) — as one line naming the * path, or `undefined`. They are the judges `registerFlow` and `objectstack * validate` call, and an executor's run-time twin refuses the same set. + * + * The value-slot judge is handed the node's sibling blocks, so a slot the + * ledger roots outside `config` (a `connector_action` node's + * `connectorConfig.input`) is judged, and the line names its block. */ -function configRefusal(type: string, config: Record): string | undefined { - const token = flowNodeValueTemplateRefusals(type, config)[0]; - if (token) return `${token.label} at config.${token.path}: ${token.message}`; +function configRefusal(type: string, node: SimNode): string | undefined { + const config = node.config ?? {}; + const token = flowNodeValueTemplateRefusals(type, config, node)[0]; + if (token) return `${token.label} at ${token.block}.${token.path}: ${token.message}`; const contract = flowNodeConfigRefusals(type, config)[0]; return contract ? `config.${contract.path}: ${contract.message}` : undefined; } @@ -381,6 +386,13 @@ export class FlowSimulator { if (type === 'loop') return this.executeLoop(node); + if (type === 'connector_action') { + // The executor resolves `connectorConfig.input` before it dispatches, and + // refuses a `{…}` token there, so the action is never called. + const refused = configRefusal(type, node); + if (refused) return this.record(node.id, type, node.label, 'error', { error: refused }); + } + if (MOCKED_SIDE_EFFECT.has(type) || type === 'script') { const wrote = this.applyMock(node); return this.proceed(node, 'mocked', { @@ -583,7 +595,7 @@ export class FlowSimulator { }); } - const refused = configRefusal('loop', cfg); + const refused = configRefusal('loop', node); if (refused) return this.record(node.id, node.type, node.label, 'error', { error: refused }); let items: unknown = cfg.collection; let ref: string;