f00b2fcb1e feat: drafts follow their item through a move; behind means base ≠ head (#10577)
* refactor: give home multi-select a reserved gutter and a menu entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep checkbox theming and reserve the gutter on non-selectable rows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: carry every draft with an item when it moves

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: move draft-only items and warn editors when an item moves

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: put the home selection checkbox back in the kind icon slot

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* feat: animate the home bulk bar and exit selection at zero

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* fix: keep dialog icon badges round and the panel inside narrow viewports

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* fix: address review findings on the draft-carry path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* fix: keep a staged rename when a move carries the draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restamp only the deployer's own carried draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope the moved-save restamp to the mover as well

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read the app move's author from the head version, not the draft's base

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry a flow draft's baseline path so deploying it cannot un-move the flow

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reject unsupported kinds in move_draft, survive NUL-poisoned draft rows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: skip NUL-poisoned rows in every draft-value rewrite, not just the first

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: report a NUL-poisoned draft on move instead of 500ing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: name the attempted operation in the NUL rejection message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop dead selection code and comments that outlived their state

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: describe script staleness as head-pinned, which is what the loader does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the third staleness comment left claiming a stable fork base

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address CI review — auth order, save race, carry failure, path validation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate operators earlier, skip the write tx without lineage, unblock a chained move

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: run the post-write moved re-assert under RLS, not the raw pool

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin the moved answer to what the saver can see

The post-write re-assert names a path and a username, and nothing at any
layer stopped it reading them off a raw pool connection. Swapping the
transaction back to `db.begin()` compiles and passes everything else, so
the guard has to be a test: a non-admin saving at a path whose item moved
into a folder they cannot see gets `saved`, while the admin gets `moved`.

Also drops two doc comments still arguing that clearing the write gate at
the old path removes the need for an RLS envelope. It does not — the gate
resolves the old path and the re-assert asks about the new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the real deploy path and stop restating the RLS constraint

`update_path` is not a symbol in this repo; a script move goes through
`create_script`. The re-assert's comment re-derived the disclosure argument
that already sits on `resolve_moved_to_in`, where a caller would break it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state the RLS and restamp constraints once each

The RLS envelope was argued at three sites in drafts.rs; it now sits only on
`resolve_moved_to_in`, whose signature is what a caller would break. The
restamp scoping was copy-pasted at all three deploy call sites while already
documented in full on `move_drafts_for_path`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry both path keys on a move, and grant the draft sequence

The upsert now runs as `windmill_user`, so it calls nextval on `draft_id_seq`
as that role. The only thing granting that is the ALTER DEFAULT PRIVILEGES in
20250205131523, whose DO block swallows failures — so an instance where it
errored would fail every autosave with `permission denied for sequence`.

A draft value carries two path keys: the typed one and a mirror the editors
keep in step with it while it differs from the row's path. Rewriting only the
typed one left the mirror naming the old location, and the loaders prefer the
mirror — reopening a moved session script restored the old path and the next
save un-did the move. Both keys now follow, in the move endpoint and in the
passive carry, under the same tri-state rule.

`typed_path_field` answered `draft_path` for every non-script kind, including
resources, variables and triggers, which have no such key. It returns `None`
for them now, and `move_draft` reads its guard off that mapping so the movable
set and the field mapping cannot drift apart.

Also documents that `move_drafts_for_path` mutates every owner's row and
enforces nothing itself, and parses the draft payload once per save instead of
three times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin the two-key move, and stop the down migration breaking instances

Revoking the sequence grant would strip a privilege a healthy instance had
before this migration ran — the grant it adds is indistinguishable in the
catalog from the one ALTER DEFAULT PRIVILEGES gives at creation time — so the
down is a comment, matching the other grant-only migrations.

The mirror rewrite is spread over three sites that have to agree and fails
silently when they don't, so it gets a test: a draft carrying both path keys
has both moved, and one carrying neither mirror does not gain one. It reads
the value back over HTTP rather than with `sqlx::query!`, which would need an
offline cache entry of its own.

Also drops twelve `.sqlx` entries this branch added and then superseded, and
corrects the doc and openapi text that still described only the typed path
being rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: point the empty down at the grant it is declining to revoke

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: drop the restamp and tri-state; a move relocates the draft row only

A deploy that renames an item is a deploy like any other: every draft on the item
goes stale, and the stale prompt with its diff is the single mechanism to catch up.
move_drafts_for_path now touches only the row's path column, so the value keeps the
base version the draft actually forked from, and the "moved" patch carries no
version restamp. DraftBaseVersion shrinks to the three per-kind lineage fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: stale prompt links to a diff that names and lets you pick the deployed version

The stale-draft prompt gains "See what changed", which opens the diff drawer. The
drawer resolves the deployed side by the draft row's own path (not the typed path,
which after a rename still names the archived row), labels which version the left
pane is, and offers a picker over the item's deployed history for scripts, flows
and raw apps. The history endpoints return created_by (and created_at for apps)
so each entry can name its deployer. "Restore to deployed" moves to the header
actions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: move_to asserts the response status

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a script draft's base at the version it forked from

