Skip to content

Phase 09: Terminal placements

TerminalNode placements name the endpoints of a flow and carry an outcome declaration (completed or failed). Four patterns cover the common cases: an implicit null-route terminal, an explicit completed terminal, an explicit failed terminal, and scatter outputs routed directly to named terminals.

What it shows

  • Implicit terminal via null route. .node('step-a', stepA, { ok: null }). Routing an output to null is sugar for "this branch ends with outcome: completed." No explicit placement is required. Use this when the endpoint needs no name in the diagram.
  • Explicit completed terminal. .node(..., { ok: 'end' }).terminal('end'). Declares a named TerminalNode placement with the default outcome: 'completed'. The diagram shows end as a discrete node; the engine behavior is identical to a null route.
  • Explicit failed terminal. .terminal('end-fail', 'failed'). Two terminal placements, end-ok (completed) and end-fail (failed), wired from a check node. The DAG runs twice, once triggering each terminal, producing completed and failed lifecycle kinds respectively.
  • Embedded-DAG routing to named terminals. .embeddedDAG('run', 'child-for-terminals', { success: 'end-ok', error: 'end-fail' }). The parent registers named terminals and routes the embedded-DAG placement's success and error outputs directly to them. A child DAG that collects errors surfaces a failed lifecycle in the parent.

The code

ts
/**
 * 09-terminals: TerminalNode placements, explicit flow endpoints.
 *
 * A TerminalNode placement ends the flow when reached. The `outcome` field
 * declares whether the dispatcher marks the state `completed` or `failed`.
 * Four patterns are demonstrated:
 *
 *   1. Implicit terminal via null route: `.node('a', nodeA, { ok: null })`
 *      Route to null is sugar for "this branch ends with outcome=completed."
 *      No explicit TerminalNode placement is needed.
 *
 *   2. Explicit completed terminal: `.terminal('end')` (default outcome).
 *      The diagram shows 'end' as a discrete placement; the engine marks the
 *      state `completed` when it arrives there. Functionally identical to a
 *      null route; the value is in the diagram legibility.
 *
 *   3. Explicit failed terminal: `.terminal('end-fail', 'failed')`.
 *      Two terminals: `end-ok` (completed) and `end-fail` (failed). A check
 *      node routes to one depending on state. The DAG runs twice, once
 *      triggering `end-ok` and once triggering `end-fail`, and the lifecycle
 *      kind is printed for each run.
 *
 *   4. EmbeddedDAGNode routing to explicit terminals: `.embeddedDAG('run',
 *      'child', { success: 'end-ok', error: 'end-fail' })`. A child
 *      DAG's success/error outputs route to the parent's named terminals.
 *      A child with errors routes to `end-fail` and state becomes `failed`.
 *
 * DAG definitions (state, nodes, dag1-dag4, childDAG): examples/dags/09-terminals.ts
 *
 * Run: npx tsx examples/09-terminals.ts
 */

import { Dagonizer } from '@noocodex/dagonizer';
import {
  S,
  stepA,
  checkNode,
  childWork,
  childDAG,
  dag1,
  dag2,
  dag3,
  dag4,
} from './dags/09-terminals.js';

// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------

// ── Pattern 1 ─────────────────────────────────────────────────────────────
{
  const dispatcher = new Dagonizer<S>();
  dispatcher.registerNode(stepA);
  dispatcher.registerDAG(dag1);

  const state = new S();
  const result = await dispatcher.execute('demo-null-route', state);
  process.stdout.write('\nPattern 1: null route (implicit terminal):\n');
  process.stdout.write(`  lifecycle.kind = ${result.state.lifecycle.kind}\n`);
  // → completed
}

// ── Pattern 2 ─────────────────────────────────────────────────────────────
{
  const dispatcher = new Dagonizer<S>();
  dispatcher.registerNode(stepA);
  dispatcher.registerDAG(dag2);

  const state = new S();
  const result = await dispatcher.execute('demo-explicit-completed', state);
  process.stdout.write('\nPattern 2: explicit completed terminal:\n');
  process.stdout.write(`  lifecycle.kind = ${result.state.lifecycle.kind}\n`);
  // → completed
}

// ── Pattern 3a: routes to end-ok ──────────────────────────────────────────
{
  const dispatcher = new Dagonizer<S>();
  dispatcher.registerNode(checkNode);
  dispatcher.registerDAG(dag3);

  const statePass = new S();
  statePass.shouldPass = true;
  const resultPass = await dispatcher.execute('demo-explicit-terminals', statePass);
  process.stdout.write('\nPattern 3a: check node routes to end-ok:\n');
  process.stdout.write(`  lifecycle.kind = ${resultPass.state.lifecycle.kind}\n`);
  // → completed
}

// ── Pattern 3b: routes to end-fail ────────────────────────────────────────
{
  const dispatcher = new Dagonizer<S>();
  dispatcher.registerNode(checkNode);
  dispatcher.registerDAG(dag3);

  const stateFail = new S();
  stateFail.shouldPass = false;
  const resultFail = await dispatcher.execute('demo-explicit-terminals', stateFail);
  process.stdout.write('\nPattern 3b: check node routes to end-fail:\n');
  process.stdout.write(`  lifecycle.kind = ${resultFail.state.lifecycle.kind}\n`);
  // → failed
}

