Concepts
Runs, tasks and signals
Three nouns, three lifetimes, and the distinction that matters most is direction.
A run is one execution of a workflow, start to finish — order 7’s
fulfilment. It has a status, holds the input it was started with, accumulates
state as steps complete, and may live for seconds or for runTimeout: "14d".
This is the noun you and your customers care about.
A task is one attempt at one task step of one run. It is ephemeral: Foliot creates it when a run reaches a task step, POSTs it to your handler, and closes it when you answer or its deadline passes. A retry is a new task. One run therefore produces many tasks — one per task step, plus one per retry. If a run is the order, a task is “attempt #2 at charging the card”.
A signal is an external event delivered to a run that is waiting for it. A
wait step parks the run until a named signal arrives.
- Task = Foliot → your app. “Do this now.” You answer, and the run is blocked until you do.
- Signal = the world → Foliot. “This happened.” The run resumes.
One order, end to end:
POST /apps/orders/runs
{ "workflow": "order-fulfilment", "input": { "orderId": 7 },
"businessKey": "order-7" }
step "reserve" task Foliot POSTs to your handler; it returns
{ "reservationId": "R-1" }
-> state["reserve"] = { "reservationId": "R-1" }
step "await-payment" wait the run parks. status "waiting",
waitSignal "payment-confirmed".
Nothing runs. Nothing polls. Hours pass.
...Stripe's webhook reaches YOUR app, and you tell Foliot:
POST /apps/orders/signals
{ "workflow": "order-fulfilment", "businessKey": "order-7",
"name": "payment-confirmed" }
-> the run wakes
step "ship" task Foliot POSTs again
One run. Three tasks if nothing retried, five if reserve failed twice. One
signal.
Two consequences worth knowing up front:
- A signal that arrives before the run is waiting is buffered, not dropped. A payment webhook can beat your own database write; without buffering the event would be lost and the run would wait forever.
- Most handlers never touch a task token. A pushed handler answers inline.
You need
tasks/pollandtasks/{token}/completein exactly two cases: a worker that cannot be pushed to (declarequeueon the step), or a handler that answers202because the work outlives the request.
input versus state
Every run carries two payloads, and they are not interchangeable.
input { "orderId": 7 } immutable. What was asked for.
state { "reserve": { "reservationId": "R-1" }, accumulated. What happened.
"charge": { "captured": true } }
input is what you started the run with. No endpoint can change it — “what was
requested” is a claim that should not be rewritten after the fact.
state is each step’s return value, keyed by step name. It is how steps
communicate: charge reads state["reserve"] for the reservation id. Handlers
receive both. An operator can correct state through
PATCH /runs/{runId}/state when a handler recorded the wrong thing, and that
edit is recorded with the keys touched and the reason — but not the values, so a
correction never copies a large or sensitive payload into the history.
Workflows, versions and steps
A workflow definition is data, not code: steps, their order, retry policy,
timeouts. Foliot never evaluates anything you send — that is why a choice
condition is a {path, op, value} object rather than an expression.
Definitions are versioned, and a run pins to the version it started on for life. Registering v3 changes what new runs do and cannot disturb work in flight. Rewind is the only way to move an existing run across versions, and it is explicit.
Four kinds of step:
- task — the only kind that reaches your app. Pushed to your handler, or
offered to a puller when it declares a
queue. - sleep — park until a wall-clock time. No polling, no cost.
- wait — park until a named signal arrives, optionally with a timeout.
- choice — branch on a predicate over
{input, state}. Evaluated by the engine; no code runs.
A step’s next defaults to the following step in the list, and the last step’s
next defaults to $end. The sentinels are $end, $fail and $stuck.
Generations
Redrive and rewind bump a run’s generation. Every event and every task carries it, which is what keeps a re-run distinguishable from the original attempt in the history — and what lets Foliot ignore an answer from a task belonging to a superseded generation.
Intervening in a run
Five endpoints overlap, so choose by what you are fixing:
| Situation | Use |
|---|---|
| Stop it permanently; it should not have run | cancel |
| It failed or is stuck, and the cause is now fixed | redrive |
| It finished, or you must go back further than the current step | rewind |
| This step was done out of band; carry on past it | skip |
| A handler recorded a wrong value that a later step reads | state edit |
cancel is terminal — a cancelled run cannot be redriven, and rewind is the
way back into any finished run. skip takes an output so the skipped step’s
entry in state is still there for whatever reads it next.