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
26 changes: 26 additions & 0 deletions .changeset/structured-loop-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
---

feat(automation): structured control-flow constructs (ADR-0031) — loop container

Adopt structured control-flow as the native, AI-authored flow model (ADR-0031),
choosing representation **(B) nested sub-structure**: containers carry their body
as a self-contained single-entry/single-exit region in `config`.

- **spec**: new `automation/control-flow.zod.ts` defining the `loop` container
(`config.body`), `parallel` block (`config.branches[]`, implicit join), and
`try/catch/retry` (`config.try`/`config.catch`/`config.retry`) configs, plus
region well-formedness analysis (`analyzeRegion`, `findRegionEntry`) and
`validateControlFlow` (single-entry/single-exit, acyclic; bounded loop).
- **engine**: `registerFlow()` now rejects malformed control-flow regions before
a flow can run; new `AutomationEngine.runRegion()` executes a body region in
the enclosing variable scope without touching the shared DAG traversal.
- **loop executor**: replaces the no-op `loop` stub with a real iteration
container — binds the iterator/index variables and runs the body once per item
under a hard max-iteration guard. Legacy flat-graph loops (no `config.body`)
keep working — the construct is additive.

Parallel-block and try/catch *engine execution* and BPMN interop mapping remain
follow-ups (issue #1479, tasks 3–5).
23 changes: 23 additions & 0 deletions docs/adr/0031-advanced-flow-node-executors-and-dag.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ structured containers; **to be decided in the implementation ADR/PR**:
The choice, plus designer rendering of containers and migration of existing
flows, is the first task below.

### Representation decision (#1479) — **(B) nested sub-structure**

The implementation adopts **(B)**: each container carries its body as a
self-contained region in `config` — `config.body` for `loop`,
`config.branches[]` for `parallel`, `config.try`/`config.catch` for `try_catch`
(see `@objectstack/spec` `automation/control-flow.zod.ts`). Rationale:

1. **Well-formed by construction.** A nested region is its *own* graph, so
single-entry is intrinsic and there are no scope markers to balance or leak
across — validation (`analyzeRegion`/`validateControlFlow`) is local.
2. **The shared `engine.ts` traversal stays untouched.** The container executor
runs its body via a scoped `AutomationEngine.runRegion()`; the main DAG
`traverseNext` never learns about scope markers (deliberate, given the
multi-agent discipline around `engine.ts`). The container's ordinary
out-edges remain the after-loop/after-block continuation, so the DAG
invariant for ordinary edges holds.
3. **Cleaner AST for AI** — the design center (ADR-0010/0011).

Existing flat-graph loops (a `loop` node with no `config.body`) keep their legacy
behavior — the constructs are **additive**, activated only when the nested
structure is present. Migrating legacy flat loops and designer rendering of
nested containers (in `../objectui`) are deferred follow-ups.

## Consequences

- **Positive**: AI (and humans) author from a small set of constructs that are
Expand Down
58 changes: 58 additions & 0 deletions examples/app-showcase/src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,63 @@ export const TaskDoneNotifyOwnerFlow = defineFlow({
],
});

/**
* Batch Reminders — demonstrates the ADR-0031 **structured loop container**.
*
* The `loop` node owns a bounded **body region** (`config.body`, a
* single-entry/single-exit sub-graph) and iterates it over a collection: each
* task is bound to `task` (and its index to `taskIndex`) in the enclosing
* variable scope, and the body sends a reminder. A hard `maxIterations` guard
* keeps iteration bounded. The loop node's ordinary out-edge (`→ end`) is the
* after-loop continuation — the DAG invariant for ordinary edges is preserved.
*/
export const BatchRemindersFlow = defineFlow({
name: 'showcase_batch_reminders',
label: 'Batch Task Reminders (Loop)',
description: 'Iterates a collection of tasks and sends a reminder for each (structured loop container, ADR-0031).',
type: 'autolaunched',
variables: [
{ name: 'tasks', type: 'list', isInput: true, isOutput: false },
],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'loop_tasks',
type: 'loop',
label: 'For each task',
config: {
collection: '{tasks}',
iteratorVariable: 'task',
indexVariable: 'taskIndex',
maxIterations: 500,
body: {
nodes: [
{
id: 'send_reminder',
type: 'script',
label: 'Send Reminder',
config: {
actionType: 'email',
inputs: {
to: '{task.owner.email}',
subject: 'Reminder ({taskIndex}): {task.title}',
template: 'showcase_task_reminder_email',
},
},
},
],
edges: [],
},
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'loop_tasks' },
{ id: 'e2', source: 'loop_tasks', target: 'end' },
],
});