The script editor seeded the draft's parent_hash from the deployed head on every
load, and the next autosave persisted it, so a draft behind the deploy read as up
to date after being opened once. The base now comes from the draft when one
exists; the head is only used for a fresh checkout or an explicit topHash. Deploy
already fetches the live head and confirms on mismatch, so the base is what makes
that check meaningful. The webhook "run this version" URL uses the deployed hash
rather than the draft's base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: store the version a draft forked from in one draft.base column

Every kind kept its fork base under a different name and type inside the
value: parent_hash (hex) for scripts, version_id for flows, parent_version for
apps. draft.base holds it as one text id, derived on save from the value so
every writer fills it the same way, backfilled by the migration (rows holding a
NUL are skipped, since ->> raises on them). The get-by-path overlay exposes it
as draft_base and the drafts list as base; the editors and the compare page
read that one field and compare it to the head as text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: raw-app drafts carry a fork base, so behind means base != head for them too

The raw-app bundle never carried the version it forked from, which left raw apps
on the timestamp check that self-heals as you type, and the header's deploy guard
read a version prop nothing set, so deploying over a newer version never asked.
The route now stamps parent_version into the bundle (the draft's own base when it
has one, else the head), the server derives draft.base from it, the stale prompt
compares it to the head and links to the diff, and the editor threads it to the
header so the deploy guard confirms. A deploy re-pins the base to the version it
wrote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: refuse a rename onto a path that already holds a draft

A draft occupies its path the way a deployed item does: a never-deployed item,
or a draft left on an archived script. Renaming onto it would either merge two
items or leave the losing row stranded at a path its item has left. The move now
refuses with a BadRequest inside the deploy's transaction, so the rename itself
fails and the source stays deployed. Every draft on the item then moves; there is
no longer a left-behind count to report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: save drafts by row id, so an open editor follows its draft through a move

A rename carries every draft on the item to the new path. An editor left open
across it was still saving by the path it opened on, which the server had to
refuse and answer with where the item went (the "moved" handshake and its
modal). The draft row has an id: the get-by-path overlay now returns it as
draft_id, every later save sends it, and the server writes the row wherever it
is and answers with that path. The editor then follows: it flushes what it holds,
tells the user, and navigates to the item's new path, where the stale prompt
says what changed. The lineage-based move resolvers, the moved status and the
moved modal are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: the out-of-date prompt names both versions and can take the latest as the new base

The prompt now says which version the draft forked from and which is deployed
(and by whom), instead of two timestamps, and gains "Take latest, keep my
edits": the draft's base moves to the head and its content stays, so the user
can acknowledge a newer version without discarding their work. Each route sets
its kind's base field on the draft value and persists it; the raw-app bundle
carries it already, so setting the state is enough there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: two-action out-of-date prompt; taking the latest moves into the diff drawer

Four buttons made the prompt hard to read. It keeps "See what changed" and a
red "Use latest" (it replaces the draft); closing it is keeping the draft.
"Take latest, keep my edits" moves to the diff drawer's header, offered only
while the draft is behind, so the user takes the latest with the diff in front
of them. Scripts, flows and raw apps pass the action through their diff drawer;
the classic app editor has no drawer wired to the prompt and loses it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: drop the draft_id_seq grant; the draft upsert runs on the raw pool

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a moved draft's path keys follow it, and a refused rename names the draft's owner

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow a moved draft on tab close, and deploy a followed flow at its new path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: write a followed draft by id against the row's own path keys; keep base on assign and clone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: look up a script's head at its row path, and show flow and app version ids bare

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: session editors save by draft id; raw apps keep a legacy draft's base unknown

Also advance the raw-app base on deploy, relocate once per move, drop the
hoisted operator check and the unread base on drafts/list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard a base-unknown raw-app deploy against the head at load; keep the base in session hydration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: the server follows a moved draft through a move record, not client-sent row ids

