Skip to content

Example 30: Progress Events

What It Is

Progress Events are application-facing projections of the Dagonizer lifecycle. The Dispatcher browser demo turns runtime hooks into trace rows, active-node highlights, completed edges, and error markers beside the live DAG.

This page is the UI layer on top of Example 18: Observability: hooks report what happened; progress events decide how the application displays or transports it.

How It Works

The observer layer receives lifecycle callbacks from the dispatcher and projects them into trace records plus graph state. The DAG remains unchanged; progress is a read-side projection of execution events. Multiple UI panes can consume the same event stream without adding progress nodes to the workflow.

That separation keeps progress cheap to add. A browser trace, CLI spinner, log sink, or server-sent events endpoint can all subscribe to the same lifecycle-derived stream.

Diagrams, Examples, and Outputs

DAG registration and diagram

The graph is a normal pipeline; progress events are emitted by the runtime observer while these placements execute. The Dispatcher owns the smallest in-browser version through its trace panel.

support-dispatcher progress DAG

7 placements
DAG JSON-LD registered with the dispatcher
{
  "@context": {
    "@version": 1.1,
    "name": {
      "@id": "https://noocodec.dev/ontology/dag/name"
    },
    "version": {
      "@id": "https://noocodec.dev/ontology/dag/version"
    },
    "entrypoints": {
      "@id": "https://noocodec.dev/ontology/dag/entrypoints",
      "@container": "@index"
    },
    "nodes": {
      "@id": "https://noocodec.dev/ontology/dag/nodes",
      "@container": "@set"
    },
    "outputs": {
      "@id": "https://noocodec.dev/ontology/dag/outputs"
    },
    "node": {
      "@id": "https://noocodec.dev/ontology/dag/node"
    },
    "dag": {
      "@id": "https://noocodec.dev/ontology/dag/dag"
    },
    "body": {
      "@id": "https://noocodec.dev/ontology/dag/body"
    },
    "source": {
      "@id": "https://noocodec.dev/ontology/dag/source"
    },
    "sources": {
      "@id": "https://noocodec.dev/ontology/dag/sources",
      "@container": "@index"
    },
    "itemKey": {
      "@id": "https://noocodec.dev/ontology/dag/itemKey"
    },
    "execution": {
      "@id": "https://noocodec.dev/ontology/dag/execution"
    },
    "concurrency": {
      "@id": "https://noocodec.dev/ontology/dag/concurrency"
    },
    "throttle": {
      "@id": "https://noocodec.dev/ontology/dag/throttle"
    },
    "reservoir": {
      "@id": "https://noocodec.dev/ontology/dag/reservoir"
    },
    "gather": {
      "@id": "https://noocodec.dev/ontology/dag/gather"
    },
    "dagReference": {
      "@id": "https://noocodec.dev/ontology/dag/dagReference",
      "@type": "@id"
    },
    "DagReference": {
      "@id": "https://noocodec.dev/ontology/dag/DagReference"
    },
    "from": {
      "@id": "https://noocodec.dev/ontology/dag/from"
    },
    "path": {
      "@id": "https://noocodec.dev/ontology/dag/path"
    },
    "candidates": {
      "@id": "https://noocodec.dev/ontology/dag/candidates",
      "@container": "@set"
    },
    "candidateDag": {
      "@id": "https://noocodec.dev/ontology/dag/candidateDag",
      "@type": "@id"
    },
    "selectedDag": {
      "@id": "https://noocodec.dev/ontology/dag/selectedDag",
      "@type": "@id"
    },
    "resultField": {
      "@id": "https://noocodec.dev/ontology/dag/resultField"
    },
    "policy": {
      "@id": "https://noocodec.dev/ontology/dag/policy"
    },
    "reducer": {
      "@id": "https://noocodec.dev/ontology/dag/reducer"
    },
    "outcome": {
      "@id": "https://noocodec.dev/ontology/dag/outcome"
    },
    "phase": {
      "@id": "https://noocodec.dev/ontology/dag/phase"
    },
    "stateMapping": {
      "@id": "https://noocodec.dev/ontology/dag/stateMapping"
    },
    "container": {
      "@id": "https://noocodec.dev/ontology/dag/container"
    },
    "DAG": {
      "@id": "https://noocodec.dev/ontology/dag/DAG"
    },
    "Placement": {
      "@id": "https://noocodec.dev/ontology/dag/Placement"
    },
    "SingleNode": {
      "@id": "https://noocodec.dev/ontology/dag/SingleNode"
    },
    "ScatterNode": {
      "@id": "https://noocodec.dev/ontology/dag/ScatterNode"
    },
    "EmbeddedDAGNode": {
      "@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
    },
    "GatherNode": {
      "@id": "https://noocodec.dev/ontology/dag/GatherNode"
    },
    "TerminalNode": {
      "@id": "https://noocodec.dev/ontology/dag/TerminalNode"
    },
    "PhaseNode": {
      "@id": "https://noocodec.dev/ontology/dag/PhaseNode"
    }
  },
  "@id": "urn:noocodec:dag:support-dispatcher",
  "@type": "DAG",
  "name": "dag:support-dispatcher",
  "version": "1",
  "entrypoints": {
    "main": "urn:noocodec:dag:support-dispatcher/node/classify-message"
  },
  "nodes": [
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/setup",
      "@type": "PhaseNode",
      "name": "dag:support-dispatcher/node/setup",
      "node": "urn:noocodec:node:dispatcher-setup",
      "phase": "pre"
    },
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/classify-message",
      "@type": "SingleNode",
      "name": "dag:support-dispatcher/node/classify-message",
      "node": "urn:noocodec:node:classify-message",
      "outputs": {
        "routine": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
        "escalate": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
        "off-topic": "urn:noocodec:dag:support-dispatcher/node/decline"
      }
    },
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/ai-compose",
      "@type": "SingleNode",
      "name": "dag:support-dispatcher/node/ai-compose",
      "node": "urn:noocodec:node:ai-compose",
      "outputs": {
        "drafted": "urn:noocodec:dag:support-dispatcher/node/send-response"
      }
    },
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/park-for-operator",
      "@type": "SingleNode",
      "name": "dag:support-dispatcher/node/park-for-operator",
      "node": "urn:noocodec:node:park-for-operator",
      "outputs": {
        "parked": "urn:noocodec:dag:support-dispatcher/node/end",
        "ready": "urn:noocodec:dag:support-dispatcher/node/send-response"
      }
    },
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/send-response",
      "@type": "SingleNode",
      "name": "dag:support-dispatcher/node/send-response",
      "node": "urn:noocodec:node:send-response",
      "outputs": {
        "sent": "urn:noocodec:dag:support-dispatcher/node/end"
      }
    },
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/decline",
      "@type": "SingleNode",
      "name": "dag:support-dispatcher/node/decline",
      "node": "urn:noocodec:node:decline",
      "outputs": {
        "declined": "urn:noocodec:dag:support-dispatcher/node/end"
      }
    },
    {
      "@id": "urn:noocodec:dag:support-dispatcher/node/end",
      "@type": "TerminalNode",
      "name": "dag:support-dispatcher/node/end",
      "outcome": "completed"
    }
  ]
}
Mermaid generated from the same DAG
Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
  %% dag:support-dispatcher (v1)
  entry_main(["main"])
  entry_main --> urn_noocodec_dag_support-dispatcher/node/classify-message
  urn_noocodec_dag_support-dispatcher/node/setup(["dag:support-dispatcher/node/setup (pre)"])
  urn_noocodec_dag_support-dispatcher/node/classify-message["dag:support-dispatcher/node/classify-message"]
  urn_noocodec_dag_support-dispatcher/node/classify-message -->|routine| urn_noocodec_dag_support-dispatcher/node/ai-compose
  urn_noocodec_dag_support-dispatcher/node/classify-message -->|escalate| urn_noocodec_dag_support-dispatcher/node/park-for-operator
  urn_noocodec_dag_support-dispatcher/node/classify-message -->|off-topic| urn_noocodec_dag_support-dispatcher/node/decline
  urn_noocodec_dag_support-dispatcher/node/ai-compose["dag:support-dispatcher/node/ai-compose"]
  urn_noocodec_dag_support-dispatcher/node/ai-compose -->|drafted| urn_noocodec_dag_support-dispatcher/node/send-response
  urn_noocodec_dag_support-dispatcher/node/park-for-operator["dag:support-dispatcher/node/park-for-operator"]
  urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|parked| urn_noocodec_dag_support-dispatcher/node/end
  urn_noocodec_dag_support-dispatcher/node/park-for-operator -->|ready| urn_noocodec_dag_support-dispatcher/node/send-response
  urn_noocodec_dag_support-dispatcher/node/send-response["dag:support-dispatcher/node/send-response"]
  urn_noocodec_dag_support-dispatcher/node/send-response -->|sent| urn_noocodec_dag_support-dispatcher/node/end
  urn_noocodec_dag_support-dispatcher/node/decline["dag:support-dispatcher/node/decline"]
  urn_noocodec_dag_support-dispatcher/node/decline -->|declined| urn_noocodec_dag_support-dispatcher/node/end
  urn_noocodec_dag_support-dispatcher/node/end((("dag:support-dispatcher/node/end")))

