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 receives (GET /api/workflowslists both,pinnedwhen the trigger has its own address,triggerProblemwhen its facts carry a problem —event-absentfor no event chosen,trigger-unknownfor a trigger missing from the catalogue,event-unknownfor an event no loaded plugin declares —, and anexamplepayload per workflow). Theexampleis the trigger's example event: the declared example plus a<kind>-exempleplaceholder for each required context kind it lacks (patientId: "patient-exemple"). Replace the placeholders with real identifiers before sending it. - 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
An event whose type a loaded trigger receives starts one run for each
published workflow that receives it — every workflow of that type without a
trigger filter, and every one whose filter keeps the event (see
Choose which events start a workflow).
A declared event is received by the workflows on its dedicated triggers and by
those on Événement reçu configured on it.
The event is refused when no trigger claims its type (an undeclared type that no
loaded trigger receives), when no published
workflow receives it, when its type has its own address (webhook, schedule,
sub-workflow call, EAI reception) and the body does not designate a workflow
with __workflowFile, or, under EVENT_CONTRACTS=enforce, when it does not
conform to the contract of its declared type.
From the command line
Send the fields of the event's contract with their JSON types, plus the
identifiers of its required context kinds. For BioResultReceived: analyte
(String, required), value (Decimal, required, a JSON number), unit and the
other optional fields, patientId and encounterId. The contract is shown in
the trigger inspector (Declared event) and on the Plugins page
(Declared events). Under EVENT_CONTRACTS=observe, the default, a missing
field is not refused and the trigger has no value for it; under enforce, the
event is refused with its issues (see
Contract check).
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"}'
GET /api/openapi.json describes this operation, with one body schema per accepted
event type (see
OpenAPI description).
The response lists the runs the event started, in file order:
{
"eventId": "evt-3f2b8c1e-5d4a-4b7e-9c61-2a8f0d7e4b19",
"runs": [
{ "id": "evt-3f2b8c1e-5d4a-4b7e-9c61-2a8f0d7e4b19:bio-result", "created": true, "match": "unfiltered", "status": "suspended", "…": "…" }
]
}
Each entry is the run's view plus created (false for a run the event had
already started) and match (the reason of the delivery). A run's id is
<eventId>:<slug>, the event id followed by the workflow file name without
.workflow.yaml. The status codes, refusal messages, match values and run id
rules are in Event ingestion API.
- Send your own
eventIdif you may retry. Without one, the server generatesevt-<uuid>and a retry is a new event. With it, posting the sameeventIdagain starts no second run of a workflow that received it, and after a503only the missing runs start. - Designate one workflow with
"__workflowFile": "<file>.workflow.yaml"instead of starting every workflow that receives the type. The designated workflow's filter is not evaluated. - Run a stored draft (a spec saved but never published: bench fixtures
seeded from
workflows/drafts/, work in progress) by adding"__allowDraft": truenext to"__workflowFile"; the console's composer sets it for you. The draft goes through the full publication checks first, and an invalid draft is refused with its errors. A draft run takes the run id of the published version: trying a draft with theeventIdof a real event takes the published version's place for that event. The composer sends the example without aneventId, so a fresh one is generated.
Then find the runs:
curl localhost:4000/api/runs # most recent first: id, workflowName, subjects, status, createdAt, sourceEventId, routeMatch, eventType
curl 'localhost:4000/api/runs?event=<eventId>' # the runs started by one event (older runs included, by their id)
curl localhost:4000/api/runs/<id> # full detail: status, pending, nodeStatus, trace, observations, output
Quote the run id in a URL path (encodeURIComponent): it contains :.
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 example event: the example of its
trigger's event, plus a
<kind>-exempleplaceholder for each required context kind the example lacks. Replace the placeholders with real identifiers when the workflow reads the record. With a target, only that workflow starts, whatever its filter. None: every workflow that receives the type keeps the current payload and starts every published workflow that receives its type, subject to their filters. - 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. An event sent without a target workflow can start several runs; the first one is selected. When no filter keeps the event, the composer stays open with Event filtered: no filter keeps it.
(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-3f2b8c1e-5d4a-4b7e-9c61-2a8f0d7e4b19:bio-result",
"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>:<runId> — see
libs/engine-core/src/engine/interpreter.ts). The resolve endpoint reads it off
the run; pass it back as pendingId to make sure your decision applies to that
validation and not to a later one of the same run (see below). 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","pendingId":"<pending.id>"}'
Body fields (apps/api/src/server/routes/runs.ts, opResolve in apps/api/src/server/runs.ts; decision in
apps/api/src/server/validation-resolve.ts):
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.choice— for a choice validation (itspending.options), theidof the chosen option; the decision is thenacceptedwith that choice. Required for a choice validation, refused for an accept / reject one.pendingId— optional, thepending.idof the validation you are deciding. When it is set and the run waits on another validation (the run moved on since you read it), the decision is not applied. Without it, the decision applies to whatever validation the run waits on.
The response is the run's refreshed view, read once the run has moved past the
decided validation (up to about 6 seconds): pending is now null, or the next
validation of the run, and the run resumes to completion or to its next
node/pause. When the run reaches completed or failed within that time, its
status is frozen in the run index at once, and later decisions on the run answer
409 without calling Restate.
Failure modes:
400— the body is not a JSON object, orpendingIdorchoiceis not a string.400—choix attendu parmi : …,choix « x » inconnu de la validation (…)orvalidation sans choix : décider par accepted ou rejected: the choice does not fit the pending validation.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).409—{"error":"décision portant sur une autre validation du run"}:pendingIdnames a validation the run no longer waits on. Read the run again and decide its current validation.409—{"error":"run terminé : aucune validation en attente"}: the run index already holds a terminal status for the run, and Restate is not called; or the run's invocation has ended in Restate while its state still shows the validation, for instance a killed run: the run is then frozenfailed, and no decision reaches it.409—{"error":"état du run indisponible dans Restate (HTTP <status>) : validation non appliquée"}: the Restate ingress answered an HTTP error when reading the run, as it does for a run past its retention window. A transient5xxanswer gives the same response: check the run before deciding again.409—{"error":"validation déjà tranchée"}: Restate refused the decision because the validation was already resolved, for instance by a concurrent resolution.500— the Restate ingress was unreachable, or answered another error, while reading, resuming or polling the run. The decision may or may not have been applied: check the run (GET /api/runs/<id>) before sending it again. Sending a decision that was already applied answers409.
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 — and the pendingId of the validation shown, so a decision taken on a
screen loaded before the run moved on is refused with the 409 above.
From a FHIR warehouse
With the fhir-validation-decided binding active, a decision written on the
validation's FHIR Task resumes the run without calling this endpoint. The
binding passes the Task's urn:ehr-lab:validation-pending identifier as
pendingId — see
Connect a FHIR warehouse.
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);failedfor the node that failed the run, with itserrorin the observation (thetriggerwhen the run failed before any node).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.
- Event ingestion API — the full
POST /api/eventscontract. - Glossary — run, human-task, HITL, trace code.