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
15 changes: 11 additions & 4 deletions docs/qa/platform-checklist/areas/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"title": "Flow Runs render loop/region iterations as a nested execution tree",
"since": "v16",
"status": "active",
"revision": 3,
"revision": 4,
"priority": "P1",
"surface": "mixed",
"personas": [
Expand All @@ -86,7 +86,7 @@
"app": "showcase",
"requires": [
"showcase_batch_reminders (examples/app-showcase/src/automation/flows/index.ts BatchRemindersFlow) — an autolaunched loop flow with a `tasks` list input, runnable on demand via the trigger route",
"a `loop` whose body holds a two-branch `parallel` — the only shape that exercises both index keys on one step, needed by the loop { parallel } clause. ⚠️ NOT PRESENT in examples/app-showcase: it carries `loop` (showcase_batch_reminders) and `parallel` (showcase_fan_out_notify) as SEPARATE flows and nests neither, so that clause scores blocked(fixture) until such a flow lands (filed as #16356). The clause is written now because what it pins is settled — maintainer ruling 2026-09-03 — and a clause missing from this item is exactly what let the `iteration` overload sit unmeasured"
"showcase_nested_fan_out_reminders (examples/app-showcase/src/automation/flows/index.ts NestedFanOutRemindersFlow) — an autolaunched flow whose `loop` body holds a two-branch `parallel`, the only shape that exercises both index keys on one step; landed by #16356, which is why the loop { parallel } clause below no longer scores blocked(fixture). Rows are task-shaped `{id, title, owner, watcher}`: `owner` and `watcher` are the two branches' recipients and `notify` refuses an empty resolved recipient set, so a row missing either ends the sweep. Drive it over at least TWO rows — one row cannot distinguish an (iteration, branch) pair from a branch index alone"
]
},
"steps": [
Expand All @@ -96,7 +96,7 @@
"record every step's nodeId, nodeType, status, parentNodeId, iteration, regionKind (ExecutionStepLogSchema #1505 region tags)",
"open the flow in the Studio flow-designer (metadata-admin) and its Runs panel (FlowRunsPanel) — NOT the developer Flow Runs page — and expand the newest run",
"screenshot the expanded step tree showing the per-iteration children under the loop node",
"nesting run (needs the loop { parallel } fixture above): trigger that flow over at least two rows, GET its newest run detail, and record every branch step's parentNodeId, iteration, branch and regionKind — both index keys are read off the SAME step, which is the whole point of the clause",
"nesting run: POST /api/v1/automation/showcase_nested_fan_out_reminders/trigger with body {\"params\": {\"tasks\": [r1, r2]}} — at least TWO task-shaped rows each carrying id/title/owner/watcher — then GET /api/v1/automation/showcase_nested_fan_out_reminders/runs/:runId and record every branch step's parentNodeId, iteration, branch and regionKind; both index keys are read off the SAME step, which is the whole point of the clause",
"contrast run: trigger again with {\"params\": {\"tasks\": []}} and capture the loop step of that run"
],
"acceptance": [
Expand Down Expand Up @@ -148,7 +148,8 @@
"#3358 §2 — 'the developer Flow Runs page renders steps flat; looking there alone reads as a miss'",
"packages/spec/src/automation/execution.zod.ts#ExecutionStepLogSchema (ExecutionStepLogSchema parentNodeId/iteration/regionKind)",
"objectui packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx (#1505 buildStepTree)",
"examples/app-showcase/src/automation/flows/index.ts#BatchRemindersFlow (BatchRemindersFlow)"
"examples/app-showcase/src/automation/flows/index.ts#BatchRemindersFlow (BatchRemindersFlow)",
"examples/app-showcase/src/automation/flows/index.ts#NestedFanOutRemindersFlow (NestedFanOutRemindersFlow — the loop { parallel } fixture, #16356)"
],
"history": [
{
Expand All @@ -168,6 +169,12 @@
"date": "2026-09-06",
"change": "added the loop { parallel } clause. The item pinned parentNodeId/iteration/regionKind on a plain loop body and said nothing about the nested case — the one shape where two enclosing regions each have an index of their own. It was unwritable while `iteration` was overloaded, because there was no correct reading to assert: the branch index displaced the row. The maintainer ruling of 2026-09-03 made `iteration` single-valued and gave the branch index its own `branch` key, and the engine's runRegion tagger now carries an outer region's index through nesting instead of discarding it for a step an inner region already tagged. Also records, rather than hides, the fixture gap the clause exposes: showcase nests neither construct in the other",
"ref": "#15230"
},
{
"revision": 4,
"date": "2026-09-10",
"change": "the loop { parallel } fixture landed: examples/app-showcase gains showcase_nested_fan_out_reminders, a loop whose body holds a two-branch parallel. Revision 3 wrote the clause with no flow to trigger and recorded the gap in fixtures.requires; this revision replaces that record with the flow that closes it and spells the nesting run's trigger call and row shape, so the clause is driven rather than scored blocked(fixture)",
"ref": "#16356"
}
]
},
Expand Down
115 changes: 115 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,120 @@ export const FanOutNotifyFlow = defineFlow({
],
});

