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{
"@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 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:
onNodeStartappends astarttrace event and marks the DAG node active.onNodeEndappends anendtrace event, marks the node completed, and flashes the traversed edge.onErrorappends anerrortrace event and marks the node errored.
Run
npm run docs:devWhat 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.
/**
* 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
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
TraceEventrecords. - 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/progressstill providesEventBusandSseStream; the browser runnable demonstrates the hook-to-progress boundary.
Related Concepts
- Observability guide - full hook reference and EventBus multiplexing patterns
- Example 18: Observability - subclass hooks: onFlowStart, onFlowEnd, onNodeStart, onNodeEnd, onError
- Example 20: Streaming execution - async-iterable execution API: per-node progress events