Skip to content

03 · Parallel Work (Fork, Join & Modes) ​

Several things happen at once, then the flow waits for enough of them to finish. A fork activates all its targets in parallel; a join activates automatically once its requires reach the threshold set by mode. This models peer review where a decision can be made as soon as two of three reviewers report back.

Workflow diagram ​

Features shown: addFork, addJoin, join mode ('all' / 'any' / a quorum number), the two-state auto-complete pattern, state-aware guards (Guard.stateCompleted, Guard.or), getStateStatus.

Code ​

ts
import { z } from 'zod';
import { createWorkflow, Guard, StateStatus } from 'flowyd';
import { MermaidExporter } from 'flowyd/visualization';
ts
// ─── Action payloads ────────────────────────────────────────────────────────────

const AssignSchema = z.object({ handlingEditor: z.string() });

const ReviewSchema = z.object({
  reviewer: z.string(),
  verdict: z.enum(['accept', 'minor-revisions', 'major-revisions', 'reject']),
  score: z.number().int().min(1).max(5),
});

const DecisionSchema = z.object({ editor: z.string() });

// ─── Workflow ────────────────────────────────────────────────────────────────────
//
// Ordering rule: a fork's `targets` and a join's `requires` may only reference
// states already registered. So register the branch states first, THEN the
// fork/join that points at them.

export const peerReview = createWorkflow({ name: 'peer-review' })
  .defineAction('ASSIGN', AssignSchema)
  .defineAction('SUBMIT_A', ReviewSchema)
  .defineAction('SUBMIT_B', ReviewSchema)
  .defineAction('SUBMIT_C', ReviewSchema)
  .defineAction('ACCEPT', DecisionSchema)
  .defineAction('REJECT', DecisionSchema)

  .addStep('submitted', { label: 'Submitted' })

  // "done" steps — registered before the join; auto-complete on entry.
  .addStep('a-done', { label: 'Review A In' })
  .addStep('b-done', { label: 'Review B In' })
  .addStep('c-done', { label: 'Review C In' })

  // "in-progress" steps — registered before the fork; the parallel branches.
  .addStep('reviewer-a', { label: 'Reviewer A' })
  .addStep('reviewer-b', { label: 'Reviewer B' })
  .addStep('reviewer-c', { label: 'Reviewer C' })

  .addFork('assign-reviewers', {
    label: 'Assign Reviewers',
    targets: ['reviewer-a', 'reviewer-b', 'reviewer-c'],
  })
  .addJoin('decision-ready', {
    label: 'Decision Ready',
    requires: ['a-done', 'b-done', 'c-done'],
    // Change to 'all' to wait for every reviewer, or 'any' for the first one.
    mode: 2, // quorum: a decision can be made once 2 of 3 reviews are in.
  })

  .addStep('accepted', { label: 'Accepted' })
  .addStep('rejected', { label: 'Rejected' })

  .setInitial('submitted')
  .setTerminal(['accepted', 'rejected'])

  .addTransition({ from: 'submitted', to: 'assign-reviewers', on: 'ASSIGN' })

  // Each reviewer reports independently; order doesn't matter.
  .addTransition({ from: 'reviewer-a', to: 'a-done', on: 'SUBMIT_A' })
  .addTransition({ from: 'reviewer-b', to: 'b-done', on: 'SUBMIT_B' })
  .addTransition({ from: 'reviewer-c', to: 'c-done', on: 'SUBMIT_C' })

  // A state-aware guard: only let the editor accept once a review is actually in.
  // (Trivially true here once the join is active, but shows `Guard.stateCompleted`
  // / `Guard.or` reading the live status of OTHER states — not the payload.)
  .addTransition({
    from: 'decision-ready',
    to: 'accepted',
    on: 'ACCEPT',
    guard: Guard.or([Guard.stateCompleted('a-done'), Guard.stateCompleted('b-done')]),
  })
  .addTransition({ from: 'decision-ready', to: 'rejected', on: 'REJECT' })

  .build();

// ─── Run ─────────────────────────────────────────────────────────────────────────

async function run() {
  console.log('=== Peer Review — State Diagram ===\n');
  console.log(MermaidExporter.export(peerReview.getDefinition()));

  console.log('\n=== Run: quorum of 2 — a decision before the 3rd review arrives ===\n');
  const paper = peerReview.createInstance('paper-flowyd-2026');

  await paper.dispatch('ASSIGN', { handlingEditor: 'dr-ng' });
  // The fork fanned out: all three reviewers are active at once.
  console.log(`after ASSIGN     → active: ${paper.getCurrentStates()}`);

  await paper.dispatch('SUBMIT_A', { reviewer: 'A', verdict: 'accept', score: 5 });
  console.log(`after review A   → active: ${paper.getCurrentStates()}`); // join not yet (1/2)

  await paper.dispatch('SUBMIT_B', { reviewer: 'B', verdict: 'minor-revisions', score: 4 });
  // Quorum (2) reached → the join activates automatically, no dispatch needed.
  console.log(`after review B   → active: ${paper.getCurrentStates()}`);

  // Reviewer C never reported — and didn't need to. Inspect any state directly:
  console.log(`  reviewer-c is still: ${paper.getStateStatus('reviewer-c')}`); // active
  console.log(`  c-done is still    : ${paper.getStateStatus('c-done')}`); // idle
  console.log(`  decision-ready is  : ${paper.getStateStatus('decision-ready')}`); // active

  const done = await paper.dispatch('ACCEPT', { editor: 'dr-ng' });
  console.log(`\nafter ACCEPT     → ${paper.getCurrentStates()}  terminal: ${paper.isTerminal()}`);
  if (done.success) console.log(`exited (completed) on accept: ${done.exitedStates}`);

  // Sanity check on the auto-completed done-states using the snapshot status map.
  const statuses = paper.getSnapshot().stateStatuses;
  const completed = Object.entries(statuses)
    .filter(([, s]) => s === StateStatus.Completed)
    .map(([id]) => id);
  console.log(`\nall completed states: ${completed.join(', ')}`);
}

What to notice ​

  • The join fires automatically. With mode: 2, decision-ready activates the instant the second review lands — no dispatch crosses the barrier, and the third reviewer is left untouched (still active). Switch mode to 'all' to wait for everyone, or 'any' for the first.
  • The two-state branch pattern. Each fork target (reviewer-a) leads to a done step (a-done) that has no outgoing transition and isn't terminal, so flowyd auto-completes it on entry — and that completion is what the join counts. Register the done states before the join, and the in-progress states before the fork.
  • State-aware guards read the live status of other states (Guard.stateCompleted), not the action payload.

Run it locally: npx tsx examples/03-parallel-fork-join.ts

Released under the MIT License.