Skip to main content

Architecture

One idea explains the shape of the whole system: the execution plan is the boundary.

Your pipeline.ts is TypeScript with closures. What runs is JSON. Everything upstream of that JSON exists to produce it; everything downstream consumes it and has never heard of TypeScript. That's what lets the same pipeline run on your laptop, in someone else's CI, and on a server that has never seen your source.

The two-phase model explains what that means when you're writing a pipeline. This page is about how the packages are arranged around it.

The layers

Dependencies point downward only. That's the whole rule, and three specific consequences of it are worth stating.

@flowwright/core has no dependencies

Not "few" — none. dependencies is empty. It's the authoring SDK plus the IR types, and it is the one package your pipeline.ts imports directly, so anything it dragged in would become something you'd have to install.

It does use Node built-ins (hashFiles reads the disk), so it isn't browser-safe. The guarantee is about your dependency tree, not about where it can run.

@flowwright/api-contract is IO-free

No node: imports at all, one dependency (zod). It's the schemas the server, the CLI, the workers and the web UI all agree on.

The proof is @flowwright/web: a browser app that depends on api-contract and nothing else. If a node:fs import ever crept in, the UI build would break.

@flowwright/runtime knows nothing about servers

The local engine — runPlan, the executors, the event stream — has no idea the control plane exists. control-plane depends on runtime, never the reverse.

This is why flow run on your laptop and a run on a self-hosted worker execute the same function. There is no "local mode" branch to drift.

The layering is checked, not just intended

pnpm graph:check compares the real dependency graph against an exhaustive list. An edge that isn't on the list fails the build, so core → runtime can't be added quietly. It used to only check that expected edges were present, which meant it couldn't catch a violation at all.

Loading a pipeline

flow run doesn't import your pipeline.ts. It spawns a child process that does.

flow run
└─ spawn: node --import tsx load-child pipeline.ts
├─ imports your pipeline.ts
├─ runs every stage body once (the recording pass)
└─ prints <marker>{"version":2,…} to stdout
└─ parse the JSON after the marker
└─ validate it
└─ runPlan(plan)

Three things about that are deliberate.

Arbitrary code runs in a process that isn't the CLI. Your pipeline is TypeScript that can do anything at import time. Keeping it out of the long-lived process keeps whatever it does from affecting the run.

Only the serializable IR crosses back. Not closures, not handles — JSON on stdout, after a marker so a stray console.log in your pipeline can't corrupt the parse.

Module resolution is redirected, in both directions. @flowwright/core and any @flowwright/plugin-* your pipeline imports resolve to the runtime's own copies, not whatever is in the checked-out repo. Two reasons:

  • The recording singleton must be the same instance your pipeline imports. Two copies of core means the stage bodies record into an object the builder never reads, and you get an empty plan.
  • Your repo might not have them. A Java or Go repo with a pipeline.ts and no node_modules still works.

Both module systems are patched — the CommonJS resolver and the ESM hook — so it works whether your pipeline loads as ESM or CJS. A package the runtime doesn't carry falls through to normal resolution, and the redirect applies only while loading the definition: your stages run against your repo's own dependencies.

How the scheduler picks

Once the plan is JSON, runPlan walks it in topological order.

A stage is ready when every dependency has a decided status — succeeded, failed or skipped. Not "started": decided. Concurrency is a cap on stages in flight; when one finishes, the scheduler re-scans the whole order for whatever is now ready, so ties break by plan order rather than by who was waiting longest.

What happens when something fails — which stages get skipped, what continueOnError changes, and why in-flight stages are allowed to finish — is on Dependencies and ordering.

Where things live

PackageDoes
coreThe DSL you import, and the IR types
runtimerunPlan, executors, the event stream, the loader
plugin-*Seven shared libraries of stage helpers
api-contractSchemas shared by every process
control-planeServer logic, framework-independent
store-postgres · store-s3Swappable state and artifact backends
provider-github · provider-gitlabSource-control adapters
outbound-networkProxy-aware outbound transport
cliThe flow binary
server-host · worker-host · webThe deployable applications

The applications are compositions — they wire libraries together and own no logic of their own. That's why control-plane can be embedded in something else, and why the server and worker are the same image with different entrypoints.

Everything except the three applications is published to npm. The field-by-field IR contract is the reference.