The browser demo translates lifecycle hooks into view-model events:

  • onNodeStart appends a start trace event and marks the DAG node active.
  • onNodeEnd appends an end trace event, marks the node completed, and flashes the traversed edge.
  • onError appends an error trace event and marks the node errored.

Run

bash
npm run docs:dev

What It Lets You Do

Progress events let applications turn DAG lifecycle hooks into product UI or transport updates. Use this when a live page, CLI, SSE endpoint, or log sink needs to show node-level progress while the DAG is still running.

They also keep progress out of business logic. Nodes do not emit UI events; the runtime reports lifecycle, and the runner decides how to render it.

Code Samples

The observer snippet shows lifecycle hooks becoming Dispatcher trace state. The DAG snippet is included to show that the graph itself does not contain progress-only nodes.

vue
/**
 * DispatcherBrowserObserver: wires lifecycle hooks to the Vue reactive state
 * (trace feed, DAG graph, conversation, awaiting-input tab auto-switch).
 */
class DispatcherBrowserObserver extends ObservedDag<DispatcherState> {
  readonly #services: DispatcherServices;

  constructor(log: DomConsoleLogger, services: DispatcherServices) {
    super(log);
    this.#services = services;
  }

  protected override onFlowStart(dagName: string, state: DispatcherState, signal: AbortSignal): void {
    super.onFlowStart(dagName, state, signal);
    void this.#services.llm.warm().catch(() => { /* best-effort: never block or fail a flow start */ });
  }