/**
* Nested Fan-out Reminders — demonstrates the ADR-0031 **parallel block nested
* inside a loop body**, the one composition the flows above do not show:
* `BatchRemindersFlow` loops, `FanOutNotifyFlow` fans out, and neither sits
* inside the other.
*
* Why the nesting earns its own flow rather than a comment: it is the only
* shape in which a single execution step carries BOTH region indices. The
* maintainer ruling of 2026-09-03 made `ExecutionStepLog.iteration`
* single-valued — always the enclosing loop's row, carried through any nesting
* — and gave the parallel branch position its own `branch` key. A step inside
* a parallel branch that is itself inside a loop body therefore records
* `iteration` (which row) and `branch` (which audience) at once, so a per-row
* failure inside one branch stays attributable to the row. In every other
* composition one of the two keys is absent by construction.
*
* Shape: the `loop` body holds exactly one node — the `parallel` — so each row
* fans out to both audiences concurrently and the block joins before the next
* row starts. ⛔ Deliberately NO `try_catch` between the two: a containment
* region would retag the leaf steps `try` / `catch`, and the branch position
* would no longer be readable off them. Per-iteration containment is
* demonstrated by `BatchRemindersFlow` instead.
*
* Input rows are task-shaped — `{ id, title, owner, watcher }`. `owner` and
* `watcher` are the two recipients and `notify` refuses an empty resolved
* recipient set, so a row missing either ends the sweep; drive it with at least
* two rows so the `(iteration, branch)` pairs have something to distinguish.
*
* Fixture for `docs/qa/platform-checklist/areas/automation.json`
* → `automation.flow-run-step-nesting`, the `loop { parallel }` clause.
*/
export const NestedFanOutRemindersFlow = defineFlow({
name: 'showcase_nested_fan_out_reminders',
label: 'Nested Fan-out Reminders (Loop of Parallel)',
description: 'Iterates a collection of tasks and notifies each task\'s owner and watcher concurrently — a parallel block nested in a loop body, the only shape that carries both region indices on one step (ADR-0031).',
type: 'autolaunched',
status: 'active',
variables: [
{ name: 'tasks', type: 'list', isInput: true, isOutput: false },
],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'each_task',
type: 'loop',
label: 'For each task',
config: {
collection: '{tasks}',
iteratorVariable: 'task',
indexVariable: 'taskIndex',
maxIterations: 500,
body: {
nodes: [
{
// The whole body is this one `parallel` node. Its branch steps are
// the records the `loop { parallel }` clause reads: each carries
// `regionKind: 'parallel-branch'`, its own `branch` (0 or 1) and
// the enclosing loop's `iteration`. The container step itself is a
// loop-body step — the row on `iteration`, no `branch` of its own.
id: 'fan_out_audiences',
type: 'parallel',
label: 'Notify both audiences',
config: {
branches: [
{
name: 'Notify the owner',
nodes: [
{
id: 'notify_owner',
type: 'notify',
label: 'Notify Owner',
config: {
recipients: '{task.owner}',
title: 'Overdue ({taskIndex}): {task.title}',
sourceObject: 'showcase_task',
sourceId: '{task.id}',
},
},
],
edges: [],
},
{
name: 'Notify the watcher',
nodes: [
{
id: 'notify_watcher',
type: 'notify',
label: 'Notify Watcher',
config: {
recipients: '{task.watcher}',
title: 'Watching ({taskIndex}): {task.title}',
sourceObject: 'showcase_task',
sourceId: '{task.id}',
},
},
],
edges: [],
},
],
},
},
],
edges: [],
},
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'each_task' },
{ id: 'e2', source: 'each_task', target: 'end' },
],
});