A move writes old path -> new path (per workspace and kind, per owner for a
draft-only move) in its transaction; a draft save or discard addressed to a
path the caller has no draft at resolves through it and keeps the moved
draft's path keys. Creating an item at a path drops the records leaving it.
Every writer (edit routes, sessions, chat, CLI, the tab-close flush) follows
without passing an id, so the id plumbing is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: session loaders keep a draft's base, and a failed relocation flush stays put

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a draft-only app move refuses the other app kind; a session keeps an unknown base unknown

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin a teammate's carried draft; name the kind that refuses a draft move

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an unknown base stays unknown in every loader, and an owner move extends an item move

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a clone keeps only a base it can resolve; a base-unknown script deploys without a false guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a workspace clone sanitizes a NUL-bearing draft instead of copying it unstripped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: move records follow an account rename and deletion; a legacy draft says why it cannot move

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an owner move extends only the item's own route, not another user's

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a redeploy ends a route off its path, take-latest persists on raw apps, stale picker loads are dropped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a poisoned draft's path keys follow a move, legacy only bypasses routing on a delete, picker loads are generation-guarded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: count picker load generations, and report a skipped legacy upsert as a conflict

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a legacy discard follows the item's move record too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a failed version load keeps the picker on what the diff shows; one spelling for a legacy delete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the picker marks the version on display as head, restore compares the head, relocation follows the last move

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: say so when a version fails to load in the diff picker

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take latest re-reads the head at click time; type the kept head as prepared diff data

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: taking the latest moves the head each editor knows, not just the base

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: take latest adopts the head the diff shows, and is offered while the drawer sees the draft behind

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a head nobody could name is not behind, so take latest is not offered without one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the flow drawer's head is the version its payload came from, and its callback type says so

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a NUL in a move's summary is dropped, and take latest simply adopts the head it was handed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a loaded raw-app draft keeps its own fork base, and an unknown head is refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a routed discard names where it landed, a superseded drawer opening is dropped, and a loaded draft keeps its base in every editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a legacy draft occupies its destination, a superseded opening writes nothing, and a loaded flow draft keeps no base it lacks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the drawer owns its opening, a loaded script draft keeps no base it lacks, and a legacy occupant says who can clear it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: taking the diff drawer without a token claims it, and the classic app editor takes one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a retried routed discard still names the destination, and filling the drawer takes the opening too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a no-op routed discard names the destination only to someone who could write there

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the no-op routed discard gates its answer on reading the destination, and a session draft keeps its unknown base

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: abandoning an opening clears the drawer it still owns

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an app deploy pins only a version it wrote as the next draft's base

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the deploy-override diff takes an opening its editor can hand back

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: pin the version this deploy wrote even when one landed on top, and tighten three comment blocks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a deploy claims only the version it appended to the head it read, and names the head separately

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a deploy always names the head it left behind, and pins a base only when it can claim one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the derived base is read after the sanitizer, and a deploy that claims nothing leaves no base to compare

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the route's lineage follows an in-place deploy, and the raw-app editor's event type carries the head

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the raw-app deploy comment says what that editor actually does with version

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a group member can be told where their item went, and a deploy names the head's author

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an emptied selection is no shift anchor, and a deploy leaves no draft for the prompt to compare

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: session tabs compare the same base pair, and a consumed draft is not out of date

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a failed anchor read is not a raced deploy, and take latest closes only its own drawer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an unclaimed deploy always confirms, and the prompt keeps warning a loaded teammate draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the base-unknown confirmation says what it knows, and two comments match the guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the other app kind collides whoever owns it, and session tabs get a head to compare

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the cross-kind refusal reads properly, and a session flow keeps its own response's head

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a fresh session checkout takes the head its payload came from, and a deploy keeps the base it pinned

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the move endpoint validates its source path, and two comments say what their branch does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an unanswered head read confirms rather than assuming the app editor is current

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a deploy is not blocked by the draft a move carried to its destination

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an unread head confirms with the copy for caution, not for an observed deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the move record alone excuses a carried draft at the destination, whoever owns it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: an app deploy answers with the version it wrote, so the editor stops inferring it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: the rename assertion reads the deploy's json answer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the unread-head warning reads as caution in the deploy drawer too, and the cross-kind refusal names a remedy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a reused destination retires the routes pointing at it, and draft_base stays out of diffs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the app head is the tail of app.versions, not the newest timestamp

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: app history lists in deployed order, so the picker numbers it right

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: the ordering test's setup sql compiles offline, and the head join names its app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: kinds that cannot move skip the move lookup, and the move wording needs read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: a deploy history comes a page at a time, so the diff drawer opens at once

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a history stays whole unless asked to page, and pages inside the version array

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an asked-for history page is bounded, and a failed one is not the end of the list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an unasked history is whole again, and an absurd page is empty not an error

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: naming only a page still asks for one, and a stray version stays reachable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a fork's nul-poisoned draft arrives clean, so its dangling identity repoints too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: a raw app names its deployed version even when the history will not load

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-18 20:54:23 +02:00
2024-02-08 16:09:11 +01:00
2026-02-03 17:50:56 +00:00
2026-09-18 15:28:25 +02:00
2023-08-26 09:17:07 +02:00
2026-08-19 14:18:37 +00:00
2025-04-21 16:53:23 +02:00
2026-04-16 06:26:25 -07:00
2025-12-08 10:34:16 +00:00
2025-03-07 15:08:19 +01:00
2022-05-05 04:25:58 +02:00
2024-02-08 16:09:11 +01:00