  protected override onNodeStart(
    nodeName: string,
    state: DispatcherState,
    placementPath: readonly string[],
    signal: AbortSignal,
  ): void {
    super.onNodeStart(nodeName, state, placementPath, signal);
    const fullId = [...placementPath, nodeName].join('/');
    trace.value = [...trace.value, { 'node': fullId, 'ts': Date.now(), 'variant': 'start' }];
    dagGraph.value?.setActive(fullId);
  }

  protected override onNodeEnd(
    nodeName: string,
    output: string | null,
    state: DispatcherState,
    placementPath: readonly string[],
    signal: AbortSignal,
  ): void {
    super.onNodeEnd(nodeName, output, state, placementPath, signal);
    const fullId = [...placementPath, nodeName].join('/');
    trace.value = [...trace.value, { 'node': fullId, output, 'ts': Date.now(), 'variant': 'end' }];
    dagGraph.value?.setCompleted(fullId);
    if (output !== null) dagGraph.value?.markEdgeTraversed(fullId, output);
    DISPATCHER_NODE_TRACE[nodeName]?.(state, output);
  }

  protected override onError(
    nodeName: string,
    error: Error,
    state: DispatcherState,
    placementPath: readonly string[],
    signal: AbortSignal,
  ): void {
    super.onError(nodeName, error, state, placementPath, signal);
    const fullId = [...placementPath, nodeName].join('/');
    trace.value = [...trace.value, {
      'node': fullId,
      'ts': Date.now(),
      'variant': 'error',
      'message': error.message !== '' ? error.message : String(error),
    }];
    dagGraph.value?.setErrored(fullId);
  }