/**
* Resilient Sync — demonstrates the ADR-0031 **try/catch/retry** construct.
*
Expand Down Expand Up @@ -1825,6 +1939,7 @@ export const allFlows = [
ProjectClosureFlow,
BatchRemindersFlow,
FanOutNotifyFlow,
NestedFanOutRemindersFlow,
ResilientSyncFlow,
ProjectEscalationFlow,
InboundTaskWebhookFlow,
Expand Down
141 changes: 141 additions & 0 deletions examples/app-showcase/test/nested-fan-out-region-indices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#16356] The `loop { parallel }` fixture, pinned by RUNNING it.
*
* `docs/qa/platform-checklist/areas/automation.json` →
* `automation.flow-run-step-nesting` carries a `loop { parallel }` acceptance
* clause (item revision 3, from #15230). Until this flow landed the item's own
* `fixtures.requires` recorded that showcase nests neither construct in the
* other, so that clause could only ever score `blocked(fixture)`: there was no
* flow to trigger.
*
* What makes the shape load-bearing, so nobody simplifies it back: the
* maintainer ruling of 2026-09-03 made `ExecutionStepLog.iteration`
* single-valued (the enclosing loop's row, carried through nesting) and gave
* the parallel branch position its own `branch` key. **A step inside a parallel
* branch that is itself inside a loop body is the ONLY shape where both index
* keys are populated on one record.** One row, or one branch, and the clause
* cannot discriminate — which is why the assertions below are written over
* TWO rows and TWO branches.
*
* This is a RUN pin, not a structural one. A structural assertion ("the body
* holds a parallel with two branches") passes on an engine that tags the steps
* wrong, and the clause is about the tags. So the real flow — read out of
* `src/automation/flows/index.ts`, not a copy — is executed on a real
* `AutomationEngine` with the real built-in node executors, and the three
* things the clause asks for are asserted off the resulting step log:
*
* (a) every `parallel-branch` step carries a `branch` equal to its branch
* position, `branch: 0` INCLUDED — a falsy check anywhere on the way
* silently drops the first branch;
* (b) every `(iteration, branch)` pair appears exactly once across the run —
* the pre-ruling engine wrote a CONSTANT iteration per branch, so
* counting distinct values is not enough on its own;
* (c) the enclosing `parallel` container step itself reads
* `regionKind: 'loop-body'` with the row on `iteration` and NO `branch`.
*
* Plus the guard the clause's own `verify` names: every recorded step still
* parses under `ExecutionStepLogSchema` at this head.
*
* No messaging service is registered here, so `notify` takes its documented
* "no messaging service registered" path and returns success without
* delivering. That is deliberate: this pin is about the region tags the engine
* writes around the node, not about delivery, which
* `notify-delivery-outcome.integration.test.ts` owns.
*/

import { describe, it, expect } from 'vitest';
import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation';
import { ExecutionStepLogSchema } from '@objectstack/spec/automation';

