Documentation / Conception / Guides pratiques / Run a workflow and resolve validations

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:4000 for a local pnpm run dev/docker compose up -d, or whatever Topology maps to in your deployment.
  • A loaded workflow whose trigger.type you know, and the eventType it receives (GET /api/workflows lists both, pinned when the trigger has its own address, triggerProblem when its facts carry a problem — event-absent for no event chosen, trigger-unknown for a trigger missing from the catalogue, event-unknown for an event no loaded plugin declares —, and an example payload per workflow). The example is the trigger's example event: the declared example plus a <kind>-exemple placeholder for each required context kind it lacks (patientId: "patient-exemple"). Replace the placeholders with real identifiers before sending it.
  • At least one human-task node 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 eventId if you may retry. Without one, the server generates evt-<uuid> and a retry is a new event. With it, posting the same eventId again starts no second run of a workflow that received it, and after a 503 only 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": true next 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 the eventId of a real event takes the published version's place for that event. The composer sends the example without an eventId, 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

  1. Open the config-plane at http://localhost:3000 and go to Executions.
  2. Click + Emit an event (or Close if the composer is already open).
  3. 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>-exemple placeholder 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.
  4. 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 (its pending.options), the id of the chosen option; the decision is then accepted with that choice. Required for a choice validation, refused for an accept / reject one.
  • pendingId — optional, the pending.id of 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, or pendingId or choice is not a string.
  • 400 — choix attendu parmi : …, choix « x » inconnu de la validation (…) or validation 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"}: pendingId names 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 frozen failed, 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 transient 5xx answer 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 answers 409.

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 (or failed if a later step errored) once every reachable node has run.
  • trace — a validation-resolved entry (with your decision/by) or a validation-timed-out one, followed by whatever ran next.
  • nodeStatus / observations — done for the validation node and every node downstream that received data; skipped for the branch absence propagation cut (e.g. the escalation branch on a normal accept); failed for the node that failed the run, with its error in the observation (the trigger when the run failed before any node).
  • output — the run's final per-node outputs, once completed.
97 documents12 sectionssource : /docs · généré au build