Flows and scripts
Both models run the same atoms. They differ in what they are made of, and that difference decides everything else — what happens on failure, what an operator can see, and how far the work can spread.
A flow is a plan
Section titled “A flow is a plan”You write a flow as a list of named nodes. Before anything runs, the engine reads the whole definition, resolves every task it references, validates the parameters, and produces an execution plan. Only then does the first node run.
That up-front pass is the reason a flow behaves the way it does:
- A typo in step nine fails before step one. A misspelled task name or a missing required parameter is a planning error, not a runtime surprise forty minutes into a build.
- Every step is a checkpoint. The run’s state — which node completed, what it produced, what the variables now hold — is written down as it goes.
- Steps can move. Because a node is a self-describing unit of work rather than a closure over local memory, the engine can hand it to a different worker, or a worker on a different machine.
name: nightly-backuplist: truevars: target: /srv/backupstasks: - name: dump shell: script: pg_dump app > /tmp/app.sql setvar: dump_path
- name: compress archive: operation: compress source: /tmp/app.sql destination: "{{target}}/app.tar.zst"
- name: upload s3: command: upload bucketname: backups s3url: "{{minio.url}}" commandparams: files: - path: "{{target}}/app.tar.zst" destination: /nightly/app.tar.zstThree nodes, three checkpoints. If the upload fails, the dump and the compression are still recorded as done.
A script is a calculation
Section titled “A script is a calculation”You write a script as a function. It is compiled, called with your arguments, and returns a value. There are no nodes and no plan — the language’s own control flow is the control flow.
function main(threshold) { const rows = db.query({ sql: 'select name, size from artifacts' }); const big = rows.rows.filter((r) => r.size > threshold);
return { count: big.length, total: big.reduce((s, r) => s + r.size, 0), names: big.map((r) => r.name), };}kis script run report.js 1048576Try writing that as a flow and the awkwardness is immediate: filtering, summing and mapping are three lines of ordinary code and three nodes of ceremony. The flow’s checkpointing buys you nothing here, because there is nothing to resume — if it fails, you run it again.
The contrasts that matter
Section titled “The contrasts that matter”| Flow | Script | |
|---|---|---|
| Unit | A node in a graph | A function call |
| Validated | Whole definition, before the first node | At compile, per file |
| State | Run variables, persisted per step | Local variables, in memory |
| On failure | The failed node is known; the run can resume or route to an error branch | The process ends; nothing is retained |
| On machine loss | Another worker picks the run up from its last checkpoint | The run is lost |
| Concurrency | Workers, and fan-out nodes over a collection | Whatever the language provides |
| Distribution | Across workers and machines | One process |
| Read by | Whoever operates it | Whoever maintains it |
| Good at | Sequencing, fan-out, long runs, partial recovery | Logic, transformation, arithmetic, decisions |
Failure is the real dividing line
Section titled “Failure is the real dividing line”Everything above follows from one question: when this breaks at 3am, what do you want to be true?
A flow answers with position. The run stopped at upload; dump and compress completed; here is
what the variables held. Resume it and the first two do not run again. Route the failure to a
fail: node and it terminates with a typed error someone can alert on. On losing a worker
mid-run, another one continues from the last checkpoint rather than starting over — restarting the
whole run is available, but it is the opt-in.
A script answers with a stack trace. That is often the better answer: for a pure computation, position is meaningless and rerunning is free.
Choose the model that matches the cost of a partial failure. When redoing the work is cheap, a script’s simplicity wins. When redoing it means another forty minutes of build time or a second full table scan, a flow’s bookkeeping pays for itself the first time it is needed.
They compose
Section titled “They compose”The choice is not architectural, and it is not permanent.
A flow runs a script. When one step needs real logic, the script: task embeds it — inline or
from a file — using the same engine and calling convention as kis script:
tasks: - name: pick-candidates script: language: javascript file: ./select.js setvar: candidates next: go: process
- name: process map: items_path: candidates task: handle-one max_concurrency: 4
- name: handle-one shell: ./process.sh {{_item}}The script decides what to work on; the flow fans out over the answer and survives losing a worker halfway through. Each model does the part it is good at.
A script becomes a flow. Because the calling convention is identical on both sides, a script
you developed with kis script run moves into a flow unchanged — same file, same entry point, same
arguments. There is no rewrite step, which is what makes starting with a script safe: you are not
committing to it.
tasks: - name: select script: language: javascript file: ./select.js args: [1048576] setvar: resultkis script run select.js 1048576The usual working order is: prototype as a script because the edit-run loop is a second long; move it into a flow when it needs to run somewhere other than your terminal.
A rule of thumb
Section titled “A rule of thumb”Write a script first. Reach for a flow the moment you need the run to survive something — a failure, a restart, a machine, or a person other than you.
See also
Section titled “See also”- Tasks and functions — one atom, both surfaces
- Flows — the flow file, node types, execution
- Scripts — languages, calling convention, reach