import { NestedFanOutRemindersFlow, allFlows } from '../src/automation/flows/index.js';

function silentLogger(): any {
const logger: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
logger.child = () => logger;
return logger;
}

/**
* A plugin context with no services at all. `getService` answers `undefined`
* rather than throwing, which is what an unprovisioned optional service looks
* like to a node executor.
*/
function bareContext(): any {
return { logger: silentLogger(), getService: () => undefined };
}

/** Two rows, each carrying both recipients the flow's branches interpolate. */
const ROWS = [
{ id: 't1', title: 'Ship the release notes', owner: 'usr_owner_1', watcher: 'usr_watch_1' },
{ id: 't2', title: 'Close the audit finding', owner: 'usr_owner_2', watcher: 'usr_watch_2' },
];

/** Branch position → the leaf node id authored in that branch. */
const BRANCH_LEAF = ['notify_owner', 'notify_watcher'] as const;

async function runFixture() {
const engine = new AutomationEngine(silentLogger());
installBuiltinNodes(engine, bareContext());
engine.registerFlow(NestedFanOutRemindersFlow.name, NestedFanOutRemindersFlow as never);

const result = await engine.execute(NestedFanOutRemindersFlow.name, { params: { tasks: ROWS } });
const runs = await engine.listRuns(NestedFanOutRemindersFlow.name);
return { result, steps: (runs[0]?.steps ?? []) as any[] };
}

describe('#16356 — showcase_nested_fan_out_reminders is the loop { parallel } fixture', () => {
it('is part of the app, not an orphan export', () => {
// A fixture the app never loads is a fixture the checklist cannot trigger.
expect(allFlows).toContain(NestedFanOutRemindersFlow);
});

it('a branch step carries BOTH indices — the loop row on `iteration`, its own position on `branch`', async () => {
const { result, steps } = await runFixture();
expect(result.success).toBe(true);

const branchSteps = steps.filter((s) => s.regionKind === 'parallel-branch');
// 2 rows x 2 branches. A flattened fixture (no parallel in the body, or one
// branch) cannot produce this population at all.
expect(branchSteps).toHaveLength(4);

// (a) + (b): every (iteration, branch) pair exactly once, and each pair
// names the leaf the branch actually authors. `branch: 0` is spelled out
// rather than counted, so a falsy check that drops it fails here.
expect(
branchSteps.map((s) => `${s.nodeId}@iteration=${s.iteration}/branch=${s.branch}`).sort(),
).toEqual([
`${BRANCH_LEAF[0]}@iteration=0/branch=0`,
`${BRANCH_LEAF[0]}@iteration=1/branch=0`,
`${BRANCH_LEAF[1]}@iteration=0/branch=1`,
`${BRANCH_LEAF[1]}@iteration=1/branch=1`,
]);

// The innermost region still wins the identity field.
for (const s of branchSteps) expect(s.parentNodeId).toBe('fan_out_audiences');

// (c) The `parallel` container step is a loop-body step: the row on
// `iteration`, no `branch` of its own.
const containerSteps = steps.filter((s) => s.nodeId === 'fan_out_audiences');
expect(containerSteps).toHaveLength(ROWS.length);
for (const s of containerSteps) {
expect(s.regionKind).toBe('loop-body');
expect(s.parentNodeId).toBe('each_task');
expect(s.branch).toBeUndefined();
}
expect(containerSteps.map((s) => s.iteration)).toEqual([0, 1]);

// The clause's cross-check: every recorded step is a legal step log.
expect(steps.length).toBeGreaterThan(0);
for (const step of steps) {
const parsed = ExecutionStepLogSchema.safeParse(step);
if (!parsed.success) {
throw new Error(
`step ${step.nodeId} does not parse under ExecutionStepLogSchema: `
+ JSON.stringify(parsed.error.issues),
);
}
}
});
});
Loading