From a07c42d0a4babe152100dba00ed0a253a332462e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:35:07 +0000 Subject: [PATCH] feat(automation): xExpression marker on the loop collection config field (#3304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow designer generates a node's config form from its published `configSchema` (ADR-0018). Introduce an `xExpression: 'expression' | 'template'` marker on string properties — riding the same Zod `.meta()` → JSON-Schema channel as `xRef` / `xEnumDeprecated` — that declares whether a string is bare CEL or an `interpolate()` single-brace `{var}` template. Apply it to the `loop` node's `collection` (a `{tasks}` template): • LoopConfigSchema.collection — the canonical Zod source (control-flow.zod.ts), emitted via z.toJSONSchema. • the shipped descriptor's configSchema literal (service-automation loop-node.ts) — the JSON objectui actually reads. loop's configSchema is a hand-written literal that doesn't derive from the Zod schema, so both are annotated so they agree. Closes the live divergence: without the marker the designer rendered `collection` as plain text online while the offline hardcoded form rendered it as a mono expression editor, and the CEL brace-trap false-flagged `{tasks}`. objectui #2670 Phase 3 (#2699) already consumes the marker. Additive + backward-compatible: an unknown value is ignored, runtime unchanged. Follow-up (tracked in #3304): the same marker on map/decision/script and the node types that publish no configSchema yet. Tests: LoopConfigSchema emits xExpression through z.toJSONSchema (spec); the registered loop descriptor's configSchema.collection carries it (service-automation). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi --- .changeset/loop-collection-xexpression.md | 25 +++++++++++++++++++ .../src/builtin/loop-node.test.ts | 11 ++++++++ .../src/builtin/loop-node.ts | 7 +++++- .../spec/src/automation/control-flow.test.ts | 15 +++++++++++ .../spec/src/automation/control-flow.zod.ts | 11 +++++++- 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 .changeset/loop-collection-xexpression.md diff --git a/.changeset/loop-collection-xexpression.md b/.changeset/loop-collection-xexpression.md new file mode 100644 index 0000000000..1b1914d91a --- /dev/null +++ b/.changeset/loop-collection-xexpression.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": patch +"@objectstack/service-automation": patch +--- + +feat(automation): mark the loop `collection` config field as an interpolate() template so designer forms render it correctly (#3304) + +The flow designer generates a node's config form from its published +`configSchema` (ADR-0018). A string property can now carry an `xExpression: +'expression' | 'template'` marker — riding the same Zod `.meta()` → JSON-Schema +channel as `xRef` / `xEnumDeprecated` — that declares whether the string is bare +CEL or an `interpolate()` single-brace `{var}` template. + +The `loop` node's `collection` (e.g. `{tasks}`) is a template, so it is now +marked `xExpression: 'template'` on both the canonical `LoopConfigSchema` and the +shipped descriptor's `configSchema` literal (service-automation loop-node). +Without the marker the designer rendered `collection` as plain text online while +the offline hardcoded form rendered it as a mono expression editor, and the CEL +brace-trap false-flagged `{tasks}` as a malformed condition. The marker closes +that divergence — objectui #2670 Phase 3 (#2699) already consumes it. + +Additive and backward-compatible: an unknown `xExpression` value is ignored by +the designer, and runtime behavior is unchanged. Filling the same marker in on +the remaining node types (map/decision/script and the node types that publish no +`configSchema` yet) is tracked as follow-up in #3304. diff --git a/packages/services/service-automation/src/builtin/loop-node.test.ts b/packages/services/service-automation/src/builtin/loop-node.test.ts index e730bc9587..d04bcba17c 100644 --- a/packages/services/service-automation/src/builtin/loop-node.test.ts +++ b/packages/services/service-automation/src/builtin/loop-node.test.ts @@ -69,6 +69,17 @@ describe('loop container executor (ADR-0031)', () => { ], }); + it('publishes xExpression:"template" on the collection field of its configSchema (objectui #2670)', () => { + // The shipped descriptor tells the flow designer `collection` is an + // `interpolate()` `{var}` template — so it renders a `{var}` picker and does + // not false-positive the CEL brace-trap on `{tasks}`. + const descriptor = engine.getActionDescriptor('loop'); + const schema = descriptor?.configSchema as + | { properties?: { collection?: { xExpression?: unknown } } } + | undefined; + expect(schema?.properties?.collection?.xExpression).toBe('template'); + }); + it('iterates the body region once per item, binding iterator + index', async () => { engine.registerFlow('loop_flow', loopFlow( { diff --git a/packages/services/service-automation/src/builtin/loop-node.ts b/packages/services/service-automation/src/builtin/loop-node.ts index f10d6616a7..ee366da473 100644 --- a/packages/services/service-automation/src/builtin/loop-node.ts +++ b/packages/services/service-automation/src/builtin/loop-node.ts @@ -40,7 +40,12 @@ export function registerLoopNode(engine: AutomationEngine, ctx: PluginContext): configSchema: { type: 'object', properties: { - collection: { type: 'string', description: 'Template/variable resolving to the array to iterate' }, + // `xExpression: 'template'` tells the designer this string is an + // `interpolate()` single-brace `{var}` template (e.g. `{tasks}`), not + // bare CEL — so the flow-designer renders the mono expression editor + // with a `{var}` data-picker and does NOT run the CEL brace-trap on it + // (objectui #2670 Phase 3; the contract mirrors `xRef`/`xEnumDeprecated`). + collection: { type: 'string', description: 'Template/variable resolving to the array to iterate', xExpression: 'template' }, iteratorVariable: { type: 'string', description: 'Loop variable holding the current item' }, indexVariable: { type: 'string', description: 'Optional loop variable holding the current index' }, maxIterations: { type: 'integer', minimum: 1, maximum: LOOP_MAX_ITERATIONS_CEILING }, diff --git a/packages/spec/src/automation/control-flow.test.ts b/packages/spec/src/automation/control-flow.test.ts index 224d252e8e..34016a64c6 100644 --- a/packages/spec/src/automation/control-flow.test.ts +++ b/packages/spec/src/automation/control-flow.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { LoopConfigSchema, ParallelConfigSchema, @@ -56,6 +57,20 @@ describe('LoopConfigSchema', () => { LoopConfigSchema.parse({ collection: '{x}', maxIterations: LOOP_MAX_ITERATIONS_CEILING + 1 }), ).toThrow(); }); + + it('emits the xExpression:"template" marker on `collection` through z.toJSONSchema (objectui #2670)', () => { + // The marker rides the same `.meta()` → JSON-Schema channel as + // `xRef` / `xEnumDeprecated`, telling the flow designer `collection` is an + // `interpolate()` `{var}` template (not bare CEL). + const schema = z.toJSONSchema(LoopConfigSchema, { + target: 'draft-2020-12', + io: 'input', + unrepresentable: 'any', + }) as { properties?: { collection?: { xExpression?: unknown; description?: unknown } } }; + expect(schema.properties?.collection?.xExpression).toBe('template'); + // description survives alongside the marker (they share one .meta()). + expect(schema.properties?.collection?.description).toBe('Template/variable resolving to the array to iterate'); + }); }); describe('ParallelConfigSchema', () => { diff --git a/packages/spec/src/automation/control-flow.zod.ts b/packages/spec/src/automation/control-flow.zod.ts index ed9cbe7374..0f7ad74d2d 100644 --- a/packages/spec/src/automation/control-flow.zod.ts +++ b/packages/spec/src/automation/control-flow.zod.ts @@ -103,7 +103,16 @@ export const LoopConfigSchema = lazySchema(() => z.object({ * The collection to iterate. A `{token}` template or bare variable name that * resolves (at run time) to an array in the flow's variable scope. */ - collection: z.string().min(1).describe('Template/variable resolving to the array to iterate'), + // `xExpression: 'template'` marks this as an `interpolate()` `{var}` template + // (not bare CEL), so the flow designer renders a `{var}` picker + mono editor + // and skips the CEL brace-trap (objectui #2670 Phase 3). Flows through + // `z.toJSONSchema` verbatim, same channel as `xRef` / `xEnumDeprecated`. The + // shipped `loop` descriptor carries the same marker on its hand-written + // configSchema literal (service-automation/builtin/loop-node.ts). + collection: z.string().min(1).meta({ + description: 'Template/variable resolving to the array to iterate', + xExpression: 'template', + }), /** Variable name the current item is bound to inside the body. */ iteratorVariable: z.string().min(1).default('item').describe('Loop variable holding the current item'), /** Optional variable name the zero-based index is bound to inside the body. */