  protected override onFlowEnd(
    dagName: string,
    state: DispatcherState,
    result: ExecutionResultType<DispatcherState>,
    signal: AbortSignal,
  ): void {
    super.onFlowEnd(dagName, state, result, signal);

    const lifecycleVariant = state.lifecycle.variant;

    // Update conversation from state.
    if (state.conversation.length > 0) {
      conversation.value = [...state.conversation];
    }

    escalationReason.value = state.escalationReason;
    terminalVariant.value = (
      lifecycleVariant === 'completed' ||
      lifecycleVariant === 'failed' ||
      lifecycleVariant === 'cancelled' ||
      lifecycleVariant === 'timed_out'
    ) ? lifecycleVariant : 'pending';

    if (lifecycleVariant === 'awaiting-input' && result.parked !== null) {
      // Store the full result outside Vue reactivity (preserves class identity).
      parkedExecution = { 'result': result, 'dagName': dagName, 'cursor': result.parked.cursor };
      // Expose only the cursor to Vue so the template can react.
      parked.value = { 'dagName': dagName, 'cursor': result.parked.cursor };
      // Auto-switch left pane to Operator tab.
      leftActiveKey.value = 'operator';
      logger.note(`parked at cursor: ${result.parked.cursor} · key: ${result.parked.correlationKey}`);
    } else {
      parkedExecution = null;
      parked.value = null;
    }

    logger.result(`lifecycle=${lifecycleVariant} · conversation=${String(state.conversation.length)} turns`);
  }
}

Details for Nerds

DAG definition

ts

const setup           = new PlaceholderNode<DispatcherState, 'ready'>('urn:noocodec:node:dispatcher-setup', ['ready']);
const classifyMessage = new PlaceholderNode<DispatcherState, 'routine' | 'escalate' | 'off-topic'>('urn:noocodec:node:classify-message', ['routine', 'escalate', 'off-topic']);
const aiCompose       = new PlaceholderNode<DispatcherState, 'drafted'>('urn:noocodec:node:ai-compose', ['drafted']);
const parkForOperator = new PlaceholderNode<DispatcherState, 'parked' | 'ready'>('urn:noocodec:node:park-for-operator', ['parked', 'ready']);
const sendResponse    = new PlaceholderNode<DispatcherState, 'sent'>('urn:noocodec:node:send-response', ['sent']);
const decline         = new PlaceholderNode<DispatcherState, 'declined'>('urn:noocodec:node:decline', ['declined']);

const supportDispatcherDagIri = 'urn:noocodec:dag:support-dispatcher' as const;
const placement = (placementIdentifier: string): string =>
  DAGIdentity.placementId(supportDispatcherDagIri, placementIdentifier);

export const supportDispatcherDAG: DAGType = new DAGBuilder(supportDispatcherDagIri, '1')
  // Pre-phase: stamps runId before the entrypoint runs.
  .phase(placement('setup'), 'pre', setup)

  // Entrypoint: classify the inbound message.
  .node(placement('classify-message'), classifyMessage, {
    'routine':   placement('ai-compose'),
    'escalate':  placement('park-for-operator'),
    'off-topic': placement('decline'),
  })

  // Routine branch: AI composes a canned reply -> send -> done.
  .node(placement('ai-compose'), aiCompose, {
    'drafted': placement('send-response'),
  })

  // Escalation branch: HITL suspension point.
  .node(placement('park-for-operator'), parkForOperator, {
    'parked': placement('end'),
    'ready':  placement('send-response'),
  })

  // Shared convergence: both routine and escalated paths flow through send-response.
  .node(placement('send-response'), sendResponse, {
    'sent': placement('end'),
  })

  // Off-topic branch: decline and close.
  .node(placement('decline'), decline, {
    'declined': placement('end'),
  })

  .terminal(placement('end'), { 'outcome': 'completed' })

  .build();
  • Lifecycle hooks to progress events. The Dispatcher observer converts engine hooks into TraceEvent records.
  • Multiple subscribers. The same hook updates the text trace, DAG graph, and log feed.
  • Browser-visible progress. The right-side Trace tab and DAG tab subscribe to these events.
  • Transport option. For server transports, @studnicky/dagonizer/progress still provides EventBus and SseStream; the browser runnable demonstrates the hook-to-progress boundary.

Watched over by the Order of Dagon.