Onboarding, local setup, quality gates, and contribution conventions for the FlowOS monorepo.
flowos/
├── backend/ # Python service
│ ├── src/flowforge/
│ │ ├── domain/ # pure Python, no framework imports
│ │ ├── application/ # compiler, executor, scheduler, events, services
│ │ ├── infrastructure/ # repositories, queues, plugins, security
│ │ ├── observability/ # logging, metrics, tracing
│ │ └── presentation/ # FastAPI composition root + routers
│ ├── migrations/ # Alembic migrations
│ └── tests/
├── sdk/ # flowforge-sdk — node authoring primitives (zero deps)
│ ├── src/flowforge_sdk/
│ └── tests/
├── contracts/ # flowforge-contracts — plugin protocol wire contracts
├── frontend/ # React + TypeScript management UI
├── docs/ # this documentation set, and the README's screenshot producer under assets/
├── examples/ # sample workflows and a reference plugin
├── site/ # the landing page, one static file in the identity DESIGN.md defines
├── DESIGN.md # the visual identity every surface reads
└── docker-compose.yml
- Python 3.13+ (CI runs 3.14)
- uv
- Node.js 20+ (frontend)
# Python workspace (backend + sdk + contracts)
uv sync --all-packages
# Frontend
cd frontend && npm install && cd ..# Backend (dev mode, in-memory adapters, hot reload)
make dev-api # or: uv run --package flowforge uvicorn flowforge.presentation.main:app --reload --port 8000
# Frontend (separate terminal)
make dev-frontend # or: cd frontend && npm run devOn Windows, start the API without --reload when process-isolated plugins
are enabled:
uv run --package flowforge python -m uvicorn flowforge.presentation.main:app --port 8000Uvicorn's Windows reload worker uses a selector event loop, which cannot launch the subprocess transport required by process-isolated plugins. The API will isolate that plugin failure, but running without reload keeps all plugins available.
Seed the first (admin) account, then run a workflow through the UI or API. The
API docs are at http://localhost:8000/docs.
Every change must pass all gates. make check runs the Python gates; the
frontend gates run separately.
| Gate | Command |
|---|---|
| Format | uv run --package flowforge ruff format --check . |
| Lint | uv run --package flowforge ruff check . |
| Types | uv run --package flowforge mypy --strict |
| Tests (coverage) | uv run --package flowforge pytest --cov --cov-fail-under=95 -q |
| SDK tests | uv run --package flowforge-sdk pytest -q (from sdk/) |
| Frontend lint | npm run lint (from frontend/) |
| Frontend build | npm run build (from frontend/) |
| Plugin packaging | uv build --package flowos-demo-nodes |
| Frontend lint | cd frontend && npm run lint |
| Frontend build | cd frontend && npm run build (tsc + vite) |
CI runs the Python gates on every push and pull request
(.github/workflows/ci.yml). The frontend gates are not yet wired into CI —
run them locally when you touch the frontend.
Conventions enforced by the gates:
- Ruff: line length 100, double-quoted strings, isort with first-party
imports (
flowforge,flowforge_sdk,flowforge_contracts). - mypy: strict mode over
backend/src,sdk/src,contracts/src. - Coverage: ≥ 95% across
backend/src/flowforgeandcontracts/src/flowforge_contracts. - pytest:
asyncio_mode = auto, markers declared inpyproject.toml(integrationrequires Docker-backed PostgreSQL and Redis).
Adding an integration (an external service) means writing a plugin, not a built-in node: see
docs/plugins.mdand copyexamples/reference_plugin. The steps below are for nodes bundled with the engine itself.
- Create
backend/src/flowforge/infrastructure/nodes/<name>.pyas aflowforge_sdk.BaseNodesubclass (see the SDK guide and the existing nodes for the pattern). - Register it in
backend/src/flowforge/infrastructure/nodes/__init__.pythrough theflowforge.nodesentry-point group (re-export the class so the plugin is importable). - Test it in
backend/tests/infrastructure/nodes/following the existing node tests (returned failure on bad config, output shape, retry/timeout interaction where relevant).
The workflow aggregate lives in domain/workflow.py and validation is
collected by Workflow.create → validate(). Add a ValidationIssue for new
rules (codes/severities in domain/errors.py) and extend the compile step in
application/compiler.py if the plan needs new metadata.
- Change
infrastructure/models.py(SQLAlchemy 2 mapped classes onBase). - Generate a migration:
uv run --package flowforge alembic revision --autogenerate -m "describe change". - Update both the SQL adapter (
infrastructure/persistence/) and the in-memory adapter (infrastructure/repositories/) so behavior stays consistent in development mode. - Update
docs/database.mdif the schema is user-visible.
- Add Pydantic models to
presentation/api/schemas.py. - Add the route to the relevant router under
presentation/api/. - Gate it with
require_permission(...)(seedomain/identity.pyfor the permission enums) and add arate_limited(...)dependency for public or login endpoints. - Register the router in
presentation/main.pyand add tests inbackend/tests/presentation/.
- Follow the existing suites: domain tests (pure, fast), application tests (compile/execute/events), infrastructure tests, presentation tests (HTTP layer end-to-end with the test client), and the SDK suite.
- Aim to keep coverage ≥ 95%; new branches need tests.
- Async tests are collected automatically (pytest-asyncio in auto mode). Do not
construct async primitives (e.g.
asyncio.StreamReader) in sync tests — run them in an event loop instead. - Do not sleep to create a race. Drive the interleave: gate a node on an
asyncio.Event, move a claim's visibility deadline into the past, or hold a database session open on its first write. Every concurrency suite here does one of those, which is why they fail for a reason rather than sometimes.
The suite runs entirely on substitutes: fakeredis for the Redis adapter and
SQLite for the SQL ones. Both are faithful enough for semantics and neither is
the thing that runs in production, so a handful of guarantees are executable
but unexecuted by default. They are marked integration and skip unless
pointed at a real service:
docker run --rm -p 6379:6379 redis:7-alpine
FLOWOS_TEST_REDIS_URL=redis://localhost:6379/15 uv run --package flowforge pytest -m integration -qWhat that closes, and nothing else does: the in-progress key's TTL being the
clock that ends a visibility window, and WATCH aborting an acknowledgment
that a competing claim overtook. What remains open either way: PostgreSQL —
the SQL adapters' concurrent writes are exercised against file-backed SQLite in
WAL mode with separate connections, which is a real race but not Postgres's
MVCC.
Three more things become executable once a disposable PostgreSQL is reachable, none of which the suite above touches:
backend/tests/jepsen— checkpoint monotonicity and execution visibility proved against a real server, including killing it mid-write (FLOWOS_TEST_DESTRUCTIVE=1because these suitesSIGKILLthe process they are talking to; seebackend/tests/jepsen/conftest.pyfor the full precondition list). The queue-durability half of that package instead needs Redis and its own systemd unit to kill.backend/tests/infrastructure/test_arq_job_queue_integration.py— the queue's ownership fence against a real Redis, notfakeredis.- The SQL repository suites under
backend/tests/infrastructure(test_sql_*.py,test_db.py) — parametrised to also run every case against PostgreSQL when a DSN is configured, in addition to the SQLite path every developer runs by default. PointFLOWOS_TEST_POSTGRES_DSNat the server and the extrapostgres-parametrised cases collect alongside thesqliteones; leave it unset and onlysqlitecollects, unchanged.
Standing up disposable containers to point those variables at (a Docker Desktop or Linux Docker install, not WSL/systemd):
docker run -d --name flowos-ci-postgres \
-e POSTGRES_USER=flowos -e POSTGRES_PASSWORD=flowos -e POSTGRES_DB=flowos \
-p 5432:5432 postgres:17-alpine
docker run -d --name flowos-ci-redis \
-p 6379:6379 redis:7-alpine redis-server --appendonly noexport FLOWOS_TEST_POSTGRES_DSN=postgresql+asyncpg://flowos:flowos@localhost:5432/flowos
export FLOWOS_TEST_REDIS_URL=redis://localhost:6379/0
export FLOWOS_TEST_DESTRUCTIVE=1
# Migrate the target database first — this is the only place the migration
# chain runs against real PostgreSQL rather than SQLite.
FLOWOS_DATABASE_URL="$FLOWOS_TEST_POSTGRES_DSN" uv run --package flowforge \
alembic -c backend/alembic.ini upgrade head
uv run --package flowforge pytest -q backend/tests/jepsen \
backend/tests/infrastructure/test_arq_job_queue_integration.py \
backend/tests/infrastructurerequires_postgres()/requires_redis() (backend/tests/jepsen/conftest.py)
skip a whole module unless the configured DSN or URL is both set and
actually reachable (a short socket probe at collection time) — a bad target
skips instead of failing every dependent fixture with a raw connection error.
A second, narrower precondition applies only to the tests in
test_queue_durability.py that use the redis_nemesis fixture: killing and
reviving a server needs a controller, not just a reachable one. That
controller is one of two things now:
- a systemd unit, reached through a prefix (
FLOWOS_TEST_SYSTEMCTL, defaultwsl -u root --, since these servers run in WSL on this workstation); - the Docker containers started above, addressed as
docker:<container-name>—FLOWOS_TEST_REDIS_CONTROLLER=docker:flowos-ci-redisandFLOWOS_TEST_POSTGRES_CONTROLLER=docker:flowos-ci-postgres.
Each service resolves its own controller variable first
(resolve_controller() in backend/tests/jepsen/nemesis.py), falling back to
FLOWOS_TEST_SYSTEMCTL when the service-specific one is unset — this is what
lets CI point Postgres and Redis at their own containers without a systemd
unit anywhere in the picture. The literal none still means what it always
has: no controller is available, so those two tests skip with "fault
injection needs a controllable server", whichever form is in play.
test_checkpoint_monotonicity.py and test_execution_visibility.py never
touch a nemesis, so a missing controller does not affect them either way; they
run whenever PostgreSQL is reachable.
The integration job in .github/workflows/ci.yml runs after quality-gates
against postgres:17-alpine and redis:7-alpine, started as plain docker run containers rather than GitHub Actions services: containers:
docker run -d --name flowos-ci-postgres \
-e POSTGRES_USER=flowos -e POSTGRES_PASSWORD=flowos -e POSTGRES_DB=flowos \
-p 5432:5432 postgres:17-alpine
docker run -d --name flowos-ci-redis \
-p 6379:6379 redis:7-alpine redis-server --appendonly noA services: container is a sidecar the job cannot reach with docker kill,
which is exactly what fault injection needs to do to it — a plain container
the job started itself, it can. The job waits for each with a pg_isready
and redis-cli ping retry loop before doing anything else, runs alembic upgrade head against the container Postgres, then the command above (jepsen,
the arq Redis integration suite, and the full backend/tests/infrastructure
directory with its PostgreSQL-parametrised cases included), and finally an
if: always() step that removes both containers whether the run passed or
failed.
FLOWOS_TEST_POSTGRES_CONTROLLER=docker:flowos-ci-postgres and
FLOWOS_TEST_REDIS_CONTROLLER=docker:flowos-ci-redis point each suite's
nemesis at the container carrying its name — resolve_controller() in
backend/tests/jepsen/nemesis.py reads the service-specific variable first
and falls back to FLOWOS_TEST_SYSTEMCTL (kept at none here, since there is
still no systemd unit for a wsl -u root ---style controller to address).
That means CI now runs real fault injection instead of skipping it: kill
issues docker kill --signal=KILL <container>, the real crude SIGKILL this
whole package is built around, not a graceful docker stop. pause/resume
use docker pause/docker unpause (the freezer cgroup, the closest Docker
analogue to SIGSTOP/SIGCONT), and revive polls the port until it actually
answers, not just until the container is running again — a restarted
container's postgres or redis process is not necessarily accepting
connections yet.
The two fault-injection tests in test_queue_durability.py that use the
redis_nemesis fixture now execute against flowos-ci-redis instead of
skipping (test_checkpoint_monotonicity.py and test_execution_visibility.py
never take a nemesis fixture, so they were never affected by this either way —
they already ran whenever PostgreSQL was reachable, and still do). The rest of
CI (quality-gates, plugin-packaging, frontend, live-integrations) is
unchanged and still runs on substitutes.
frontend/e2e drives the whole editor in a real Chromium browser: bootstrap
the first admin, create a workflow and configure it, run it and watch it
succeed, pause it on a core.wait node and resume it, and create a
credential. Nothing in npm test (vitest) touches a browser or a running
API — this suite is the only thing here that does.
It needs a live API and a live frontend dev server, started by hand first
(Playwright's webServer option cannot portably shell out to uv run across
this repo's machines and CI runners, so it is not used here — see
frontend/e2e/playwright.config.ts's comment):
uv run --package flowforge uvicorn flowforge.presentation.main:app --host 127.0.0.1 --port 8000cd frontend
npm run dev -- --host 127.0.0.1 --port 5173Then, from frontend/:
npm run e2eIf 8000/5173 are already taken by another dev stack, start both on 8001/5174
instead (VITE_PROXY_TARGET=http://127.0.0.1:8001 for the dev server) and
point the suite at it: E2E_BASE_URL=http://127.0.0.1:5174 npm run e2e.
The dev-mode API allows exactly one /auth/bootstrap call for its whole
process lifetime, so the suite bootstraps its one admin account itself in a
Playwright globalSetup (frontend/e2e/global-setup.ts) and saves its
signed-in storageState to frontend/e2e/.auth/; every spec file reuses that
session instead of bootstrapping its own. Restart the API between local runs
of the suite (or of the same spec twice) so that single bootstrap slot is free
again.
A failed run leaves an HTML report at frontend/playwright-report/index.html
— open it directly in a browser, or serve it with npx playwright show-report
from frontend/, to see each failing step's trace, screenshots, and network
log. CI uploads the same report (plus frontend/test-results) as a build
artifact whenever the e2e job fails.
- Read CONTRIBUTING.md and follow the Code of Conduct.
- Branch from
main, implement, and run all quality gates above. - Open a pull request; CI re-runs the Python gates.
- Update
CHANGELOG.mdunder[Unreleased]and the ROADMAP in the same commit as the feature they describe so the documents never drift. - Keep commits conventional-style (
fix(web): …,feat(engine): …,docs: …).
- Architecture and terminology: architecture.md
- How runs work: engine.md
- Writing nodes: sdk.md and
examples/reference_plugin - Endpoints and permissions: api.md
- Schema and migrations: database.md
- Running the stack: deployment.md