Parallel Execution Fork/Join — Phase 2: TypeScript Parity Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bring the TypeScript library (ui/) to parity with the Phase 1 Java engine's fork/join
parallelism: an active-branch token model in the simulation, a shared fork→join structural analyzer,
and the new/changed validation rules — all with vitest coverage mirroring the engine tests.
Architecture: Port the Java ParallelRegions static analyzer to a pure TS module reused by both
the simulator and the validator (mirroring the engine→validator dependency). Convert
simulate.ts from a single currentNodeId cursor to a set of active-branch tokens that fan out at
forks and synchronize (wait-for-all) at joins, keeping currentNodeId/blockedOn as derived
back-compat fields so the existing (Phase 3-owned) UI keeps compiling. Retire the
UNCONDITIONAL_MULTIPLE_EDGES warning and surface the new structural codes through the validator.
Tech Stack: TypeScript + React 19, @xyflow/react, Vite, vitest (no jsdom — pure testable
logic). All commands run from ui/.
Spec: docs/superpowers/specs/2026-09-04-parallel-execution-fork-join-design.md (Phase 2 is
defined in its "Phased delivery" §2). The Phase 1 Java implementation this ports from lives on branch
issues/gh-88 (PR #97): engine/src/main/java/io/apitomy/flow/engine/ParallelRegions.java,
WorkflowEngine.java, and engine/.../validation/WorkflowValidator.java.
Global Constraints
- Parity target: behavior must match the Phase 1 Java engine. Fork = a node with ≥2 outgoing
edges where every edge is unconditional (no
condition) and non-default (isDefault === false). AND-join = the earliest node reachable from all fork branches; it fires once all incoming edges have received a branch token. Fail-fast: any branch failure fails the whole run.ENDcompletes the whole run and cancels siblings. - Branch id scheme (verbatim from engine): root branch id is
"root"; fork children are"<parentBranchId>.<index>"(0-based); the continuing branch after a join fires is"<joinNodeId>#join". - Structural codes (exact strings):
MIXED_FORK_EDGES,FORK_WITHOUT_JOIN,PARALLEL_BRANCH_REACHES_ENDare the three the analyzer emits. (UNBALANCED_PARALLEL,CROSSING_PARALLEL_REGIONS,PARALLEL_REGION_CYCLEare defined in the spec but not emitted in Phase 1 — do not emit them here either; parity with the engine is the rule.) - Retire
UNCONDITIONAL_MULTIPLE_EDGESentirely (validator + docs). That edge shape is now valid fork authoring. - Code style: 4-space indentation in
simulate.ts(match the existing file); 2-space invalidateWorkflow.ts/instance.ts(match those files). Explicit types; keep the files React-free and pure. Do not introduce@testing-library/jsdom. - Verify every task: run
npx vitest run <changed test file>andnpx tsc --noEmitbefore marking a task complete. The final task also runsnpm run lintandnpm run build. Do not report work done on unverified code. - No Claude attribution in commit messages.
Task 1: Instance types — active branches, join arrivals, branch-attributed history
Files:
- Modify: ui/src/types/instance.ts
- Modify: ui/src/index.ts (export the new ActiveBranch type)
- Modify: ui/src/dev/sampleWorkflows.ts:96,116,145 (three WorkflowInstance literals gain the new
required fields)
Interfaces:
- Produces: interface ActiveBranch { branchId: string; nodeId: string };
WorkflowInstance.activeBranches: ActiveBranch[];
WorkflowInstance.joinArrivals: Record<string, string[]>;
WorkflowInstance.currentNodeId: string | null (was string);
HistoryEntry.branchId?: string. Tasks 2–5 consume ActiveBranch.
- [ ] Step 1: Rewrite
ui/src/types/instance.ts
export type InstanceStatus = 'running' | 'waiting' | 'completed' | 'failed' | 'cancelled';
/** A live position in the workflow graph. Mirrors the Java {@code ActiveBranch} record. */
export interface ActiveBranch {
branchId: string;
nodeId: string;
}
export interface HistoryEntry {
nodeId: string;
nodeName: string;
edgeId?: string;
edgeCondition?: string;
enteredOn: string;
completedOn?: string;
output?: Record<string, any>;
/**
* The branch this visit belongs to. The root (non-parallel) branch uses `"root"`; a missing value
* also denotes the root, preserving back-compat for existing linear histories.
*/
branchId?: string;
}
export interface WorkflowInstance {
id: string;
workflowId: string;
/**
* Derived, back-compat: the sole active node when exactly one branch is active, otherwise `null`
* (when zero or multiple branches are active). Mirrors the engine's derived accessor.
*/
currentNodeId: string | null;
/** Concurrently active branch tokens. A non-parallel instance has exactly one (`"root"`). */
activeBranches: ActiveBranch[];
/** Per-join arrival record: incoming edge ids that have received a branch token, awaiting the rest. */
joinArrivals: Record<string, string[]>;
status: InstanceStatus;
context: Record<string, any>;
history: HistoryEntry[];
failureReason?: string;
createdOn: string;
updatedOn: string;
}
- [ ] Step 2: Export
ActiveBranchfrom the library entry point
In ui/src/index.ts, extend the existing instance re-export:
export type { WorkflowInstance, InstanceStatus, HistoryEntry, ActiveBranch } from './types/instance.ts';
- [ ] Step 3: Update the three dev
WorkflowInstancefixtures
In ui/src/dev/sampleWorkflows.ts, each of the three instance literals (at lines ~96, ~116, ~145)
already sets currentNodeId: '<id>'. Immediately after each currentNodeId: line, add the two new
required fields, deriving the single active branch from that same node id. For the fixture whose
currentNodeId is 'triage':
currentNodeId: 'triage',
activeBranches: [{ branchId: 'root', nodeId: 'triage' }],
joinArrivals: {},
For the 'end-mitigated' fixture:
currentNodeId: 'end-mitigated',
activeBranches: [{ branchId: 'root', nodeId: 'end-mitigated' }],
joinArrivals: {},
Apply the same pattern (matching activeBranches[0].nodeId to that literal's currentNodeId) to all
three.
- [ ] Step 4: Typecheck
Run: cd ui && npx tsc --noEmit
Expected: PASS (no errors). If a WorkflowInstance construction elsewhere now lacks
activeBranches/joinArrivals, add them the same way (single "root" branch at the existing
currentNodeId, empty joinArrivals).
- [ ] Step 5: Run the existing suite to confirm no regressions
Run: cd ui && npx vitest run
Expected: PASS (this task changes only types + fixtures).
- [ ] Step 6: Commit
git add ui/src/types/instance.ts ui/src/index.ts ui/src/dev/sampleWorkflows.ts
git commit -m "feat(types): active-branch instance state and branch-attributed history (#88)"
Task 2: Shared fork→join analyzer (port of ParallelRegions.java)
Files:
- Create: ui/src/simulation/parallelRegions.ts
- Test: ui/src/simulation/parallelRegions.test.ts
Placement rationale: the analyzer lives under simulation/ (the runtime-parity module) and is
imported by both the simulator and the validator, mirroring the Java arrangement where the validator
imports io.apitomy.flow.engine.ParallelRegions.
Interfaces: - Produces:
interface ParallelProblem { code: string; nodeId: string }
interface ParallelAnalysis {
isFork(nodeId: string): boolean;
isJoin(nodeId: string): boolean;
joinFor(forkNodeId: string): string | undefined;
incomingEdgeIds(joinNodeId: string): Set<string>;
problems: ParallelProblem[];
}
function analyzeParallelRegions(workflow: Workflow): ParallelAnalysis;
- [ ] Step 1: Write the failing tests
Create ui/src/simulation/parallelRegions.test.ts:
import { describe, it, expect } from 'vitest';
import { type Workflow, type WorkflowNode, type WorkflowEdge } from '../types/workflow.ts';
import { analyzeParallelRegions } from './parallelRegions.ts';
function node(id: string, type: WorkflowNode['type']): WorkflowNode {
return { id, type, name: id, config: {}, position: { x: 0, y: 0 } };
}
function edge(id: string, source: string, target: string, extra: Partial<WorkflowEdge> = {}): WorkflowEdge {
return { id, source, target, priority: 0, isDefault: false, ...extra };
}
function workflow(nodes: WorkflowNode[], edges: WorkflowEdge[]): Workflow {
return { id: 'wf', name: 'wf', nodes, edges };
}
/** start -> f(fork) -> a, b ; a -> j, b -> j ; j -> end */
function forkJoin(): Workflow {
return workflow(
[node('start', 'start'), node('f', 'wait'), node('a', 'action'), node('b', 'action'),
node('j', 'wait'), node('end', 'end')],
[edge('s', 'start', 'f'), edge('fa', 'f', 'a'), edge('fb', 'f', 'b'),
edge('aj', 'a', 'j'), edge('bj', 'b', 'j'), edge('je', 'j', 'end')],
);
}
describe('analyzeParallelRegions', () => {
it('classifies a fork and its matching join', () => {
const r = analyzeParallelRegions(forkJoin());
expect(r.isFork('f')).toBe(true);
expect(r.isJoin('j')).toBe(true);
expect(r.joinFor('f')).toBe('j');
expect(r.problems).toEqual([]);
});
it('reports every incoming edge of the join', () => {
const r = analyzeParallelRegions(forkJoin());
expect(r.incomingEdgeIds('j')).toEqual(new Set(['aj', 'bj']));
});
it('returns a fresh incoming-edge set each call (no shared mutable state)', () => {
const r = analyzeParallelRegions(forkJoin());
const first = r.incomingEdgeIds('j');
first.add('mutated');
expect(r.incomingEdgeIds('j')).toEqual(new Set(['aj', 'bj']));
});
it('does not treat an exclusive choice as a fork', () => {
const w = workflow(
[node('start', 'start'), node('a', 'end'), node('b', 'end')],
[edge('ea', 'start', 'a', { condition: 'context.x == 1' }),
edge('eb', 'start', 'b', { isDefault: true })],
);
const r = analyzeParallelRegions(w);
expect(r.isFork('start')).toBe(false);
expect(r.problems).toEqual([]);
});
it('reports MIXED_FORK_EDGES when unconditional and conditional edges are mixed', () => {
const w = workflow(
[node('start', 'start'), node('a', 'end'), node('b', 'end')],
[edge('ea', 'start', 'a'), edge('eb', 'start', 'b', { condition: 'context.x == 1' })],
);
const r = analyzeParallelRegions(w);
expect(r.isFork('start')).toBe(false);
expect(r.problems).toContainEqual({ code: 'MIXED_FORK_EDGES', nodeId: 'start' });
});
it('reports FORK_WITHOUT_JOIN when branches never re-converge', () => {
const w = workflow(
[node('start', 'start'), node('a', 'action'), node('b', 'action'),
node('ea', 'end'), node('eb', 'end')],
[edge('fa', 'start', 'a'), edge('fb', 'start', 'b'),
edge('ae', 'a', 'ea'), edge('be', 'b', 'eb')],
);
const r = analyzeParallelRegions(w);
// both branches reach an END without a common convergence node
expect(r.problems.some(p => p.nodeId === 'start'
&& (p.code === 'FORK_WITHOUT_JOIN' || p.code === 'PARALLEL_BRANCH_REACHES_END'))).toBe(true);
});
it('reports PARALLEL_BRANCH_REACHES_END when a branch can hit END before the join', () => {
// start forks to a and b; a -> j, b -> end (bypasses the join), j has a and (would-be) b
const w = workflow(
[node('start', 'start'), node('a', 'action'), node('b', 'action'),
node('j', 'wait'), node('end', 'end')],
[edge('fa', 'start', 'a'), edge('fb', 'start', 'b'),
edge('aj', 'a', 'j'), edge('be', 'b', 'end'), edge('je', 'j', 'end')],
);
const r = analyzeParallelRegions(w);
expect(r.problems.some(p => p.nodeId === 'start' && p.code === 'PARALLEL_BRANCH_REACHES_END'))
.toBe(true);
});
});
- [ ] Step 2: Run the tests to verify they fail
Run: cd ui && npx vitest run src/simulation/parallelRegions.test.ts
Expected: FAIL — analyzeParallelRegions is not defined (module missing).
- [ ] Step 3: Implement the analyzer
Create ui/src/simulation/parallelRegions.ts (4-space indentation):
import { type Workflow, type WorkflowEdge } from '../types/workflow.ts';
/** A structural problem discovered during parallel-region analysis. */
export interface ParallelProblem {
code: string;
nodeId: string;
}
/**
* Result of static parallel-region analysis: fork/join classification plus any structural problems.
* Mirrors the Java {@code io.apitomy.flow.engine.ParallelRegions} so the validator and the simulation
* agree on what a fork is, what its join is, and what a well-formed region looks like. Pure and
* side-effect free.
*/
export interface ParallelAnalysis {
/** @returns true if the node is a fork (≥2 outgoing edges, all unconditional and non-default). */
isFork(nodeId: string): boolean;
/** @returns true if the node is the synchronizing join of some fork. */
isJoin(nodeId: string): boolean;
/** @returns the join node id paired with the given fork, or undefined if none. */
joinFor(forkNodeId: string): string | undefined;
/** @returns a fresh copy of the incoming edge ids a join must collect before it fires. */
incomingEdgeIds(joinNodeId: string): Set<string>;
/** The structural problems discovered during analysis. */
problems: ParallelProblem[];
}
function isUnconditional(e: WorkflowEdge): boolean {
return !e.condition || e.condition.trim() === '';
}
function outgoing(workflow: Workflow, nodeId: string): WorkflowEdge[] {
return workflow.edges.filter(e => e.source === nodeId);
}
function isEndNode(workflow: Workflow, nodeId: string): boolean {
return workflow.nodes.find(n => n.id === nodeId)?.type === 'end';
}
/**
* Analyzes a workflow's parallel structure.
*
* @param workflow the workflow to analyze
* @returns the computed regions and any structural problems
*/
export function analyzeParallelRegions(workflow: Workflow): ParallelAnalysis {
const forks = new Set<string>();
const joins = new Set<string>();
const forkToJoin = new Map<string, string>();
const joinIncoming = new Map<string, Set<string>>();
const problems: ParallelProblem[] = [];
for (const node of workflow.nodes) {
const out = outgoing(workflow, node.id);
if (out.length < 2) {
continue;
}
const anyForkShaped = out.some(e => isUnconditional(e) && !e.isDefault);
const allForkShaped = out.every(e => isUnconditional(e) && !e.isDefault);
if (allForkShaped) {
forks.add(node.id);
} else if (anyForkShaped) {
problems.push({ code: 'MIXED_FORK_EDGES', nodeId: node.id });
}
}
for (const forkId of forks) {
const join = findJoin(workflow, forkId, problems);
if (join !== null) {
forkToJoin.set(forkId, join);
joins.add(join);
const incoming = new Set<string>();
for (const e of workflow.edges) {
if (e.target === join) {
incoming.add(e.id);
}
}
joinIncoming.set(join, incoming);
}
}
return {
isFork: (nodeId) => forks.has(nodeId),
isJoin: (nodeId) => joins.has(nodeId),
joinFor: (forkNodeId) => forkToJoin.get(forkNodeId),
incomingEdgeIds: (joinNodeId) => new Set(joinIncoming.get(joinNodeId) ?? []),
problems,
};
}
/**
* Finds the synchronizing join for a fork: the earliest node where every branch leaving the fork
* re-converges. Records FORK_WITHOUT_JOIN / PARALLEL_BRANCH_REACHES_END when no single balanced
* convergence node exists. Mirrors {@code ParallelRegions.findJoin}.
*/
function findJoin(workflow: Workflow, forkId: string, problems: ParallelProblem[]): string | null {
const branches = outgoing(workflow, forkId);
const reachablePerBranch: Set<string>[] = [];
let anyBranchReachesEnd = false;
for (const branch of branches) {
const reachable = new Set<string>();
const queue: string[] = [branch.target];
while (queue.length > 0) {
const current = queue.shift() as string;
if (reachable.has(current)) {
continue;
}
reachable.add(current);
if (isEndNode(workflow, current)) {
anyBranchReachesEnd = true;
}
for (const out of outgoing(workflow, current)) {
queue.push(out.target);
}
}
reachablePerBranch.push(reachable);
}
// Common = nodes reachable from ALL branches.
let common = new Set<string>(reachablePerBranch[0]);
for (let i = 1; i < reachablePerBranch.length; i++) {
common = new Set([...common].filter(n => reachablePerBranch[i].has(n)));
}
if (common.size === 0) {
problems.push({
code: anyBranchReachesEnd ? 'PARALLEL_BRANCH_REACHES_END' : 'FORK_WITHOUT_JOIN',
nodeId: forkId,
});
return null;
}
// Earliest common node, using branch[0]'s BFS insertion order as the canonical ordering.
let join: string | null = null;
for (const candidate of reachablePerBranch[0]) {
if (common.has(candidate)) {
join = candidate;
break;
}
}
if (join === null) {
join = common.values().next().value as string;
}
// Balance: every branch must reach the join without first hitting END.
for (const branch of branches) {
if (reachesEndBeforeJoin(workflow, branch.target, join)) {
problems.push({ code: 'PARALLEL_BRANCH_REACHES_END', nodeId: forkId });
return null;
}
}
return join;
}
function reachesEndBeforeJoin(workflow: Workflow, start: string, join: string): boolean {
const visited = new Set<string>();
const queue: string[] = [start];
while (queue.length > 0) {
const current = queue.shift() as string;
if (current === join || visited.has(current)) {
continue;
}
visited.add(current);
if (isEndNode(workflow, current)) {
return true;
}
for (const out of outgoing(workflow, current)) {
queue.push(out.target);
}
}
return false;
}
- [ ] Step 4: Run the tests to verify they pass
Run: cd ui && npx vitest run src/simulation/parallelRegions.test.ts
Expected: PASS (all cases).
- [ ] Step 5: Typecheck
Run: cd ui && npx tsc --noEmit
Expected: PASS.
- [ ] Step 6: Commit
git add ui/src/simulation/parallelRegions.ts ui/src/simulation/parallelRegions.test.ts
git commit -m "feat(sim): shared fork/join structural analyzer (#88)"
Task 3: Validator — retire UNCONDITIONAL_MULTIPLE_EDGES, add structural parallel rules
Files:
- Modify: ui/src/validation/validateWorkflow.ts (remove the UNCONDITIONAL_MULTIPLE_EDGES block at
~lines 248–251; add a validateParallelStructure pass wired into validateWorkflow)
- Modify: ui/src/validation/validateWorkflow.test.ts (add parity tests)
- Modify: docs/user-guide/validation.md (remove the retired row; add the new codes)
- Test: ui/src/validation/validateWorkflow.test.ts
Interfaces:
- Consumes: analyzeParallelRegions and ParallelProblem from ../simulation/parallelRegions.ts
(Task 2).
- Produces: validateWorkflow now emits MIXED_FORK_EDGES, FORK_WITHOUT_JOIN,
PARALLEL_BRANCH_REACHES_END (all severity: 'error') and no longer emits
UNCONDITIONAL_MULTIPLE_EDGES.
- [ ] Step 1: Write the failing tests
Append to ui/src/validation/validateWorkflow.test.ts (uses the file's existing node/edge/
workflow/hasProblem helpers):
describe('parallel structure', () => {
// start -> f(fork) -> a, b ; a -> j, b -> j ; j -> end
function wellFormedForkJoin(): Workflow {
return workflow(
[node('start', 'start'), node('f', 'wait'), node('a', 'action'), node('b', 'action'),
node('j', 'wait'), node('end', 'end')],
[edge('s', 'start', 'f'), edge('fa', 'f', 'a'), edge('fb', 'f', 'b'),
edge('aj', 'a', 'j'), edge('bj', 'b', 'j'), edge('je', 'j', 'end')],
);
}
it('no longer emits the retired UNCONDITIONAL_MULTIPLE_EDGES warning', () => {
expect(hasProblem(validateWorkflow(wellFormedForkJoin()), 'UNCONDITIONAL_MULTIPLE_EDGES'))
.toBe(false);
});
it('a well-formed structured fork/join produces no parallel-structure errors', () => {
const problems = validateWorkflow(wellFormedForkJoin());
expect(hasProblem(problems, 'MIXED_FORK_EDGES')).toBe(false);
expect(hasProblem(problems, 'FORK_WITHOUT_JOIN')).toBe(false);
expect(hasProblem(problems, 'PARALLEL_BRANCH_REACHES_END')).toBe(false);
});
it('MIXED_FORK_EDGES when a node mixes unconditional and conditional edges', () => {
const w = workflow(
[node('start', 'start'), node('a', 'end'), node('b', 'end')],
[edge('ea', 'start', 'a'), edge('eb', 'start', 'b', { condition: 'context.x == 1' })],
);
expect(hasProblem(validateWorkflow(w), 'MIXED_FORK_EDGES')).toBe(true);
});
it('PARALLEL_BRANCH_REACHES_END when a branch can hit END before the join', () => {
const w = workflow(
[node('start', 'start'), node('a', 'action'), node('b', 'action'),
node('j', 'wait'), node('end', 'end')],
[edge('fa', 'start', 'a'), edge('fb', 'start', 'b'),
edge('aj', 'a', 'j'), edge('be', 'b', 'end'), edge('je', 'j', 'end')],
);
expect(hasProblem(validateWorkflow(w), 'PARALLEL_BRANCH_REACHES_END')).toBe(true);
});
it('skips parallel analysis when there are edge-reference errors', () => {
const w = workflow(
[node('start', 'start'), node('a', 'action'), node('end', 'end')],
[edge('fa', 'start', 'a'), edge('fb', 'start', 'nonexistent'), edge('ae', 'a', 'end')],
);
const problems = validateWorkflow(w);
expect(hasProblem(problems, 'INVALID_EDGE_TARGET')).toBe(true);
// With a dangling target the graph is unsafe to traverse; no parallel-structure errors are added.
expect(hasProblem(problems, 'FORK_WITHOUT_JOIN')).toBe(false);
expect(hasProblem(problems, 'PARALLEL_BRANCH_REACHES_END')).toBe(false);
});
});
- [ ] Step 2: Run the tests to verify they fail
Run: cd ui && npx vitest run src/validation/validateWorkflow.test.ts
Expected: FAIL — the MIXED_FORK_EDGES / PARALLEL_BRANCH_REACHES_END cases fail (codes not emitted
yet), and the "no longer emits" case fails (the warning is still emitted).
- [ ] Step 3: Remove the retired warning
In ui/src/validation/validateWorkflow.ts, delete the UNCONDITIONAL_MULTIPLE_EDGES block (currently
at ~lines 248–251):
const allUnconditional = outgoing.every(e => !e.condition || e.condition.trim() === '');
if (allUnconditional && defaults.length === 0) {
problems.push(problem('warning', 'UNCONDITIONAL_MULTIPLE_EDGES', 'Node has multiple outgoing edges with no conditions', sourceId));
}
Delete those four lines outright. Leave the surrounding MULTIPLE_DEFAULT_EDGES, NO_DEFAULT_EDGE,
and DUPLICATE_EDGE_PRIORITY checks intact.
- [ ] Step 4: Add the parallel-structure pass
At the top of ui/src/validation/validateWorkflow.ts, add the import:
Wire a new pass into validateWorkflow (2-space indentation to match the file):
export function validateWorkflow(workflow: Workflow): ValidationProblem[] {
const problems: ValidationProblem[] = [];
validateStructure(workflow, problems);
validateConnectivity(workflow, problems);
validateEdgeConditions(workflow, problems);
validateSemantics(workflow, problems);
validateParallelStructure(workflow, problems);
return problems;
}
Add the function (place it near the other validate* functions):
/**
* Surfaces structured-parallelism problems from the shared fork/join analyzer. Skipped when the graph
* already has edge-reference errors that would make traversal unsafe. Mirrors the Java
* {@code WorkflowValidator.validateParallelStructure}.
*/
function validateParallelStructure(workflow: Workflow, problems: ValidationProblem[]) {
const hasEdgeRefErrors = problems.some(p =>
p.code === 'INVALID_EDGE_SOURCE' || p.code === 'INVALID_EDGE_TARGET');
if (hasEdgeRefErrors) {
return;
}
const regions = analyzeParallelRegions(workflow);
for (const p of regions.problems) {
problems.push(problem('error', p.code, messageForParallelProblem(p.code), p.nodeId));
}
}
function messageForParallelProblem(code: string): string {
switch (code) {
case 'MIXED_FORK_EDGES':
return 'Node mixes unconditional (fork) edges with conditional/default edges; make all outgoing '
+ 'edges unconditional to fork, or add conditions/a default for exclusive choice';
case 'FORK_WITHOUT_JOIN':
return 'Parallel branches from this fork do not re-converge at a single join';
case 'PARALLEL_BRANCH_REACHES_END':
return 'A parallel branch can reach an end node without first joining';
default:
return 'Invalid parallel structure';
}
}
- [ ] Step 5: Update the validation docs
In docs/user-guide/validation.md, remove the UNCONDITIONAL_MULTIPLE_EDGES table row (line ~99) and
add rows for the three new codes in the same table (keep the existing column layout; do not reflow
table cells):
| `MIXED_FORK_EDGES` | Node mixes unconditional (fork) edges with conditional/default edges |
| `FORK_WITHOUT_JOIN` | Parallel branches from a fork do not re-converge at a single join |
| `PARALLEL_BRANCH_REACHES_END` | A parallel branch can reach an end node without first joining |
- [ ] Step 6: Run the tests to verify they pass
Run: cd ui && npx vitest run src/validation/validateWorkflow.test.ts
Expected: PASS (new cases green; the rest of the file unchanged and still green).
- [ ] Step 7: Typecheck
Run: cd ui && npx tsc --noEmit
Expected: PASS.
- [ ] Step 8: Commit
git add ui/src/validation/validateWorkflow.ts ui/src/validation/validateWorkflow.test.ts docs/user-guide/validation.md
git commit -m "feat(validation): retire UNCONDITIONAL_MULTIPLE_EDGES, add fork/join structural rules (#88)"
Task 4: Simulation — convert to the active-branch token model (linear parity)
Files:
- Modify: ui/src/simulation/simulate.ts (reshape SimState; rewrite the lifecycle over branch
tokens; no fork/join yet — a single "root" branch throughout)
- Test: ui/src/simulation/simulate.test.ts (existing tests must stay green; add branch-attribution
assertions)
Interfaces:
- Consumes: ActiveBranch from ../types/instance.ts (Task 1).
- Produces (for Task 5 and external consumers): SimState gains activeBranches: ActiveBranch[],
parkedBranchIds: string[], joinArrivals: Record<string, string[]>; keeps currentNodeId: string
and blockedOn?: { nodeId; kind } as derived back-compat fields. resumeSimulation gains an
optional 4th parameter nodeId?: string. Internal helpers Task 5 extends: moveBranch,
enterNode, quiesce, derive, runnableBranch, completeBranchEntry, recordOutputOnBranch.
Design note (parity): this mirrors the engine's token model with one branch. The engine keeps
currentNodeId as a derived accessor (sole node, else null); SimState.currentNodeId uses the same
idea but resolves to '' (empty) rather than null when zero/≥2 branches are active, so the existing
Phase 3-owned UI (SimulationPanel, WorkflowEditor) — which reads simState.currentNodeId as a
string and simState.blockedOn.nodeId/.kind — keeps compiling untouched. History entries store
the actual branchId ("root" for the root branch), matching the engine.
- [ ] Step 1: Add branch-attribution assertions to the existing test file
Append to ui/src/simulation/simulate.test.ts:
describe('active-branch model — linear parity', () => {
it('runs a linear flow on the root branch with attributed history', () => {
const wf = workflow(
[node('start', 'start'), node('act', 'action'), node('end', 'end')],
[edge('e1', 'start', 'act'), edge('e2', 'act', 'end')],
);
let state = startSimulation(wf, {});
expect(state.activeBranches).toEqual([{ branchId: 'root', nodeId: 'start' }]);
state = runSimulation(wf, state);
expect(state.status).toBe('blocked');
expect(state.currentNodeId).toBe('act');
expect(state.blockedOn?.nodeId).toBe('act');
expect(state.activeBranches).toEqual([{ branchId: 'root', nodeId: 'act' }]);
state = resumeSimulation(wf, state, { output: { done: true } });
state = runSimulation(wf, state);
expect(state.status).toBe('completed');
expect(state.history.every(h => h.branchId === 'root')).toBe(true);
});
});
Run: cd ui && npx vitest run src/simulation/simulate.test.ts
Expected: FAIL — activeBranches is undefined on the current single-cursor SimState.
- [ ] Step 2: Reshape
SimStateand add theActiveBranchimport
In ui/src/simulation/simulate.ts, change the instance import and the SimState interface:
Replace the SimState interface with:
/** The complete, serializable state of a simulation run. */
export interface SimState {
status: SimStatus;
/** Live branch positions (tokens). Replaces the single cursor; mirrors the engine. */
activeBranches: ActiveBranch[];
/**
* Derived, back-compat: the sole active node's id when exactly one branch is active, else `''`
* (empty) when zero or multiple branches are active. Keeps existing single-path consumers working.
*/
currentNodeId: string;
/** Branch ids currently parked on a blocking node, awaiting a mock (not runnable). */
parkedBranchIds: string[];
/** Arrived incoming-edge ids per join node, awaiting the remaining branches. */
joinArrivals: Record<string, string[]>;
/** The evolving instance context (start context plus merged mock outputs). */
context: Record<string, unknown>;
/** Nodes entered so far, in order (may contain repeats for loops / multiple branches). */
visitedNodeIds: string[];
/** History entries, shaped like a real {@code WorkflowInstance} history (branch-attributed). */
history: HistoryEntry[];
/** Last routing evaluation per edge id, for the canvas overlay. */
edgeEvaluations: Record<string, EdgeEvaluation>;
/** Derived, back-compat: the first parked branch's node/kind when `status === 'blocked'`. */
blockedOn?: { nodeId: string; kind: NodeType };
/** Set when `status === 'failed'`. */
error?: SimError;
/** Number of transitions taken across all branches, for the loop guard. */
transitions: number;
}
- [ ] Step 3: Rewrite the lifecycle functions over branch tokens
Replace startSimulation, stepSimulation, runSimulation, and resumeSimulation with:
export function startSimulation(workflow: Workflow, context: Record<string, unknown>): SimState {
const startNode = workflow.nodes.find(n => n.type === 'start');
if (!startNode) {
return {
status: 'failed',
activeBranches: [],
currentNodeId: '',
parkedBranchIds: [],
joinArrivals: {},
context: { ...context },
visitedNodeIds: [],
history: [],
edgeEvaluations: {},
error: { message: 'No start node found' },
transitions: 0,
};
}
return {
status: 'running',
activeBranches: [{ branchId: 'root', nodeId: startNode.id }],
currentNodeId: startNode.id,
parkedBranchIds: [],
joinArrivals: {},
context: { ...context },
visitedNodeIds: [startNode.id],
history: [{ ...enterEntry(startNode), branchId: 'root' }],
edgeEvaluations: {},
transitions: 0,
};
}
/**
* Advances the simulation by a single step: picks the first runnable branch and resolves its outgoing
* edge, moving it to (and entering) the target. When no branch is runnable the state quiesces to a
* blocked/terminal status. A no-op when the simulation is not `running`.
*/
export function stepSimulation(workflow: Workflow, state: SimState): SimState {
if (state.status !== 'running') {
return state;
}
if (state.transitions >= MAX_TRANSITIONS) {
return derive(workflow, fail(state, {
message: `Exceeded transition limit (${MAX_TRANSITIONS}) — possible infinite loop`,
nodeId: state.currentNodeId || undefined,
}));
}
const branch = runnableBranch(workflow, state);
if (!branch) {
return quiesce(workflow, state);
}
const node = findNode(workflow, branch.nodeId);
if (!node) {
return derive(workflow, fail(state, {
message: `Current node not found: ${branch.nodeId}`, nodeId: branch.nodeId,
}));
}
// Resolve the single outgoing edge (fork handling is added in Task 5).
let selection: EdgeSelection;
try {
selection = selectEdge(workflow, node, { context: state.context });
} catch (e) {
if (e instanceof EdgeConditionError) {
return derive(workflow, {
...fail(state, { message: e.message, nodeId: node.id, edgeId: e.edgeId }),
edgeEvaluations: mergeEvaluations(state.edgeEvaluations, e.evaluations),
});
}
throw e;
}
const edgeEvaluations = mergeEvaluations(state.edgeEvaluations, selection.evaluations);
if (!selection.edge) {
return derive(workflow, {
...fail(state, {
message: `No matching outgoing edge from node: ${node.name || node.id}`,
nodeId: node.id,
}),
edgeEvaluations,
});
}
const history = completeBranchEntry(state.history, branch.branchId, node.id);
const moved = moveBranch(
workflow,
{ ...state, history, edgeEvaluations, transitions: state.transitions + 1 },
branch.branchId,
selection.edge,
);
return quiesce(workflow, moved);
}
/**
* Runs the simulation forward until it blocks, completes, or fails. A no-op when not `running`.
*/
export function runSimulation(workflow: Workflow, state: SimState): SimState {
let current = state;
while (current.status === 'running') {
const next = stepSimulation(workflow, current);
if (next === current) {
break;
}
current = next;
}
return current;
}
/**
* Delivers a mock output/event to a parked (blocking) branch, merges any output into context (as a
* real node would), and marks that branch runnable so the next step routes it onward. When `nodeId`
* is given the matching parked branch is targeted; otherwise the first parked branch is resumed. A
* no-op unless the simulation is `blocked`.
*/
export function resumeSimulation(
workflow: Workflow,
state: SimState,
mock: SimMock,
nodeId?: string,
): SimState {
if (state.status !== 'blocked') {
return state;
}
const parked = state.activeBranches.filter(b => state.parkedBranchIds.includes(b.branchId));
const target = nodeId ? parked.find(b => b.nodeId === nodeId) : parked[0];
if (!target) {
return state;
}
const output = mock.output ?? {};
const context = { ...state.context, ...output };
const history = recordOutputOnBranch(state.history, target.branchId, target.nodeId, output);
const parkedBranchIds = state.parkedBranchIds.filter(id => id !== target.branchId);
return derive(workflow, { ...state, status: 'running', context, history, parkedBranchIds });
}
- [ ] Step 4: Add the token-model internals and update
enterNode
Replace the existing enterNode and add the new helpers below it. Remove the now-unused
completeLast and recordOutputOnLast helpers (replaced by the branch-aware versions):
/** @returns the first branch that is runnable (not parked, not sitting on an end node), or undefined. */
function runnableBranch(workflow: Workflow, state: SimState): ActiveBranch | undefined {
return state.activeBranches.find(b =>
!state.parkedBranchIds.includes(b.branchId) && findNode(workflow, b.nodeId)?.type !== 'end');
}
/**
* Moves a branch across a single edge to its target and enters it. (Join synchronization is added in
* Task 5.)
*/
function moveBranch(
workflow: Workflow,
state: SimState,
branchId: string,
edge: WorkflowEdge,
): SimState {
const target = findNode(workflow, edge.target);
if (!target) {
return fail(state, { message: `Edge target not found: ${edge.target}`, edgeId: edge.id });
}
const activeBranches = state.activeBranches
.filter(b => b.branchId !== branchId)
.concat({ branchId, nodeId: target.id });
return enterNode({ ...state, activeBranches }, workflow, branchId, target, edge);
}
/** Applies the type-specific behavior when a branch enters a node, recording branch-attributed history. */
function enterNode(
state: SimState,
workflow: Workflow,
branchId: string,
node: WorkflowNode,
viaEdge?: WorkflowEdge,
): SimState {
void workflow;
const history = state.history.concat({ ...enterEntry(node, viaEdge), branchId });
const visitedNodeIds = [...state.visitedNodeIds, node.id];
const base: SimState = { ...state, history, visitedNodeIds };
if (node.type === 'end') {
// END completes the whole run and cancels siblings. Keep the end node as the terminal
// `currentNodeId` (branches are cleared, so `derive` preserves it for terminal states).
return {
...base,
status: 'completed',
currentNodeId: node.id,
history: completeBranchEntry(base.history, branchId, node.id),
activeBranches: [],
parkedBranchIds: [],
joinArrivals: {},
};
}
if (node.type === 'start') {
return fail(base, { message: 'Cannot transition to a start node', nodeId: node.id });
}
if (BLOCKING_KINDS.has(node.type)) {
return { ...base, parkedBranchIds: [...base.parkedBranchIds, branchId] };
}
// 'wait' — routable in the simulation; the branch stays runnable and the next step routes it.
return base;
}
/**
* Derives the instance status once no branch is runnable: `blocked` when a branch is parked, a
* defensive `failed` on an empty non-terminal state, otherwise `running`. Always recomputes the
* derived `currentNodeId`/`blockedOn`.
*/
function quiesce(workflow: Workflow, state: SimState): SimState {
if (state.status !== 'running') {
return derive(workflow, state);
}
if (runnableBranch(workflow, state)) {
return derive(workflow, state);
}
if (state.activeBranches.length === 0) {
return derive(workflow, fail(state, {
message: 'No active branches and the simulation did not complete (parallel deadlock)',
}));
}
return derive(workflow, { ...state, status: 'blocked' });
}
/**
* Recomputes the derived back-compat fields (`currentNodeId`, `blockedOn`) from the branch set. During
* a live run (`running`/`blocked`) `currentNodeId` is the sole active node or `''` (when 0 or ≥2
* branches are active). At a terminal state (`completed`/`failed`) the branch set is empty, so the
* terminal/failing node id already on the state is preserved (existing tests assert it).
*/
function derive(workflow: Workflow, state: SimState): SimState {
let currentNodeId = state.currentNodeId;
if (state.status === 'running' || state.status === 'blocked') {
currentNodeId = state.activeBranches.length === 1 ? state.activeBranches[0].nodeId : '';
}
let blockedOn: SimState['blockedOn'];
if (state.status === 'blocked') {
const firstParked = state.activeBranches.find(b => state.parkedBranchIds.includes(b.branchId));
const node = firstParked ? findNode(workflow, firstParked.nodeId) : undefined;
if (node) {
blockedOn = { nodeId: node.id, kind: node.type };
}
}
return { ...state, currentNodeId, blockedOn };
}
/** Marks the open (not-yet-completed) history entry for `(branchId, nodeId)` completed. */
function completeBranchEntry(history: HistoryEntry[], branchId: string, nodeId: string): HistoryEntry[] {
const copy = history.slice();
for (let i = copy.length - 1; i >= 0; i--) {
const e = copy[i];
if (e.branchId === branchId && e.nodeId === nodeId && !e.completedOn) {
copy[i] = { ...e, completedOn: new Date().toISOString() };
return copy;
}
}
return copy;
}
/** Records the produced output on the open history entry for `(branchId, nodeId)`. */
function recordOutputOnBranch(
history: HistoryEntry[],
branchId: string,
nodeId: string,
output: Record<string, unknown>,
): HistoryEntry[] {
const copy = history.slice();
for (let i = copy.length - 1; i >= 0; i--) {
const e = copy[i];
if (e.branchId === branchId && e.nodeId === nodeId && !e.completedOn) {
copy[i] = { ...e, output: { ...(e.output ?? {}), ...output } };
return copy;
}
}
return copy;
}
Note: enterEntry, findNode, fail, mergeEvaluations, selectEdge, EdgeSelection, and
EdgeConditionError are unchanged and remain in the file.
- [ ] Step 5: Run the full simulation suite
Run: cd ui && npx vitest run src/simulation/simulate.test.ts
Expected: PASS — all pre-existing single-cursor tests still pass (they assert currentNodeId /
blockedOn, both preserved), plus the new branch-attribution test.
- [ ] Step 6: Typecheck
Run: cd ui && npx tsc --noEmit
Expected: PASS (the Phase 3-owned SimulationPanel/WorkflowEditor still compile against the derived
currentNodeId/blockedOn).
- [ ] Step 7: Commit
git add ui/src/simulation/simulate.ts ui/src/simulation/simulate.test.ts
git commit -m "refactor(sim): active-branch token model (linear parity) (#88)"
Task 5: Simulation — fork fan-out and AND-join synchronization
Files:
- Modify: ui/src/simulation/simulate.ts (add fork handling in stepSimulation; add join handling in
moveBranch; thread the analysis through)
- Test: ui/src/simulation/simulate.test.ts (parallel behavior mirroring WorkflowEngineParallelTest)
Interfaces:
- Consumes: analyzeParallelRegions / ParallelAnalysis from ./parallelRegions.ts (Task 2);
moveBranch, enterNode, quiesce, completeBranchEntry from Task 4.
- Produces: fork nodes fan out into child branches ("<parent>.<i>"); joins wait for all incoming
edges then continue as "<joinNodeId>#join".
- [ ] Step 1: Write the failing parallel tests
Append to ui/src/simulation/simulate.test.ts:
describe('fork / AND-join', () => {
// start -> f(fork) -> a, b ; a -> j, b -> j ; j -> end. a and b are actions (block for a mock).
function forkJoin(): Workflow {
return workflow(
[node('start', 'start'), node('f', 'wait'), node('a', 'action'), node('b', 'action'),
node('j', 'wait'), node('end', 'end')],
[edge('s', 'start', 'f'), edge('fa', 'f', 'a'), edge('fb', 'f', 'b'),
edge('aj', 'a', 'j'), edge('bj', 'b', 'j'), edge('je', 'j', 'end')],
);
}
it('fans out into both branches and parks each on its action', () => {
const state = runSimulation(forkJoin(), startSimulation(forkJoin(), {}));
expect(state.status).toBe('blocked');
const parkedNodes = state.activeBranches
.filter(b => state.parkedBranchIds.includes(b.branchId))
.map(b => b.nodeId)
.sort();
expect(parkedNodes).toEqual(['a', 'b']);
expect(state.currentNodeId).toBe(''); // two active branches -> no single current node
});
it('waits for all branches, then fires the join once and completes', () => {
const wf = forkJoin();
let state = runSimulation(wf, startSimulation(wf, {}));
// resume branch at 'a' only -> join must NOT fire yet; sibling 'b' still blocked
state = resumeSimulation(wf, state, { output: { fromA: 1 } }, 'a');
state = runSimulation(wf, state);
expect(state.status).toBe('blocked');
expect(state.visitedNodeIds.filter(id => id === 'j')).toHaveLength(0);
// resume branch at 'b' -> all arrived -> join fires once -> end
state = resumeSimulation(wf, state, { output: { fromB: 2 } }, 'b');
state = runSimulation(wf, state);
expect(state.status).toBe('completed');
expect(state.visitedNodeIds.filter(id => id === 'j')).toHaveLength(1);
expect(state.context).toMatchObject({ fromA: 1, fromB: 2 });
});
it('attributes branch history to distinct child branch ids', () => {
const wf = forkJoin();
const state = runSimulation(wf, startSimulation(wf, {}));
const branchIds = new Set(state.history.map(h => h.branchId));
expect(branchIds.has('root.0')).toBe(true);
expect(branchIds.has('root.1')).toBe(true);
});
it('fails the whole simulation when one branch cannot route', () => {
// branch 'b' leads to a node with a condition that never matches and no default -> no edge.
const wf = workflow(
[node('start', 'start'), node('f', 'wait'), node('a', 'wait'), node('b', 'wait'),
node('j', 'wait'), node('dead', 'wait'), node('end', 'end')],
[edge('s', 'start', 'f'), edge('fa', 'f', 'a'), edge('fb', 'f', 'b'),
edge('aj', 'a', 'j'), edge('bd', 'b', 'dead', { condition: 'context.never == true' }),
edge('je', 'j', 'end')],
);
const state = runSimulation(wf, startSimulation(wf, {}));
expect(state.status).toBe('failed');
expect(state.error?.message).toContain('No matching outgoing edge');
});
it('handles a nested fork/join and completes once', () => {
// start -> f -> a, g(inner fork) ; g -> c, d ; c -> ij, d -> ij ; ij -> j ; a -> j ; j -> end
const wf = workflow(
[node('start', 'start'), node('f', 'wait'), node('a', 'wait'), node('g', 'wait'),
node('c', 'wait'), node('d', 'wait'), node('ij', 'wait'), node('j', 'wait'),
node('end', 'end')],
[edge('s', 'start', 'f'), edge('fa', 'f', 'a'), edge('fg', 'f', 'g'),
edge('gc', 'g', 'c'), edge('gd', 'g', 'd'), edge('cij', 'c', 'ij'), edge('dij', 'd', 'ij'),
edge('ijj', 'ij', 'j'), edge('aj', 'a', 'j'), edge('je', 'j', 'end')],
);
const state = runSimulation(wf, startSimulation(wf, {}));
expect(state.status).toBe('completed');
expect(state.visitedNodeIds.filter(id => id === 'j')).toHaveLength(1);
expect(state.visitedNodeIds.filter(id => id === 'ij')).toHaveLength(1);
});
});
Run: cd ui && npx vitest run src/simulation/simulate.test.ts
Expected: FAIL — forks are not yet handled (a fork node currently routes to the first edge only via
selectEdge, so both-branch fan-out and join synchronization don't occur).
- [ ] Step 2: Add the analyzer import
At the top of ui/src/simulation/simulate.ts:
- [ ] Step 3: Add fork fan-out to
stepSimulation
In stepSimulation, immediately after the if (!node) { ... } guard and before the
selectEdge call, insert the fork branch. Then thread regions into the sequential moveBranch call
too:
const regions = analyzeParallelRegions(workflow);
// Fork: retire this branch and fan out one child branch per outgoing edge.
if (regions.isFork(node.id)) {
const forkEdges = workflow.edges.filter(e => e.source === node.id);
const forkEvals: EdgeEvaluation[] = forkEdges.map(e => ({
edgeId: e.id, condition: e.condition, isDefault: false, result: 'matched',
}));
let next: SimState = {
...state,
history: completeBranchEntry(state.history, branch.branchId, node.id),
edgeEvaluations: mergeEvaluations(state.edgeEvaluations, forkEvals),
transitions: state.transitions + 1,
activeBranches: state.activeBranches.filter(b => b.branchId !== branch.branchId),
};
let childIndex = 0;
for (const edge of forkEdges) {
const childBranchId = `${branch.branchId}.${childIndex++}`;
next = { ...next, activeBranches: [...next.activeBranches, { branchId: childBranchId, nodeId: node.id }] };
next = moveBranch(workflow, next, childBranchId, edge, regions);
if (next.status !== 'running') {
return derive(workflow, next); // END/failure inside a branch cancels the rest
}
}
return quiesce(workflow, next);
}
Then update the sequential moveBranch call at the end of stepSimulation to pass regions:
const moved = moveBranch(
workflow,
{ ...state, history, edgeEvaluations, transitions: state.transitions + 1 },
branch.branchId,
selection.edge,
regions,
);
- [ ] Step 4: Add join synchronization to
moveBranch
Change moveBranch's signature to accept regions and handle the join case:
function moveBranch(
workflow: Workflow,
state: SimState,
branchId: string,
edge: WorkflowEdge,
regions: ParallelAnalysis,
): SimState {
const target = findNode(workflow, edge.target);
if (!target) {
return fail(state, { message: `Edge target not found: ${edge.target}`, edgeId: edge.id });
}
if (regions.isJoin(target.id)) {
// Record arrival; retire the arriving branch.
const arrived = [...(state.joinArrivals[target.id] ?? []), edge.id];
const withArrival: SimState = {
...state,
joinArrivals: { ...state.joinArrivals, [target.id]: arrived },
activeBranches: state.activeBranches.filter(b => b.branchId !== branchId),
parkedBranchIds: state.parkedBranchIds.filter(id => id !== branchId),
};
const required = regions.incomingEdgeIds(target.id);
const arrivedSet = new Set(arrived);
const allArrived = [...required].every(id => arrivedSet.has(id));
if (!allArrived) {
return withArrival; // absorbed; wait for the remaining branches
}
// All branches converged: one continuing branch enters the join. Clear this join's arrivals
// so a legitimate loop-back to the same region starts clean.
const clearedArrivals = { ...withArrival.joinArrivals };
delete clearedArrivals[target.id];
const continuingId = `${target.id}#join`;
const fired: SimState = {
...withArrival,
joinArrivals: clearedArrivals,
activeBranches: [...withArrival.activeBranches, { branchId: continuingId, nodeId: target.id }],
};
return enterNode(fired, workflow, continuingId, target, edge);
}
// Sequential / fork-child arrival at a normal node.
const activeBranches = state.activeBranches
.filter(b => b.branchId !== branchId)
.concat({ branchId, nodeId: target.id });
return enterNode({ ...state, activeBranches }, workflow, branchId, target, edge);
}
- [ ] Step 5: Run the parallel suite
Run: cd ui && npx vitest run src/simulation/simulate.test.ts
Expected: PASS (fork/join cases green; linear cases from Task 4 still green).
- [ ] Step 6: Full verification
Run all of:
- cd ui && npx vitest run — Expected: PASS (whole UI suite).
- cd ui && npx tsc --noEmit — Expected: PASS.
- cd ui && npm run lint — Expected: PASS (no new lint errors).
- cd ui && npm run build — Expected: PASS (library build succeeds).
- [ ] Step 7: Commit
git add ui/src/simulation/simulate.ts ui/src/simulation/simulate.test.ts
git commit -m "feat(sim): fork fan-out and AND-join synchronization (#88)"
Self-Review
1. Spec coverage (Phase 2 scope, spec §"Phased delivery" 2 + §Validation + §"TS simulation"):
types/instance.tsactive branches +HistoryEntry.branchId→ Task 1. ✓- Shared TS fork→join analysis helper (parity with
ParallelRegions.java, emitting the three engine codes) → Task 2. ✓ validateWorkflow.tsretireUNCONDITIONAL_MULTIPLE_EDGES+ new structural rules via the shared helper + docs update → Task 3. ✓simulate.tstoken model: active-branch set, fork returns all edges, AND-join waits for all, blocking parks per-branch, END cancels siblings, fail-fast → Tasks 4 (model) + 5 (fork/join). ✓- Tests mirroring engine coverage (
simulate.test.ts,validateWorkflow.test.ts, plus analyzer tests) → Tasks 2, 3, 5. Engine cases covered: fork activates all, join waits+fires once, fail-fast, branch parks while sibling proceeds, nested fork/join, branch-attributed history, resume-by-node. ✓ - Out of scope for Phase 2 (deferred to Phase 3–5, correctly excluded):
WorkflowViewermulti-active,SimulationPanel/WorkflowEditorUI,nodeHistorygrouping, docs beyond the retired code, worked CVE example. The plan keeps those files compiling via derivedcurrentNodeId/blockedOnrather than modifying them. ✓ - Deliberately not emitted (parity with Phase 1):
UNBALANCED_PARALLEL,CROSSING_PARALLEL_REGIONS,PARALLEL_REGION_CYCLE. Documented in Global Constraints. ✓
2. Placeholder scan: No TBD/TODO/"handle edge cases"/"similar to Task N". Every code and test
step contains complete, runnable content. ✓
3. Type consistency: ActiveBranch defined in Task 1, imported by Tasks 4/5. SimState fields
(activeBranches, parkedBranchIds, joinArrivals, derived currentNodeId/blockedOn) are
consistent across Tasks 4 and 5. analyzeParallelRegions/ParallelAnalysis/ParallelProblem
signatures are identical where produced (Task 2) and consumed (Tasks 3, 5). moveBranch gains its
regions: ParallelAnalysis parameter in Task 5 and every call site is updated in the same task.
completeBranchEntry/recordOutputOnBranch match (history, branchId, nodeId[, output]) at all uses.
Branch-id scheme ("root", "<parent>.<i>", "<join>#join") is used identically in engine and
plan. ✓