foliot docs

Concepts / Concepts

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/poll and tasks/{token}/complete in exactly two cases: a worker that cannot be pushed to (declare queue on the step), or a handler that answers 202 because 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:

SituationUse
Stop it permanently; it should not have runcancel
It failed or is stuck, and the cause is now fixedredrive
It finished, or you must go back further than the current steprewind
This step was done out of band; carry on past itskip
A handler recorded a wrong value that a later step readsstate 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.