Skip to content

Lifecycle phases

PhaseNode placements run around the main DAG loop rather than inside it. They are registered like any other placement, mutate state, can throw, and are observed by the dispatcher hooks and the Instrumentation surface.

API surface

SymbolSourceRole
PhaseNode@noocodex/dagonizer/entitiesJSON Schema-derived placement type
PhaseNodeSchema@noocodex/dagonizer/entitiesThe JSON Schema
PhaseNodePlacementInterface@noocodex/dagonizerCompile-time narrowing of PhaseNode
DAGBuilder.phase(name, phase, nodeRef)@noocodex/dagonizer/builderFluent registration
Instrumentation.phaseEnter@noocodex/dagonizer/contractsFires before each phase placement
Instrumentation.phaseExit@noocodex/dagonizer/contractsFires after each phase placement

Instrumentation.phaseEnter and phaseExit are declared on the contract in packages/dagonizer/src/contracts/Instrumentation.ts.

Two arms

  • phase: 'pre': runs before the entrypoint, in DAG declaration order.
  • phase: 'post': runs after the main loop drains, in DAG declaration order, on every exit path.

Phase placements have no outputs field. They never route to other placements. They are never the main-loop entrypoint.

When to reach for it

Pre/post phases fit the bootstrap/teardown shape:

  • Pre: warm a cache, attach an observability span, validate environment, bind a request-scoped logger to state.metadata.
  • Post: flush metrics, close a database handle, persist a final checkpoint, emit a flow-finished event.

These are real units of work that participate in the DAG. They are not the same as the onFlowStart / onFlowEnd observer hooks; those are observability-only, do not register as nodes, do not show up in executedNodes, and cannot mutate state through the NodeInterface contract.

Failure semantics

PathPre-phase throwsMain loop throwsPost-phase throws
Lifecyclefailedalready-set (failed, cancelled, timed_out)unchanged
Main loop executesnopartiallyn/a
Post-phases executeyesyesyes (errors collected as warnings)

Pre-phase failures abort the run before the entrypoint sees state. Post-phase failures never overwrite the lifecycle; they are collected as warnings on state with code POST_PHASE_FAILED and the loop continues with the next post-phase. This matches the best-effort teardown shape: a failed log flush does not flip a successful run into a failure.

ExecutionResult.executedNodes

  • Pre-phase names appear at the START (every pre-phase that ran without throwing).
  • Main-loop nodes appear in the MIDDLE.
  • Post-phase names appear at the END (every post-phase that completed without throwing).

A pre-phase that threw is not appended. A post-phase that threw is not appended.

Authoring

The fluent surface lives on DAGBuilder:

ts
import { DAGBuilder } from '@noocodex/dagonizer/builder';

const dag = new DAGBuilder('pipeline', '1')
  .node('ingest', ingestNode, { success: 'process' })
  .node('process', processNode, { success: null })
  .phase('warm-cache',  'pre',  warmCacheNode)
  .phase('flush-logs',  'post', flushLogsNode)
  .phase('close-db',    'post', closeDbNode)
  .build();

Phase placements are recorded in DAG declaration order. Order matters: warm-cache runs strictly before ingest; flush-logs runs strictly before close-db.

The hand-written JSON form is also accepted:

jsonc
{
  "@id":   "urn:noocodex:dag:pipeline/node/warm-cache",
  "@type": "PhaseNode",
  "name":  "warm-cache",
  "node":  "warm-cache-node",
  "phase": "pre"
}

Validation

At registerDAG time the engine verifies that every PhaseNode.node resolves to a registered node. A missing reference raises DAGError. The schema rejects an outputs field (no routing) and rejects any phase value outside 'pre' | 'post'.

Instrumentation

For every phase placement the dispatcher invokes:

ts
instrumentation.phaseEnter(dagName, 'pre' | 'post', placementName, state, placementPath);
// ... await node.execute(state, context)
instrumentation.phaseExit(dagName,  'pre' | 'post', placementName, state, placementPath);

See Observability for the full instrumentation surface.

Watched over by the Order of Dagon.