// ── Pattern 4a: child succeeds -> end-ok ──────────────────────────────────
{
  const dispatcher = new Dagonizer<S>();
  dispatcher.registerNode(childWork);
  dispatcher.registerDAG(childDAG);
  dispatcher.registerDAG(dag4);

  const stateOk = new S();
  stateOk.shouldPass = true;
  const resultOk = await dispatcher.execute('demo-embedded-dag-terminals', stateOk);
  process.stdout.write('\nPattern 4a: scatter child DAG succeeds -> end-ok:\n');
  process.stdout.write(`  lifecycle.kind = ${resultOk.state.lifecycle.kind}\n`);
  // → completed
}

// ── Pattern 4b: child errors -> end-fail ──────────────────────────────────
{
  const dispatcher = new Dagonizer<S>();
  dispatcher.registerNode(childWork);
  dispatcher.registerDAG(childDAG);
  dispatcher.registerDAG(dag4);

  const stateErr = new S();
  stateErr.shouldPass = false;
  const resultErr = await dispatcher.execute('demo-embedded-dag-terminals', stateErr);
  process.stdout.write('\nPattern 4b: scatter child DAG errors -> end-fail:\n');
  process.stdout.write(`  lifecycle.kind = ${resultErr.state.lifecycle.kind}\n`);
  // → failed
}

Walkthrough

Pattern 1: null route

ts
export const dag1 = new DAGBuilder('demo-null-route', '1')
  .node('step-a', stepA, { 'ok': null })
  .build();

null in the routes map ends the flow. The lifecycle resolves to completed by default. This is the shortest form, sufficient when the endpoint has no semantic meaning beyond "done."

Pattern 2: explicit completed terminal

ts
export const dag2 = new DAGBuilder('demo-explicit-completed', '1')
  .node('step-a', stepA, { 'ok': 'end' })
  .terminal('end')  // outcome defaults to 'completed'
  .build();

.terminal('end') emits a TerminalNode placement with outcome: 'completed'. The outcome is identical to the null route in pattern 1. The reason to prefer this form is diagram clarity: the rendered diagram shows end as a named terminus rather than an implicit edge-to-nowhere. Worth the extra line when the endpoint name carries meaning (end-ok, response-sent, workflow-complete).

Pattern 3: explicit failed terminal

ts
export const dag3 = new DAGBuilder('demo-explicit-terminals', '1')
  .node('check', checkNode, { 'pass': 'end-ok', 'fail': 'end-fail' })
  .terminal('end-ok')
  .terminal('end-fail', 'failed')
  .build();

terminal('end-fail', 'failed') produces a placement with outcome: 'failed'. When the engine reaches it, the state lifecycle transitions to failed before the flow resolves. The author does not need to call state.markFailed() inside any node; the placement itself carries the outcome declaration.

Running the DAG twice with state.shouldPass = true and false produces:

Pattern 3a: check node routes to end-ok
  lifecycle.kind = completed

Pattern 3b: check node routes to end-fail
  lifecycle.kind = failed
Loading graph…

Use this pattern when a named path through the flow has a known semantic outcome: a validation gate that declares the flow as failed rather than silently completing, a circuit-breaker endpoint, an explicit error branch.

Pattern 4: embedded-DAG routing to named terminals

ts
// Child DAG literal: routes 'done' to a TerminalNode (well-formed).
export const childDAG: DAG = {
  '@context':  DAG_CONTEXT,
  '@id':       'urn:noocodex:dag:child-for-terminals',
  '@type':     'DAG',
  "name":      'child-for-terminals',
  "version":   '1',
  "entrypoint": 'child-work',
  "nodes": [
    {
      '@id':    'urn:noocodex:dag:child-for-terminals/node/child-work',
      '@type':  'SingleNode',
      "name":   'child-work',
      "node":   'child-work',
      "outputs": { "done": 'child-end' },
    },
    {
      '@id':     'urn:noocodex:dag:child-for-terminals/node/child-end',
      '@type':   'TerminalNode',
      "name":    'child-end',
      "outcome": 'completed',
    },
  ],
};

export const dag4 = new DAGBuilder('demo-embedded-dag-terminals', '1')
  .embeddedDAG<S, S>('run', 'child-for-terminals', {
    'success': 'end-ok',
    'error':   'end-fail',
  }, {
    // Seed the child's shouldPass from parent state before the child DAG runs.
    'inputs': { 'shouldPass': 'shouldPass' },
  })
  .terminal('end-ok')
  .terminal('end-fail', 'failed')
  .build();

The EmbeddedDAGNode placement's error output routes to the parent's end-fail terminal. When the child DAG accumulates errors (via state.collectError), the terminal reducer routes error, which arrives at end-fail, which marks the parent flow failed.

Without a named terminal, routing an embedded-DAG error output to null would silently complete the flow: an error in the child had no effect on the parent lifecycle unless the author added a dedicated SingleNode whose sole purpose was to call state.markFailed(). The named terminal collapses that pattern to one .terminal(name, 'failed') call.

Running the DAG twice:

Pattern 4a: scatter child succeeds, end-ok
  lifecycle.kind = completed

Pattern 4b: scatter child errors, end-fail
  lifecycle.kind = failed
Loading graph…

Watched over by the Order of Dagon.