windmill.dev

Open-source developer platform for internal code: APIs, background jobs, workflows and UIs. Self-hostable alternative to Retool, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs and custom UIs to trigger workflows and scripts as internal apps.

Scripts are turned into sharable UIs automatically, and can be composed together into flows or used into richer apps built with low-code. Supported languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more.

Package version Docker Image CI Package version

Commit activity Discord Shield

Try it - Website - Docs - Discord - Hub - Contributing

Windmill - Developer platform for APIs, background jobs, workflows and UIs

Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated instances and commercial support and licenses.

Windmill Diagram

https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4

Main Concepts

  1. Define a minimal and generic script in Python, TypeScript, Go or Bash that solves a specific task. The code can be defined in the provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension): provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension):

Step 1

  1. Your scripts parameters are automatically parsed and generate a frontend.

Step 2

Step 3

  1. Make it flow! You can chain your scripts or scripts made by the community shared on WindmillHub.

Step 3

  1. Build complex UIs on top of your scripts and flows.

Step 4

Scripts and flows can be triggered by schedules, webhooks, HTTP routes, Kafka, WebSockets, emails, and more.

Build your entire infra on top of Windmill!

Show me some actual script code

//import any dependency  from npm
import * as wmill from "windmill-client";
import * as cowsay from "cowsay@1.5.0";

// fill the type, or use the +Resource type to get a type-safe reference to a resource
type Postgresql = {
  host: string;
  port: number;
  user: string;
  dbname: string;
  sslmode: string;
  password: string;
};

export async function main(
  a: number,
  b: "my" | "enum",
  c: Postgresql,
  d = "inferred type string from default arg",
  e = { nested: "object" }
  //f: wmill.Base64
) {
  const email = process.env["WM_EMAIL"];
  // variables are permissioned and by path
  let variable = await wmill.getVariable("f/company-folder/my_secret");
  const lastTimeRun = await wmill.getState();
  // logs are printed and always inspectable
  console.log(cowsay.say({ text: "hello " + email + " " + lastTimeRun }));
  await wmill.setState(Date.now());

  // return is serialized as JSON
  return { foo: d, variable };
}

Local Development

Windmill supports multiple ways to develop locally and sync with your instance:

Tool Description
CLI Sync scripts from local files or GitHub, run scripts/flows from the command line
VS Code Extension Edit and test scripts & flows directly from VS Code / Cursor with full IDE support
Git Sync Two-way sync between Windmill and your Git repository
Claude Code AI-assisted development with Claude for scripts, flows, and apps

https://github.com/user-attachments/assets/c541c326-e9ae-4602-a09a-1989aaded1e9

You can run scripts locally by passing the right environment variables for the wmill client library to fetch resources and variables from your instance. See local development docs.

Stack

  • Database: Postgres (compatible with Aurora, Cloud SQL, Neon, Azure PostgreSQL)
  • Backend: Rust - stateless API servers and workers pulling jobs from a Postgres queue
  • Frontend: Svelte 5
  • Sandboxing: nsjail and PID namespace isolation
  • Runtimes:
    • TypeScript/JavaScript: Bun (default) and Deno
    • Python: python3 with uv for dependency management
    • Go, Bash, PowerShell, PHP, Rust, C#, Java, Ansible

Fastest Self-Hostable Workflow Engine

