Run a workflow and resolve validations
This recipe drives one real run end to end against a running instance: emit the event a workflow listens for, follow it to a human validation, and resolve that validation — from the command line and from the console. It assumes the workflow already exists and the instance is up; see Stand up your first instance if it isn't yet, and Your first workflow if you haven't authored one before.
Prerequisites
- A running instance with the REST API reachable —
http://localhost:4000for a localpnpm run dev/docker compose up -d, or whatever Topology maps to in your deployment. - A loaded workflow whose
trigger.typeyou know, and theeventTypeit claims (GET /api/workflowslists both, plus a ready-to-sendexamplepayload per workflow). - At least one
human-tasknode on the path you're about to exercise — see Add human validation if the workflow doesn't have one yet.
Trigger a run
A run starts when an event matching a registered trigger's eventType is
ingested — the event model is open, so nothing is rejected unless no trigger
claims that type (apps/api/src/server/api.ts, ingestEvent).
From the command line
curl -X POST localhost:4000/api/events -H 'content-type: application/json' \
-d '{"type":"BioResultReceived","patientId":"patient-42","encounterId":"sejour-7","analyte":"creatinine","value":160,"unit":"µmol/L"}'
The response is the run's view (201 for a new run, see below). A few things
the ingestion path does for you, all in ingestEvent:
eventIdandoccurredAtare optional — if you omit them, the server generates an id (evt-xxxxxxxx) and stamps the current time.- Ingestion is idempotent by
eventId— POSTing the sameeventIdtwice doesn't start a second run: you get back the existing run's view with200instead of201(created: false). - An unclaimed
typeis rejected with400—Aucun déclencheur ne revendique l'événement « <type> ».(orÉvénement sans type.iftypeis missing/empty). CheckGET /api/workflowsfor theeventTypeeach loaded workflow's trigger actually claims. - To target a specific workflow file instead of relying on
eventTypematching, add"__workflowFile": "<file>.workflow.yaml"to the body — it's stripped before the event is stored. - Pinning is also how you run a stored draft (a spec saved but never
published — bench fixtures seeded from
workflows/drafts/, work in progress), but the fallback is opt-in: add"__allowDraft": truenext to"__workflowFile"(the console's composer sets it for you). With the flag, a pinned file that matches no active version falls back to the slug's draft, after running the full publication-gate validation — an invalid draft is rejected with400and the list of errors, never started. The run is indexed withspecVersion: nulland the draft'sspecHash: history shows exactly what code ran, without inventing a published version number. Without the flag a missed pin stays a400— that is what lets machine callers (the scheduler's orphan-schedule self-healing) keep trusting "workflow introuvable". AutomaticeventTyperouting never sees drafts either way.
Then find the run:
curl localhost:4000/api/runs # most recent first: id, workflowName, subjects, status, createdAt
curl localhost:4000/api/runs/<id> # full detail: status, pending, nodeStatus, trace, observations, output
From the console
- Open the config-plane at
http://localhost:3000and go to Executions. - Click + Emit an event (or Close if the composer is already open).
- Under Target workflow, pick the workflow. The Event payload (JSON)
box fills in with that workflow's
examplepayload. - Edit the payload if needed, then ▶ Send the event. The composer closes and the new run appears at the top of the Runs list, selected.
(apps/config-plane/app/executions/ExecutionsView.tsx.)
Follow the run to its pause
A run's status is one of running, suspended, completed, failed. Poll
GET /api/runs/<id> (or watch the console, which refreshes every couple of
seconds while a run is running/suspended) until status turns
suspended — that's a human-task node blocking on awaitValidation. The
run's pending field is then non-null:
{
"id": "confirm_condition:evt-a1b2c3d4",
"kind": "confirm_condition",
"title": "Add the condition \"Chronic kidney disease\"?",
"payload": { "code": "N18", "display": "Chronic kidney disease", "note": "eGFR 34 mL/min" }
}
(this particular shape comes from human.confirm-condition's propose in
external-plugins/clinical/behaviors/human.ts — another node's title/payload
will differ, but the envelope is the same.)
pending.id is the durable promise's key (<nodeId>:<eventId> — see
libs/engine-core/src/engine/interpreter.ts); you don't need to construct or
pass it yourself, the resolve endpoint reads it off the run. For the full
timeline view (trace, per-node observations, skip reasons), see
Monitor executions.
Resolve the pending validation
From the command line
curl -X POST localhost:4000/api/runs/<id>/resolve -H 'content-type: application/json' \
-d '{"decision":"accepted","by":"Dr Martin"}'
Body fields (apps/api/src/server/api.ts, opResolve):
decision— only the exact string"rejected"resolves as rejected; anything else (including a typo, or omitting the field) resolves as"accepted". Send one of the two literal values.by— free text recorded with the decision (shown in the trace and, where the workflow forwards it, in persisted content). Defaults to"Utilisateur"if omitted — pass a real value for an auditable trail.comment— optional free text, also recorded.
The response is the run's refreshed view — pending is now null and the run
resumes past the validation (to completion, or to its next node/pause).
Failure modes:
404—{"error":"run inconnu"}: no run with that id.409—{"error":"aucune validation en attente"}: the run has no pending validation right now (already resolved, not yet suspended, or timed out — see below).
From the console
On a suspended run, the detail panel shows Human validation required,
the pending payload, and ✓ Accept / ✗ Reject buttons. With the run
selected and no composer open, the keyboard shortcuts A (accept) and R
(reject) do the same thing. Both send by as the current UI locale's
"resolved by" label — pass a real reviewer name via the API if you need one
recorded.
Handle a deadline that expires before you decide
A human-task node can carry config.deadlineSeconds; past that many seconds
without a decision, the durable timer wins the race and the run takes its
escalation branch instead — resolve no longer has anything to act on
(409, aucune validation en attente). This is a real gate from
content/clinical-demo/workflows/critical-result-sla.workflow.yaml:
nodes:
acknowledge: { type: human.acknowledge-critical, config: { deadlineSeconds: 20 } }
Check pending (is there still one to resolve?) and trace (look for a
validation-timed-out entry) before retrying a resolve call on a run you
suspect may have escalated.
Verify the outcome
curl localhost:4000/api/runs/<id>
status—completed(orfailedif a later step errored) once every reachable node has run.trace— avalidation-resolvedentry (with yourdecision/by) or avalidation-timed-outone, followed by whatever ran next.nodeStatus/observations—donefor the validation node and every node downstream that received data;skippedfor the branch absence propagation cut (e.g. the escalation branch on a normal accept).output— the run's final per-node outputs, oncecompleted.
Related
- Add human validation — author the
human-tasknode this recipe resolves, including its deadline and escalation wiring. - Monitor executions — read a run's timeline, per-node observations, and skip reasons in depth.
- Simulate a workflow — exercise the same accept/reject/timeout branches without a durable run or a real event.
- Two-level validation — the model behind static validation vs. runtime type-checking of what a node emits.
- Stand up your first instance — bring up a stack to run this recipe against.
- Why durable execution — why a suspended run survives restarts and replays exactly once resumed.
- Environment variables and Topology — where the API actually listens in your deployment.
- Glossary — run, human-task, HITL, trace code.