feat(engine): Map iterations across loop edges (no-changelog) (#36524)

This commit is contained in:
Iván Ovejero
2026-08-19 12:32:15 +00:00
committed by GitHub
parent 222eec5ccf
commit bbb4cef798
2 changed files with 234 additions and 0 deletions
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest';
import { deriveLoops } from '../../graph';
import type { GraphEdge, StepType, WorkflowGraph } from '../../graph';
import { classifyEdge, sourceRow, targetKey } from '../iteration-mapping';
function makeGraph(
edges: Array<Partial<GraphEdge> & Pick<GraphEdge, 'from' | 'to'>>,
{ batch = [] as string[] } = {},
): WorkflowGraph {
const nodeIds = [...new Set(edges.flatMap(({ from, to }) => [from, to]))];
const typeOf = (id: string): StepType =>
id === 'trigger' ? 'trigger' : batch.includes(id) ? 'batch' : 'v1-node';
return {
nodes: nodeIds.map((id) => ({ id, name: id.toUpperCase(), type: typeOf(id) })),
edges: edges.map((edge) => ({ outputIndex: 0, inputIndex: 0, ...edge })),
};
}
/**
* ┌───────┐ ┌───┐ o0 ┌───┐
* │trigger├───►│ ├──────►│ d │
* └───────┘ │ B │ └───┘
* │ │ o1 ┌───┐
* │ ├──────►│ x │
* └─▲─┘ └─┬─┘
* └──(back)───┘
*/
const loop = makeGraph(
[
{ from: 'trigger', to: 'B' },
{ from: 'B', to: 'x', outputIndex: 1 },
{ from: 'x', to: 'B', isBackEdge: true },
{ from: 'B', to: 'd', outputIndex: 0 },
],
{ batch: ['B'] },
);
const loops = deriveLoops(loop);
const edge = (from: string, to: string): GraphEdge =>
loop.edges.find((e) => e.from === from && e.to === to)!;
const classOf = (from: string, to: string) => classifyEdge(edge(from, to), loops);
describe('classifyEdge', () => {
it('classifies the four edges around a loop', () => {
expect(classOf('trigger', 'B')).toBe('entry');
expect(classOf('B', 'x')).toBe('intra');
expect(classOf('x', 'B')).toBe('back');
expect(classOf('B', 'd')).toBe('exit');
});
it('classifies edges of a loopless graph as plain', () => {
const flat = makeGraph([{ from: 'trigger', to: 'a' }]);
expect(classifyEdge(flat.edges[0], deriveLoops(flat))).toBe('plain');
});
it('classifies an edge from one loop into the next as exit', () => {
// two simple loops chained: an edge leaves B1's done slot into B2
const chained = makeGraph(
[
{ from: 'trigger', to: 'B1' },
{ from: 'B1', to: 'x', outputIndex: 1 },
{ from: 'x', to: 'B1', isBackEdge: true },
{ from: 'B1', to: 'B2', outputIndex: 0 },
{ from: 'B2', to: 'y', outputIndex: 1 },
{ from: 'y', to: 'B2', isBackEdge: true },
],
{ batch: ['B1', 'B2'] },
);
const chainEdge = chained.edges.find((e) => e.from === 'B1' && e.to === 'B2')!;
expect(classifyEdge(chainEdge, deriveLoops(chained))).toBe('exit');
});
});
describe('targetKey', () => {
it('keeps the iteration inside the loop', () => {
expect(targetKey(edge('B', 'x'), 'intra', { nodeId: 'B', iteration: 2 })).toEqual({
nodeId: 'x',
iteration: 2,
});
});
it('advances the iteration across the return edge', () => {
expect(targetKey(edge('x', 'B'), 'back', { nodeId: 'x', iteration: 2 })).toEqual({
nodeId: 'B',
iteration: 3,
});
});
it('drops back to iteration 0 when leaving the loop', () => {
expect(targetKey(edge('B', 'd'), 'exit', { nodeId: 'B', iteration: 7 })).toEqual({
nodeId: 'd',
iteration: 0,
});
});
it('targets iteration 0 through the entry edge', () => {
expect(targetKey(edge('trigger', 'B'), 'entry', { nodeId: 'trigger', iteration: 0 })).toEqual({
nodeId: 'B',
iteration: 0,
});
});
});
describe('sourceRow', () => {
const row = (nodeId: string, iteration: number) => ({
kind: 'row',
key: { nodeId, iteration },
});
it('reads the same iteration inside the loop', () => {
expect(sourceRow(edge('B', 'x'), 'intra', { nodeId: 'x', iteration: 2 })).toEqual(row('B', 2));
});
it('reads the previous iteration across the return edge', () => {
expect(sourceRow(edge('x', 'B'), 'back', { nodeId: 'B', iteration: 3 })).toEqual(row('x', 2));
});
it('reads the terminal row across an exit edge', () => {
expect(sourceRow(edge('B', 'd'), 'exit', { nodeId: 'd', iteration: 0 }, 4)).toEqual(
row('B', 4),
);
});
it('reads the entry edge at iteration 0 only, later iterations coming from the return edge', () => {
expect(sourceRow(edge('trigger', 'B'), 'entry', { nodeId: 'B', iteration: 0 })).toEqual(
row('trigger', 0),
);
expect(sourceRow(edge('trigger', 'B'), 'entry', { nodeId: 'B', iteration: 1 })).toEqual({
kind: 'none',
});
});
// `none` and `pending` are both empty, and the caller has to treat them
// differently: ignore the first, hold the decision open on the second.
it('distinguishes an edge that does not apply from one waiting for the loop to end', () => {
// a return edge has no source row at target iteration 0
expect(sourceRow(edge('x', 'B'), 'back', { nodeId: 'B', iteration: 0 })).toEqual({
kind: 'none',
});
// an exit edge does apply here, but its terminal source row does not exist yet
expect(sourceRow(edge('B', 'd'), 'exit', { nodeId: 'd', iteration: 0 })).toEqual({
kind: 'pending',
});
});
});
@@ -0,0 +1,86 @@
import type { GraphEdge, WorkflowLoop } from '../graph';
import type { StepKey } from './execution.types';
/**
* Which rows an edge connects, given that a loop member has one row per
* iteration. Each class fixes the iteration of the edge's source row (at
* `edge.from`) and of its target row (at `edge.to`):
*
* - `plain`: neither end is in a loop, so both rows sit at iteration 0.
* - `entry`: enters a batch node from outside. Target iteration 0 only, since
* later iterations are reached by the return edge instead.
* - `intra`: both ends inside one loop, so source and target share an iteration.
* - `back`: the marked return edge, where source iteration `i` pairs with target
* iteration `i + 1`.
* - `exit`: leaves a loop from the batch node's done slot. Its source is the
* loop's terminal row, whatever iteration that is, and its target sits at
* iteration 0.
*/
export type EdgeClass = 'plain' | 'entry' | 'intra' | 'back' | 'exit';
/**
* The source row an edge has at a given target iteration. The two empty cases
* mean different things and must not be collapsed:
*
* - `none`: the edge connects nothing at this target iteration, so the caller
* ignores it. An entry edge has a source at target iteration 0 only, a return
* edge at target iteration 1 upwards only. Treating this as unresolved would
* leave every later iteration undecidable.
* - `pending`: the source is a terminal row that does not exist yet, so the
* caller leaves the decision undecidable. Treating this as `none` would decide
* the done side of a loop that has not ended, and settled fates are immutable.
*/
export type SourceRow = { kind: 'row'; key: StepKey } | { kind: 'none' } | { kind: 'pending' };
export function classifyEdge(edge: GraphEdge, loops: WorkflowLoop[]): EdgeClass {
if (edge.isBackEdge) return 'back';
const sourceLoop = loops.find((loop) => loop.memberIds.has(edge.from));
const targetLoop = loops.find((loop) => loop.memberIds.has(edge.to));
if (sourceLoop && sourceLoop === targetLoop) return 'intra';
// An edge leaving one loop into another matches both `exit` and `entry`.
// `exit` is the correct one: the source row is the first loop's terminal row.
// The target row sits at iteration 0 either way.
if (sourceLoop) return 'exit';
if (targetLoop) return 'entry';
return 'plain';
}
/** The target row, given the source row. */
export function targetKey(edge: GraphEdge, edgeClass: EdgeClass, source: StepKey): StepKey {
switch (edgeClass) {
case 'back':
return { nodeId: edge.to, iteration: source.iteration + 1 };
case 'exit':
case 'entry':
return { nodeId: edge.to, iteration: 0 };
default:
return { nodeId: edge.to, iteration: source.iteration };
}
}
/** The source row, given the target row. */
export function sourceRow(
edge: GraphEdge,
edgeClass: EdgeClass,
target: StepKey,
terminalIteration?: number,
): SourceRow {
const row = (iteration: number): SourceRow => ({
kind: 'row',
key: { nodeId: edge.from, iteration },
});
switch (edgeClass) {
case 'back':
return target.iteration === 0 ? { kind: 'none' } : row(target.iteration - 1);
case 'entry':
return target.iteration > 0 ? { kind: 'none' } : row(0);
case 'exit':
if (target.iteration > 0) return { kind: 'none' };
return terminalIteration === undefined ? { kind: 'pending' } : row(terminalIteration);
default:
return row(target.iteration);
}
}