We have compared Windmill to other self-hostable workflow engines (Airflow, Prefect & Temporal) and Windmill is the most performant solution for both benchmarks: one flow composed of 40 lightweight tasks & one flow composed of 10 long-running tasks.

All methodology & results on our Benchmarks page.

Fastest workflow engine

Security

  • Sandboxing: nsjail for filesystem/resource isolation, and PID namespace isolation (enabled by default) to prevent jobs from accessing worker process memory
  • Secrets: One encryption key per workspace for credentials stored in Windmill's K/V store. We recommend encrypting the Postgres database as well.

See Security documentation for details.

Performance

Once a job started, there is no overhead compared to running the same script on the node with its corresponding runner (Deno/Go/Python/Bash). The added latency from a job being pulled from the queue, started, and then having its result sent back to the database is ~50ms. A typical lightweight deno job will take around 100ms total.

Architecture

How to self-host

For detailed setup options, see Self-Host documentation.

Docker compose

Deploy Windmill with 3 files (docker-compose.yml, Caddyfile, .env):

curl https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env

docker compose up -d

Go to http://localhost - default credentials: admin@windmill.dev / changeme

Using an external database: Set DATABASE_URL in .env to point to your managed Postgres (AWS RDS, GCP Cloud SQL, Azure, Neon, etc.) and set db replicas to 0.

Kubernetes (Helm charts)

helm repo add windmill https://windmill-labs.github.io/windmill-helm-charts/
helm install windmill-chart windmill/windmill --namespace=windmill --create-namespace

See windmill-helm-charts for configuration options.

Cloud providers

Windmill works on AWS (EKS/ECS), GCP, Azure, Ubicloud, Fly.io, Render.com, Hetzner, Digital Ocean, and others. Rule of thumb: 1 worker per 1vCPU and 1-2 GB RAM.

OAuth, SSO & SMTP

Configure OAuth and SSO (Google Workspace, Microsoft/Azure, Okta) directly from the superadmin UI. See documentation.

License

The Community Edition is free to use internally. For commercial redistribution or managed services, contact sales@windmill.dev. See LICENSE and Pricing for details.

The "Community Edition" of Windmill available in the docker images hosted under ghcr.io/windmill-labs/windmill and the github binary releases contains the files under the AGPLv3 and Apache 2 sources but also includes proprietary and non-public code and features which are not open source and under the following terms: Windmill Labs, Inc. grants a right to use all the features of the "Community Edition" for free without restrictions other than the limits and quotas set in the software and a right to distribute the community edition as is but not to sell, resell, serve Windmill as a managed service, modify or wrap under any form without an explicit agreement.

The binary compilable from source code in this repository without the "enterprise" feature flag is open-source under the LICENSE-AGPLv3 License terms and conditions.

To re-expose directly any Windmill parts to your users as a feature of your product, with the exception of iframed public Windmill "apps", or to build a feature on top of "Windmill Community Edition" that you sell commercially or embed in a distributable product or binary, you must get a commercial license. Contact us at sales@windmill.dev if you have any questions. To do the same from the binary compiled from the source code in this repository without the "enterprise" feature flag, you must comply with the AGPLv3 license terms and conditions or get a commercial license from Windmill Labs, Inc.

To use Windmill "Community Edition" as is internally in your organization, or to use its APIs as is, you do NOT need a commercial license.

Integrations

In Windmill, integrations are referred to as resources and resource types. Each Resource has a Resource Type that defines the schema that the resource needs to implement.

On self-hosted instances, you might want to import all the approved resource types from WindmillHub. A setup script will prompt you to have it being synced automatically everyday.

Environment Variables

Environment Variable name Default Description Api Server/Worker/All
DATABASE_URL The Postgres database url. All
WORKER_GROUP default The worker group the worker belongs to and get its configuration pulled from Worker
MODE standalone The mode if the binary. Possible values: standalone, worker, server, agent All
METRICS_ADDR None (ee only) The socket addr at which to expose Prometheus metrics at the /metrics path. Set to "true" to expose it on port 8001 All
JSON_FMT false Output the logs in json format instead of logfmt All
BASE_URL http://localhost:8000 The base url that is exposed publicly to access your instance. Is overriden by the instance settings if any. Server
ZOMBIE_JOB_TIMEOUT 30 The timeout after which a job is considered to be zombie if the worker did not send pings about processing the job (every server check for zombie jobs every 30s) Server
RESTART_ZOMBIE_JOBS true If true then a zombie job is restarted (in-place with the same uuid and some logs), if false the zombie job is failed Server
NATIVE_MODE false Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) Worker
SLEEP_QUEUE 50 The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. Worker
KEEP_JOB_DIR false Keep the job directory after the job is done. Useful for debugging. Worker
EXIT_AFTER_N_JOBS None Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and /tmp survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. Worker
WORKER_SUFFIX None Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. Worker
LICENSE_KEY (EE only) None License key checked at startup for the Enterprise Edition of Windmill Worker
SLACK_SIGNING_SECRET None The signing secret of your Slack app. See Slack documentation Server
COOKIE_DOMAIN None The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin Server
DENO_PATH /usr/bin/deno The path to the deno binary. Worker
PYTHON_PATH The path to the python binary if wanting to not have it managed by uv. Worker
GO_PATH /usr/bin/go The path to the go binary. Worker
GOPRIVATE The GOPRIVATE env variable to use private go modules Worker
GOPROXY The GOPROXY env variable to use Worker
NETRC The netrc content to use a private go registry Worker
PY_CONCURRENT_DOWNLOADS 20 Sets the maximum number of in-flight concurrent python downloads that windmill will perform at any given time. Worker
PATH None The path environment variable, usually inherited Worker
HOME None The home directory to use for Go and Bash , usually inherited Worker
DATABASE_CONNECTIONS 50 (Server)/3 (Worker) The max number of connections in the database connection pool All
SUPERADMIN_SECRET None A token that would let the caller act as a virtual superadmin superadmin@windmill.dev Server
TIMEOUT_WAIT_RESULT 20 The number of seconds to wait before timeout on the 'run_wait_result' endpoint Worker
QUEUE_LIMIT_WAIT_RESULT None The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. Worker
DENO_AUTH_TOKENS None Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules Worker
DISABLE_RESPONSE_LOGS false Disable response logs Server
CREATE_WORKSPACE_REQUIRE_SUPERADMIN true If true, only superadmins can create new workspaces Server
MIN_FREE_DISK_SPACE_MB 15000 Minimum amount of free space on worker. Sends critical alert if worker has less free space. Worker
RUN_UPDATE_CA_CERTIFICATE_AT_START false If true, runs CA certificate update command at startup before other initialization All
RUN_UPDATE_CA_CERTIFICATE_PATH /usr/sbin/update-ca-certificates Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true All
GOOGLE_APPLICATION_CREDENTIALS None (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a gcloud resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the file, url and aws credential sources; the executable source is not supported. Server

Run a local dev setup

We recommend using Nix. See ./frontend/README_DEV.md for all options.

Frontend only

Uses the backend of https://app.windmill.dev with local frontend (hot-reload):

cd frontend
npm install
npm run generate-backend-client  # or generate-backend-client-mac on Mac
npm run dev

Windmill available at http://localhost/

Backend + Frontend

See the ./frontend/README_DEV.md file for all running options.

  1. Start a local Postgres database using for instance the start-dev-db.sh script which will make a database available at postgres://postgres:changeme@localhost:5432/windmill Then run the migrations using the following command:
    cargo install sqlx-cli
    env DATABASE_URL=<YOUR_DATABASE_URL> sqlx migrate run
    
    This will also avoid compile time issue with sqlx's query! macro.
  2. (optional, linux only) Install nsjail and have it accessible in your PATH
  3. Install bun, deno and python3 (+ any languages you want to use), have the bins at /usr/bin/bun,/usr/bin/deno, and /usr/local/bin/python3 or set the corresponding environment variables.
  4. (optional) Install the lld linker
  5. Go to frontend/:
    1. npm install, npm run generate-backend-client then REMOTE=http://localhost:8000 npm run dev
    2. You might need to set some extra heap space for the node runtime export NODE_OPTIONS="--max-old-space-size=4096"
    3. Create an empty frontend/build folder using mkdir frontend/build
  6. Go to backend/:
    1. env DATABASE_URL=<YOUR_DATABASE_URL> RUST_LOG=info cargo run
    2. You can specify any feature flag you want to enable, for example cargo run --features python to enable the python executor.
  7. Windmill should be available at http://localhost:3000

Contributing

At this time, we are not seeking outside contribution. Bug reports and feature requests remain very welcome, and small, trivially-verified PRs that fix a problem are still accepted. See CONTRIBUTING.md for the full policy.

Contributors

© 2023-2026 Windmill Labs, Inc.

S
Description
Open-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.
Readme
882 MiB
Languages
Rust 34.1%
TypeScript 25.4%
Svelte 24.1%
HTML 12.6%
JavaScript 1.4%
Other 2.2%