Documentation / Conception / Guides pratiques / Sequence and join with after/done

Sequence and join with after/done

The DSL is dataflow — ordering normally falls out of wiring one node's output into another's input. after/done cover what's left over: starting a node once something else has happened, or waiting for several branches to finish, without any of them having a value worth carrying forward. This recipe uses the real workflow at content/clinical-demo/workflows/analyse-posos.workflow.yaml — the only shipped workflow that reaches for these two ports today — as the running example.

Prerequisites

  • A workflow file to edit — see Your first workflow if you don't have one yet.
  • Comfort with plain port wiring; see Wire ports if from/to connections are new to you.
  • Helpful, not required: Branch with guards and switch — most after wiring in this recipe starts from a guard's pass or a switch's case output.

Know the two ports before you wire them

Every node type gets these for free — you never declare them in a plugin manifest:

  • done (output) — fires once the node has produced: at least one of its declared outputs was actually emitted, or the node type declares no outputs at all (a pure sink/action). A flow.guard that didn't pass, or a compute that returns {}, does not fire it.
  • after (input, on every node except the trigger) — an AND-join: wire one or more sources into it and the node runs only once every wired source has fired. If any is absent, the node is skipped, the same way a node with an absent required input is.

Both are added by nodePorts() (libs/shared/src/engine/dynamic-ports.ts) and never reach a behavior's run/propose — the interpreter resolves and gates on them itself, before a node's declared inputs are even computed (libs/engine-core/src/engine/interpreter.ts). Whatever you wire into after is ignored as data; only its presence matters, at the same point in the run where absence propagation already governs everything else. Both ports are typed any, which is deliberately transparent to every other type (isAssignable in libs/shared/src/engine/type-system.ts treats any on either side as compatible) — that's why a data output like a switch case, not just done, can feed .after directly.

Start a node from the trigger without wiring its payload

Wire the trigger's done output into the after input of a node that doesn't need anything from the triggering event itself:

connections:
  - from: trigger.done
    to: load_meds.after

load_meds (patient.load-medications) reads only the ambient patient context, nothing from the event — so this is the only connection that ties it to the run at all.

Gate a node whose real inputs don't carry the branch's signal

This is what after is actually for: a node whose inputs are all fixed inputs: literals has nothing upstream for absence propagation to act on — wire the branch's output into .after instead.

One of several flow.switch cases:

nodes:
  make_alert_obs:
    type: transform.make
    config:
      type: Observation
    inputs:
      status: final
      code:
        code: critical-alert
        display: Alerte POSOS
      value:
        value: 1
        unit: alert
      effective: 2024-01-01T00:00:00Z
connections:
  - from: check_alerts.critical
    to: make_alert_obs.after

Every field of make_alert_obs is a fixed literal — there's nothing to wire it to check_alerts with. Without .after, transform.make would run on every event, since all of its required inputs are already satisfied. .after is what confines it to the critical case.

A human-task's outcome, along one of two mutually exclusive branches:

nodes:
  notify_pharmacist:
    type: notify.send
    config:
      severity: critical
    inputs:
      title: Escalade Pharmacien
      body: Une alerte critique POSOS n'a pas été traitée par le prescripteur et
        nécessite une révision pharmaceutique.
  notify_ok:
    type: notify.send
    config:
      severity: info
    inputs:
      title: Alerte traitée
      body: Le prescripteur a pris en charge et acquitté l'alerte POSOS.
connections:
  - from: prescriber_task.escalation
    to: notify_pharmacist.after
  - from: prescriber_task.acknowledged
    to: notify_ok.after

prescriber_task (human.acknowledge-critical) emits exactly one of acknowledged/escalation per run — see Add human validation. Each notify.send carries its own inline title/body, so .after is what ties it to the outcome that actually happened.

Compare with content/clinical-demo/workflows/critical-result-sla.workflow.yaml, which routes the same kind of outcome straight into real data inputs instead:

# content/clinical-demo/workflows/critical-result-sla.workflow.yaml
connections:
  - { from: acknowledge.acknowledged, to: record_ack.decision }
  - { from: acknowledge.escalation,   to: escalate.result }

record_ack/escalate consume decision/result directly, so ordinary absence propagation already gates them — no .after needed (see Branch with guards and switch). Reach for after only when, as with notify_pharmacist/notify_ok, the downstream node's own inputs don't naturally carry the upstream signal.

Chain a step after a sink that produces nothing

A sink/action node with no declared outputs: (notify.send among them) still fires done the moment it runs — the interpreter's withDone treats "no outputs declared" as having produced (libs/engine-core/src/engine/interpreter.ts). Wire it forward exactly like any other .done:

connections:
  - from: notify_pharmacist.done
    to: escalation_logged.after

Neither notify_pharmacist nor notify_ok chains into anything further in the shipped workflow, but the wiring is identical to the trigger case above — any node's done, not only the trigger's, can start the next step.

Join two or more branches (AND-join)

Wire every branch's .done (or any other output) into the same node's .after; the joined node runs only once all of them have fired for that run:

connections:
  - from: branch_a.done
    to: synthesis.after
  - from: branch_b.done
    to: synthesis.after

If either branch is skipped — a guard that didn't pass, a switch case that didn't match, a required input that never arrived — synthesis is skipped too. This is afterBlocked in libs/engine-core/src/engine/interpreter.ts: a node with connections on .after runs only once every one of them has resolved to a value; any single one still absent blocks it. There is no OR-join — to run something when any branch fires, wire .after from each branch into its own separate downstream node instead of one shared node.

Verify

pnpm plugin validate content/clinical-demo/workflows/analyse-posos.workflow.yaml

See Validate a workflow for the full command and error catalogue — the same port/type checks apply to after/done as to any other connection, since they're ordinary (typed any) ports once resolved. Then simulate the workflow with data on both sides of each guard/switch/human-task decision and check the node statuses in the overlay: a node skipped only because an .after join wasn't satisfied reports node-skipped-after (map-skipped-after for a flow.map node), distinct from node-skipped-missing-input for an ordinary absent required input. Monitor executions covers reading the same codes off a live run.

Troubleshooting

  • A node still runs on every event despite being wired to .after — check whether one of its inputs is a fixed inputs: literal rather than a connection. A fixed value satisfies that input regardless of .after; .after only gates the node's own start, it doesn't make other inputs conditional.
  • A join never fires — confirm every branch feeding it actually reaches "produced" per the done rule above; a guard, switch case, or human-task outcome that doesn't fire on a given run holds the join open by design — that's the AND-join working as intended, not a bug.
  • Wiring something into a node's after seems to have no effect — a node type that already declares its own input literally named after keeps that declared port instead of the implicit flow one (libs/shared/src/engine/dynamic-ports.ts); none of the core primitives do this today, but a plugin's node type could.
75 documents9 sectionssource : /docs · généré au build