Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .changeset/loop-collection-xexpression.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/services/service-automation/src/builtin/loop-node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
15 changes: 15 additions & 0 deletions packages/spec/src/automation/control-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/spec/src/automation/control-flow.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading