Skip to content

Commit a07c42d

Browse files
committed
feat(automation): xExpression marker on the loop collection config field (#3304)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi
1 parent b67d38c commit a07c42d

5 files changed

Lines changed: 67 additions & 2 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
feat(automation): mark the loop `collection` config field as an interpolate() template so designer forms render it correctly (#3304)
7+
8+
The flow designer generates a node's config form from its published
9+
`configSchema` (ADR-0018). A string property can now carry an `xExpression:
10+
'expression' | 'template'` marker — riding the same Zod `.meta()` → JSON-Schema
11+
channel as `xRef` / `xEnumDeprecated` — that declares whether the string is bare
12+
CEL or an `interpolate()` single-brace `{var}` template.
13+
14+
The `loop` node's `collection` (e.g. `{tasks}`) is a template, so it is now
15+
marked `xExpression: 'template'` on both the canonical `LoopConfigSchema` and the
16+
shipped descriptor's `configSchema` literal (service-automation loop-node).
17+
Without the marker the designer rendered `collection` as plain text online while
18+
the offline hardcoded form rendered it as a mono expression editor, and the CEL
19+
brace-trap false-flagged `{tasks}` as a malformed condition. The marker closes
20+
that divergence — objectui #2670 Phase 3 (#2699) already consumes it.
21+
22+
Additive and backward-compatible: an unknown `xExpression` value is ignored by
23+
the designer, and runtime behavior is unchanged. Filling the same marker in on
24+
the remaining node types (map/decision/script and the node types that publish no
25+
`configSchema` yet) is tracked as follow-up in #3304.

packages/services/service-automation/src/builtin/loop-node.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ describe('loop container executor (ADR-0031)', () => {
6969
],
7070
});
7171

72+
it('publishes xExpression:"template" on the collection field of its configSchema (objectui #2670)', () => {
73+
// The shipped descriptor tells the flow designer `collection` is an
74+
// `interpolate()` `{var}` template — so it renders a `{var}` picker and does
75+
// not false-positive the CEL brace-trap on `{tasks}`.
76+
const descriptor = engine.getActionDescriptor('loop');
77+
const schema = descriptor?.configSchema as
78+
| { properties?: { collection?: { xExpression?: unknown } } }
79+
| undefined;
80+
expect(schema?.properties?.collection?.xExpression).toBe('template');
81+
});
82+
7283
it('iterates the body region once per item, binding iterator + index', async () => {
7384
engine.registerFlow('loop_flow', loopFlow(
7485
{

packages/services/service-automation/src/builtin/loop-node.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export function registerLoopNode(engine: AutomationEngine, ctx: PluginContext):
4040
configSchema: {
4141
type: 'object',
4242
properties: {
43-
collection: { type: 'string', description: 'Template/variable resolving to the array to iterate' },
43+
// `xExpression: 'template'` tells the designer this string is an
44+
// `interpolate()` single-brace `{var}` template (e.g. `{tasks}`), not
45+
// bare CEL — so the flow-designer renders the mono expression editor
46+
// with a `{var}` data-picker and does NOT run the CEL brace-trap on it
47+
// (objectui #2670 Phase 3; the contract mirrors `xRef`/`xEnumDeprecated`).
48+
collection: { type: 'string', description: 'Template/variable resolving to the array to iterate', xExpression: 'template' },
4449
iteratorVariable: { type: 'string', description: 'Loop variable holding the current item' },
4550
indexVariable: { type: 'string', description: 'Optional loop variable holding the current index' },
4651
maxIterations: { type: 'integer', minimum: 1, maximum: LOOP_MAX_ITERATIONS_CEILING },

packages/spec/src/automation/control-flow.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { z } from 'zod';
45
import {
56
LoopConfigSchema,
67
ParallelConfigSchema,
@@ -56,6 +57,20 @@ describe('LoopConfigSchema', () => {
5657
LoopConfigSchema.parse({ collection: '{x}', maxIterations: LOOP_MAX_ITERATIONS_CEILING + 1 }),
5758
).toThrow();
5859
});
60+
61+
it('emits the xExpression:"template" marker on `collection` through z.toJSONSchema (objectui #2670)', () => {
62+
// The marker rides the same `.meta()` → JSON-Schema channel as
63+
// `xRef` / `xEnumDeprecated`, telling the flow designer `collection` is an
64+
// `interpolate()` `{var}` template (not bare CEL).
65+
const schema = z.toJSONSchema(LoopConfigSchema, {
66+
target: 'draft-2020-12',
67+
io: 'input',
68+
unrepresentable: 'any',
69+
}) as { properties?: { collection?: { xExpression?: unknown; description?: unknown } } };
70+
expect(schema.properties?.collection?.xExpression).toBe('template');
71+
// description survives alongside the marker (they share one .meta()).
72+
expect(schema.properties?.collection?.description).toBe('Template/variable resolving to the array to iterate');
73+
});
5974
});
6075

6176
describe('ParallelConfigSchema', () => {

packages/spec/src/automation/control-flow.zod.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,16 @@ export const LoopConfigSchema = lazySchema(() => z.object({
103103
* The collection to iterate. A `{token}` template or bare variable name that
104104
* resolves (at run time) to an array in the flow's variable scope.
105105
*/
106-
collection: z.string().min(1).describe('Template/variable resolving to the array to iterate'),
106+
// `xExpression: 'template'` marks this as an `interpolate()` `{var}` template
107+
// (not bare CEL), so the flow designer renders a `{var}` picker + mono editor
108+
// and skips the CEL brace-trap (objectui #2670 Phase 3). Flows through
109+
// `z.toJSONSchema` verbatim, same channel as `xRef` / `xEnumDeprecated`. The
110+
// shipped `loop` descriptor carries the same marker on its hand-written
111+
// configSchema literal (service-automation/builtin/loop-node.ts).
112+
collection: z.string().min(1).meta({
113+
description: 'Template/variable resolving to the array to iterate',
114+
xExpression: 'template',
115+
}),
107116
/** Variable name the current item is bound to inside the body. */
108117
iteratorVariable: z.string().min(1).default('item').describe('Loop variable holding the current item'),
109118
/** Optional variable name the zero-based index is bound to inside the body. */

0 commit comments

Comments
 (0)