diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index a7f958f337c..23a4deadb01 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -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": [ @@ -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": [ @@ -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": [ @@ -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": [ { @@ -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" } ] }, diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 2b4708eeed2..7213ede113c 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -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. * @@ -1825,6 +1939,7 @@ export const allFlows = [ ProjectClosureFlow, BatchRemindersFlow, FanOutNotifyFlow, + NestedFanOutRemindersFlow, ResilientSyncFlow, ProjectEscalationFlow, InboundTaskWebhookFlow, diff --git a/examples/app-showcase/test/nested-fan-out-region-indices.test.ts b/examples/app-showcase/test/nested-fan-out-region-indices.test.ts new file mode 100644 index 00000000000..2eebc3720ba --- /dev/null +++ b/examples/app-showcase/test/nested-fan-out-region-indices.test.ts @@ -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), + ); + } + } + }); +});