export const allFlows = [
TaskCompletedFlow,
ReassignWizardFlow,
Expand All @@ -515,4 +572,5 @@ export const allFlows = [
TaskFollowUpFlow,
NotifyOwnerSubflow,
TaskDoneNotifyOwnerFlow,
BatchRemindersFlow,
];
6 changes: 5 additions & 1 deletion packages/services/service-automation/src/builtin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* descriptors with `source: 'builtin'`.
*
* Scope (built-in baseline):
* - logic — decision / assignment / loop (engine core)
* - logic — decision / assignment (engine core)
* - logic — loop (structured iteration container, ADR-0031)
* - data — get/create/update/delete_record (platform CRUD baseline)
* - human — screen / script (core flow capability)
* - io — http_request (foundational outbound I/O)
Expand All @@ -28,6 +29,7 @@
import type { PluginContext } from '@objectstack/core';
import type { AutomationEngine } from '../engine.js';
import { registerLogicNodes } from './logic-nodes.js';
import { registerLoopNode } from './loop-node.js';
import { registerCrudNodes } from './crud-nodes.js';
import { registerScreenNodes } from './screen-nodes.js';
import { registerHttpNodes } from './http-nodes.js';
Expand All @@ -37,6 +39,7 @@ import { registerWaitNode } from './wait-node.js';
import { registerSubflowNode } from './subflow-node.js';

export { registerLogicNodes } from './logic-nodes.js';
export { registerLoopNode } from './loop-node.js';
export { registerCrudNodes } from './crud-nodes.js';
export { registerScreenNodes } from './screen-nodes.js';
export { registerHttpNodes } from './http-nodes.js';
Expand All @@ -52,6 +55,7 @@ export { registerSubflowNode } from './subflow-node.js';
*/
export function installBuiltinNodes(engine: AutomationEngine, ctx: PluginContext): void {
registerLogicNodes(engine, ctx);
registerLoopNode(engine, ctx);
registerCrudNodes(engine, ctx);
registerScreenNodes(engine, ctx);
registerHttpNodes(engine, ctx);
Expand Down
29 changes: 5 additions & 24 deletions packages/services/service-automation/src/builtin/logic-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { defineActionDescriptor } from '@objectstack/spec/automation';
import type { AutomationEngine } from '../engine.js';

/**
* Logic built-in nodes — decision / assignment / loop.
* Logic built-in nodes — decision / assignment.
*
* (The `loop` container is registered separately — see `loop-node.ts` — as a
* structured iteration construct per ADR-0031.)
*
* Part of the automation engine's foundational vocabulary, so the core
* {@link AutomationServicePlugin} seeds them directly (ADR-0018). These are NOT
Expand Down Expand Up @@ -52,27 +55,5 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext)
},
});

// loop node — iterate over a collection
engine.registerNodeExecutor({
type: 'loop',
descriptor: defineActionDescriptor({
type: 'loop', version: '1.0.0', name: 'Loop',
description: 'Iterate over a collection.',
icon: 'repeat', category: 'logic', source: 'builtin',
}),
async execute(node, variables, _context) {
const config = node.config as Record<string, unknown> | undefined;
const collectionName = config?.collection as string | undefined;
if (collectionName) {
const collection = variables.get(collectionName);
if (Array.isArray(collection)) {
variables.set('$loopItems', collection);
variables.set('$loopIndex', 0);
}
}
return { success: true };
},
});

ctx.logger.info('[Logic Nodes] 3 built-in node executors registered');
ctx.logger.info('[Logic Nodes] 2 built-in node executors registered');
}
Loading
Loading